add existing code
parent
ea9be93b92
commit
f6155f9510
@ -0,0 +1,47 @@
|
||||
plugins {
|
||||
java
|
||||
war
|
||||
id("org.springframework.boot") version "3.0.4"
|
||||
id("io.spring.dependency-management") version "1.1.0"
|
||||
}
|
||||
|
||||
group = "eu.jonahbauer"
|
||||
version = "0.0.1-SNAPSHOT"
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
configurations {
|
||||
all {
|
||||
exclude(group = "org.springframework.boot", module = "spring-boot-starter-logging")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("org.springframework.boot:spring-boot-starter")
|
||||
implementation("org.springframework.boot:spring-boot-starter-log4j2")
|
||||
implementation("org.springframework.boot:spring-boot-starter-validation")
|
||||
compileOnly("org.springframework.boot:spring-boot-devtools")
|
||||
|
||||
// Web
|
||||
implementation("org.springframework.boot:spring-boot-starter-web")
|
||||
implementation("org.springframework.boot:spring-boot-starter-security")
|
||||
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
|
||||
implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity6")
|
||||
providedRuntime("org.springframework.boot:spring-boot-starter-tomcat")
|
||||
|
||||
// Database
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||
implementation("org.flywaydb:flyway-core")
|
||||
implementation("org.mariadb.jdbc:mariadb-java-client:3.1.2")
|
||||
implementation("com.h2database:h2")
|
||||
|
||||
compileOnly("org.projectlombok:lombok:1.18.26")
|
||||
annotationProcessor("org.projectlombok:lombok:1.18.26")
|
||||
}
|
Binary file not shown.
@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
@ -0,0 +1,240 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=${0##*/}
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command;
|
||||
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||
# shell script including quotes and variable substitutions, so put them in
|
||||
# double quotes to make sure that they get re-expanded; and
|
||||
# * put everything else in single quotes, so that it's not re-expanded.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
@ -0,0 +1,91 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
@ -0,0 +1 @@
|
||||
rootProject.name = "survey"
|
@ -0,0 +1,13 @@
|
||||
package eu.jonahbauer.survey;
|
||||
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
|
||||
|
||||
public class ServletInitializer extends SpringBootServletInitializer {
|
||||
|
||||
@Override
|
||||
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
|
||||
return application.sources(SurveyApplication.class);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
package eu.jonahbauer.survey;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class SurveyApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(SurveyApplication.class, args);
|
||||
}
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
package eu.jonahbauer.survey.controller;
|
||||
|
||||
import jakarta.servlet.RequestDispatcher;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
@Controller
|
||||
public class ErrorController implements org.springframework.boot.web.servlet.error.ErrorController {
|
||||
|
||||
@RequestMapping("/error")
|
||||
public String handleError(HttpServletRequest request, Model model) {
|
||||
int statusCode = (Integer) request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
|
||||
|
||||
model.addAttribute("code", statusCode);
|
||||
|
||||
return "error";
|
||||
}
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
package eu.jonahbauer.survey.controller;
|
||||
|
||||
import eu.jonahbauer.survey.persistence.entity.Survey;
|
||||
import eu.jonahbauer.survey.persistence.entity.User;
|
||||
import eu.jonahbauer.survey.persistence.repository.SurveyRepository;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
@Controller
|
||||
@Getter(AccessLevel.PRIVATE)
|
||||
@RequiredArgsConstructor
|
||||
public class IndexController {
|
||||
private final SurveyRepository surveyRepository;
|
||||
|
||||
@GetMapping({"/index", "/", "/index.html"})
|
||||
public String index(Authentication authentication, Model model) {
|
||||
Pageable pageableRecent = PageRequest.of(0, 8, Sort.by(Sort.Direction.DESC, "created"));
|
||||
Page<Survey> recentSurveys = getSurveyRepository().findAll(pageableRecent);
|
||||
|
||||
Pageable pageableHot = PageRequest.of(0, 8);
|
||||
Page<Survey> hotSurveys = getSurveyRepository().findAllSortedBySubmissionCountDesc(pageableHot);
|
||||
|
||||
model.addAttribute("recent", recentSurveys);
|
||||
model.addAttribute("hot", hotSurveys);
|
||||
|
||||
if (authentication != null) {
|
||||
User user = (User) authentication.getPrincipal();
|
||||
Pageable pageableOwn = PageRequest.of(0, 8, Sort.by(Sort.Direction.DESC, "created"));
|
||||
Page<Survey> ownSurveys = getSurveyRepository().findAllByOwner(user, pageableOwn);
|
||||
model.addAttribute("own", ownSurveys);
|
||||
}
|
||||
|
||||
return "index";
|
||||
}
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
package eu.jonahbauer.survey.controller;
|
||||
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
@Controller
|
||||
public class LoginController {
|
||||
|
||||
@GetMapping("/login")
|
||||
public String get(@RequestParam(required = false) String error, @RequestParam(required = false) String success, Model model) {
|
||||
model.addAttribute("error", error != null);
|
||||
model.addAttribute("success", success != null);
|
||||
|
||||
return "login";
|
||||
}
|
||||
|
||||
@PostMapping(value = "/login", params = "register")
|
||||
public String register() {
|
||||
return "redirect:/register";
|
||||
}
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
package eu.jonahbauer.survey.controller;
|
||||
|
||||
import eu.jonahbauer.survey.model.RegistrationForm;
|
||||
import eu.jonahbauer.survey.persistence.entity.User;
|
||||
import eu.jonahbauer.survey.persistence.repository.UserRepository;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.validation.ObjectError;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
|
||||
@Controller
|
||||
@Getter(AccessLevel.PRIVATE)
|
||||
@RequiredArgsConstructor
|
||||
public class RegisterController {
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
@GetMapping("/register")
|
||||
public String get(Model model) {
|
||||
model.addAttribute("form", new RegistrationForm());
|
||||
return "register";
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
public String post(@Valid @ModelAttribute("form") RegistrationForm form, BindingResult bindingResult) {
|
||||
User user = getUserRepository().findByUsername(form.getUsername());
|
||||
|
||||
if (user != null) {
|
||||
bindingResult.addError(new ObjectError("form", new String[] {"registration.username.unique"}, null, null));
|
||||
}
|
||||
|
||||
if (bindingResult.hasErrors()) {
|
||||
return "register";
|
||||
} else {
|
||||
user = new User();
|
||||
user.setUsername(form.getUsername());
|
||||
user.setPassword(getPasswordEncoder().encode(form.getPassword()));
|
||||
form.setPassword(null);
|
||||
|
||||
getUserRepository().save(user);
|
||||
|
||||
return "redirect:/login?success";
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,98 @@
|
||||
package eu.jonahbauer.survey.controller.survey;
|
||||
|
||||
import eu.jonahbauer.survey.model.SurveyCreateForm;
|
||||
import eu.jonahbauer.survey.persistence.entity.Survey;
|
||||
import eu.jonahbauer.survey.persistence.entity.User;
|
||||
import eu.jonahbauer.survey.persistence.repository.SurveyRepository;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.validation.ObjectError;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
import java.util.HashSet;
|
||||
|
||||
@Controller
|
||||
@Getter(AccessLevel.PRIVATE)
|
||||
@RequiredArgsConstructor
|
||||
public class SurveyCreateController {
|
||||
private final SurveyRepository surveyRepository;
|
||||
|
||||
@GetMapping("/survey/create")
|
||||
public String get(Model model) {
|
||||
model.addAttribute("survey", new SurveyCreateForm());
|
||||
|
||||
return "survey/create";
|
||||
}
|
||||
|
||||
@PostMapping("/survey/create")
|
||||
public String post(
|
||||
@Valid @ModelAttribute("survey") SurveyCreateForm surveyForm,
|
||||
BindingResult bindingResult,
|
||||
Model model,
|
||||
RedirectAttributes redirectAttributes,
|
||||
Authentication authentication
|
||||
) {
|
||||
validateSurveyForm(surveyForm, bindingResult);
|
||||
|
||||
if (bindingResult.hasErrors()) {
|
||||
model.addAttribute("survey", surveyForm);
|
||||
return "survey/create";
|
||||
} else {
|
||||
Survey survey = surveyForm.toSurvey();
|
||||
survey.setOwner((User) authentication.getPrincipal());
|
||||
|
||||
survey = getSurveyRepository().save(survey);
|
||||
redirectAttributes.addAttribute("id", survey.getId());
|
||||
return "redirect:/survey/{id}/evaluate";
|
||||
}
|
||||
}
|
||||
|
||||
private void validateSurveyForm(SurveyCreateForm surveyForm, BindingResult bindingResult) {
|
||||
HashSet<String> questions = new HashSet<>();
|
||||
HashSet<String> answers = new HashSet<>();
|
||||
boolean uniqueQuestions = true;
|
||||
|
||||
if (surveyForm.getQuestions() != null) {
|
||||
int qIndex = -1;
|
||||
for (SurveyCreateForm.QuestionForm question : surveyForm.getQuestions()) {
|
||||
qIndex++;
|
||||
|
||||
uniqueQuestions &= questions.add(question.getText());
|
||||
|
||||
boolean uniqueAnswers = true;
|
||||
|
||||
if (question.getAnswers() != null) {
|
||||
answers.clear();
|
||||
for (String answer : question.getAnswers()) {
|
||||
uniqueAnswers &= answers.add(answer);
|
||||
}
|
||||
}
|
||||
|
||||
if (!uniqueAnswers) {
|
||||
bindingResult.addError(new FieldError(
|
||||
"survey", "questions[" + qIndex + "]",
|
||||
question, false,
|
||||
new String[] {"survey.questions.answers.unique"}, null, null
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!uniqueQuestions) {
|
||||
bindingResult.addError(new ObjectError(
|
||||
"survey",
|
||||
new String[] {"survey.questions.unique"}, null, null
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,42 @@
|
||||
package eu.jonahbauer.survey.controller.survey;
|
||||
|
||||
import eu.jonahbauer.survey.persistence.entity.Answer;
|
||||
import eu.jonahbauer.survey.persistence.entity.AnswerEvaluation;
|
||||
import eu.jonahbauer.survey.persistence.entity.Question;
|
||||
import eu.jonahbauer.survey.persistence.entity.Survey;
|
||||
import eu.jonahbauer.survey.persistence.repository.SurveyRepository;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
|
||||
@Controller
|
||||
@Getter(AccessLevel.PRIVATE)
|
||||
@RequiredArgsConstructor
|
||||
public class SurveyEvaluationController {
|
||||
private final SurveyRepository surveyRepository;
|
||||
|
||||
@GetMapping("/survey/{id}/evaluate")
|
||||
public String get(@PathVariable int id, Model model) {
|
||||
Survey survey = getSurveyRepository().findEvaluationById(id);
|
||||
|
||||
long sub = getSurveyRepository().getSubmissionCountById(survey.getId());
|
||||
|
||||
for (Question question : survey.getQuestions()) {
|
||||
for (Answer answer : question.getAnswers()) {
|
||||
AnswerEvaluation evaluation = answer.getEvaluation();
|
||||
long count = evaluation.getCount();
|
||||
|
||||
var percentage = sub > 0 ? count * 100d / sub : 0;
|
||||
evaluation.setPercentage(percentage);
|
||||
evaluation.setBarPercentage(percentage);
|
||||
}
|
||||
}
|
||||
|
||||
model.addAttribute("survey", survey);
|
||||
return "survey/evaluation";
|
||||
}
|
||||
}
|
@ -0,0 +1,70 @@
|
||||
package eu.jonahbauer.survey.controller.survey;
|
||||
|
||||
import eu.jonahbauer.survey.persistence.entity.Survey;
|
||||
import eu.jonahbauer.survey.persistence.repository.SurveyRepository;
|
||||
import eu.jonahbauer.survey.persistence.entity.User;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
@Controller
|
||||
@Getter(AccessLevel.PRIVATE)
|
||||
@RequiredArgsConstructor
|
||||
public class SurveyListController {
|
||||
private final SurveyRepository surveyRepository;
|
||||
private final MessageSource messageSource;
|
||||
|
||||
@GetMapping("/survey/recent")
|
||||
public String recent(Model model, Locale locale) {
|
||||
Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "created"));
|
||||
Page<Survey> surveys = getSurveyRepository().findAll(pageable);
|
||||
|
||||
model.addAttribute("surveys", surveys);
|
||||
model.addAttribute("title", getMessageSource().getMessage("index.heading.recent", null, locale));
|
||||
model.addAttribute("pattern", "list.data_pattern.recent");
|
||||
|
||||
return "survey/list";
|
||||
}
|
||||
|
||||
@GetMapping("/survey/own")
|
||||
public String own(@NotNull Authentication authentication, Model model, Locale locale) {
|
||||
if (authentication == null) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "created"));
|
||||
Page<Survey> surveys = getSurveyRepository().findAllByOwner((User) authentication.getPrincipal(), pageable);
|
||||
|
||||
model.addAttribute("surveys", surveys);
|
||||
model.addAttribute("title", getMessageSource().getMessage("index.heading.own", null, locale));
|
||||
model.addAttribute("pattern", "list.data_pattern.recent");
|
||||
|
||||
return "survey/list";
|
||||
}
|
||||
|
||||
@GetMapping("/survey/hot")
|
||||
public String hot(Model model, Locale locale) {
|
||||
Pageable pageable = PageRequest.of(0, 20);
|
||||
Page<Survey> surveys = getSurveyRepository().findAllSortedBySubmissionCountDesc(pageable);
|
||||
|
||||
model.addAttribute("surveys", surveys);
|
||||
model.addAttribute("title", getMessageSource().getMessage("index.heading.hot", null, locale));
|
||||
model.addAttribute("pattern", "list.data_pattern.hot");
|
||||
|
||||
return "survey/list";
|
||||
}
|
||||
}
|
@ -0,0 +1,121 @@
|
||||
package eu.jonahbauer.survey.controller.survey;
|
||||
|
||||
import eu.jonahbauer.survey.persistence.entity.Answer;
|
||||
import eu.jonahbauer.survey.persistence.entity.Question;
|
||||
import eu.jonahbauer.survey.persistence.entity.Submission;
|
||||
import eu.jonahbauer.survey.persistence.entity.Survey;
|
||||
import eu.jonahbauer.survey.persistence.repository.SubmissionRepository;
|
||||
import eu.jonahbauer.survey.persistence.repository.SurveyRepository;
|
||||
import eu.jonahbauer.survey.model.SurveyParticipationForm;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.validation.ObjectError;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Controller
|
||||
@Getter(AccessLevel.PRIVATE)
|
||||
@RequiredArgsConstructor
|
||||
public class SurveyParticipationController {
|
||||
private final SurveyRepository surveyRepository;
|
||||
private final SubmissionRepository submissionRepository;
|
||||
|
||||
@GetMapping("/survey/{id}/participate")
|
||||
public String get(@PathVariable int id, Model model) {
|
||||
Survey survey = getSurveyRepository().findDetailedById(id);
|
||||
model.addAttribute("survey", survey);
|
||||
model.addAttribute("form", new SurveyParticipationForm());
|
||||
return "survey/participate";
|
||||
}
|
||||
|
||||
@PostMapping("/survey/{id}/participate")
|
||||
public String post(
|
||||
@PathVariable int id,
|
||||
@ModelAttribute("form") SurveyParticipationForm form,
|
||||
BindingResult bindingResult,
|
||||
Model model
|
||||
) {
|
||||
Survey survey = getSurveyRepository().findDetailedById(id);
|
||||
|
||||
validateParticipation(survey, form, bindingResult);
|
||||
|
||||
if (bindingResult.hasErrors()) {
|
||||
model.addAttribute("survey", survey);
|
||||
return "survey/participate";
|
||||
} else {
|
||||
persistParticipation(survey, form);
|
||||
|
||||
return "redirect:/survey/{id}/evaluate";
|
||||
}
|
||||
}
|
||||
|
||||
private void validateParticipation(Survey survey, SurveyParticipationForm form, BindingResult bindingResult) {
|
||||
// validate all question ids are existent
|
||||
Set<Integer> questionIds = survey.getQuestions().stream().map(Question::getId).collect(Collectors.toSet());
|
||||
if (!questionIds.containsAll(form.getSelectedAnswers().keySet())) {
|
||||
bindingResult.addError(new ObjectError(
|
||||
"form",
|
||||
new String[] {"participation.survey.unknown_question"}, null, null
|
||||
));
|
||||
}
|
||||
|
||||
for (Question question : survey.getQuestions()) {
|
||||
Set<Integer> answerIds = question.getAnswers().stream().map(Answer::getId).collect(Collectors.toSet());
|
||||
Set<Integer> selectedAnswers = form.getSelectedAnswers().getOrDefault(question.getId(), Collections.emptySet());
|
||||
if (selectedAnswers == null) selectedAnswers = Collections.emptySet();
|
||||
|
||||
// validate all answer ids are existent
|
||||
if (!answerIds.containsAll(selectedAnswers)) {
|
||||
bindingResult.addError(new FieldError(
|
||||
"form", "selectedAnswers[" + question.getId() + "]",
|
||||
null, false,
|
||||
new String[] {"participation.question.unknown_answer"}, null, null
|
||||
));
|
||||
} else {
|
||||
// validate min answer count
|
||||
if (selectedAnswers.size() < question.getMinAnswers()) {
|
||||
bindingResult.addError(new FieldError(
|
||||
"form", "selectedAnswers[" + question.getId() + "]",
|
||||
null, false,
|
||||
new String[] {"participation.question.not_enough_answers"}, new Object[] { question.getMinAnswers() }, null
|
||||
));
|
||||
}
|
||||
|
||||
// validate max answer count
|
||||
if (selectedAnswers.size() > question.getMaxAnswers()) {
|
||||
bindingResult.addError(new FieldError(
|
||||
"form", "selectedAnswers[" + question.getId() + "]",
|
||||
null, false,
|
||||
new String[] {"participation.question.too_many_answers"}, new Object[] { question.getMaxAnswers() }, null
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void persistParticipation(Survey survey, SurveyParticipationForm form) {
|
||||
Submission submission = new Submission();
|
||||
submission.setSurvey(survey);
|
||||
submission.setAnswers(new HashSet<>());
|
||||
|
||||
Map<Integer, Answer> answersById = survey.getQuestions().stream()
|
||||
.flatMap(q -> q.getAnswers().stream())
|
||||
.collect(Collectors.toMap(Answer::getId, a -> a));
|
||||
|
||||
|
||||
form.getSelectedAnswers().values().stream()
|
||||
.filter(Objects::nonNull)
|
||||
.flatMap(Collection::stream)
|
||||
.map(answersById::get)
|
||||
.forEach(submission.getAnswers()::add);
|
||||
|
||||
getSubmissionRepository().save(submission);
|
||||
}
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
package eu.jonahbauer.survey.model;
|
||||
|
||||
import eu.jonahbauer.survey.validation.constraints.Password;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.Data;
|
||||
import org.hibernate.validator.constraints.Length;
|
||||
|
||||
@Data
|
||||
public final class RegistrationForm {
|
||||
@NotNull(message = "{registration.username.not_null}")
|
||||
@Length(min = 4, max = 255, message = "{registration.username.length}")
|
||||
@Pattern(regexp = "[a-zA-Z0-9_]*", message = "{registration.username.pattern}")
|
||||
private String username;
|
||||
|
||||
@NotNull(message = "{registration.password.not_null}")
|
||||
@Length(min = 8, max = 255, message = "{registration.password.length}")
|
||||
@Password(message = "{registration.password.password}")
|
||||
private String password;
|
||||
}
|
@ -0,0 +1,102 @@
|
||||
package eu.jonahbauer.survey.model;
|
||||
|
||||
import eu.jonahbauer.survey.persistence.entity.Answer;
|
||||
import eu.jonahbauer.survey.persistence.entity.Question;
|
||||
import eu.jonahbauer.survey.persistence.entity.Survey;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
import org.hibernate.validator.constraints.Length;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.TreeSet;
|
||||
|
||||
@Data
|
||||
public final class SurveyCreateForm {
|
||||
public static final QuestionForm EMPTY_QUESTION = new QuestionForm();
|
||||
|
||||
@NotBlank(message = "{survey.title.not_blank}")
|
||||
@Length(min = 8, max = 255, message = "{survey.title.length}")
|
||||
private String title;
|
||||
|
||||
@NotBlank(message = "{survey.summary.not_blank}")
|
||||
@Length(min = 8, max = 255, message = "{survey.summary.length}")
|
||||
private String summary;
|
||||
|
||||
@Size(min = 1, max = 20, message = "{survey.questions.size}")
|
||||
@NotNull(message = "{survey.questions.not_null}")
|
||||
@Valid
|
||||
private QuestionForm[] questions;
|
||||
|
||||
public QuestionForm[] getQuestions() {
|
||||
return questions;
|
||||
}
|
||||
|
||||
public SurveyCreateForm() {
|
||||
questions = new QuestionForm[] {new QuestionForm()};
|
||||
}
|
||||
|
||||
public Survey toSurvey() {
|
||||
Survey survey = new Survey();
|
||||
survey.setTitle(title);
|
||||
survey.setSummary(summary);
|
||||
survey.setQuestions(new TreeSet<>());
|
||||
|
||||
for (int i = 0; i < questions.length; i++) {
|
||||
Question question = questions[i].toQuestion();
|
||||
question.setNumber(i + 1);
|
||||
question.setSurvey(survey);
|
||||
survey.getQuestions().add(question);
|
||||
}
|
||||
|
||||
return survey;
|
||||
}
|
||||
|
||||
@Data
|
||||
public final static class QuestionForm {
|
||||
@NotNull(message = "{survey.questions.text.not_null}")
|
||||
@Length(min = 1, max = 255, message = "{survey.questions.text.length}")
|
||||
private String text;
|
||||
|
||||
@Min(value = 0)
|
||||
private int minAnswers = 0;
|
||||
|
||||
@Min(1)
|
||||
private int maxAnswers = 1;
|
||||
|
||||
@NotNull(message = "{survey.questions.answers.not_null}")
|
||||
@Size(min = 1, max = 20, message = "{survey.questions.answers.size}")
|
||||
private List<@NotBlank(message = "{survey.questions.answers.not_blank}") String> answers;
|
||||
|
||||
public Question toQuestion() {
|
||||
Question question = new Question();
|
||||
question.setText(text);
|
||||
|
||||
int min = Math.min(minAnswers, maxAnswers);
|
||||
int max = Math.max(minAnswers, maxAnswers);
|
||||
min = clamp(0, answers.size(), min);
|
||||
max = clamp(1, answers.size(), max);
|
||||
|
||||
question.setMinAnswers(min);
|
||||
question.setMaxAnswers(max);
|
||||
question.setAnswers(new TreeSet<>());
|
||||
|
||||
for (int i = 0; i < answers.size(); i++) {
|
||||
Answer answer = new Answer();
|
||||
answer.setText(answers.get(i));
|
||||
answer.setNumber(i + 1);
|
||||
answer.setQuestion(question);
|
||||
question.getAnswers().add(answer);
|
||||
}
|
||||
|
||||
return question;
|
||||
}
|
||||
|
||||
private int clamp(int min, int max, int value) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
package eu.jonahbauer.survey.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Data
|
||||
public final class SurveyParticipationForm {
|
||||
private final Map<Integer, Set<Integer>> selectedAnswers = new HashMap<>();
|
||||
}
|
@ -0,0 +1,60 @@
|
||||
package eu.jonahbauer.survey.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.hibernate.Hibernate;
|
||||
import org.hibernate.annotations.OnDelete;
|
||||
import org.hibernate.annotations.OnDeleteAction;
|
||||
|
||||
@Entity
|
||||
@Data
|
||||
@Table(name = "answer", uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uq_answer_question_number", columnNames = {"question", "number"})
|
||||
})
|
||||
@EqualsAndHashCode(of = "id")
|
||||
public class Answer implements Comparable<Answer> {
|
||||
@Id
|
||||
@Column(name = "id", nullable = false)
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Integer id;
|
||||
|
||||
/**
|
||||
* The answer text.
|
||||
*/
|
||||
@Column(name = "text", nullable = false)
|
||||
@NotBlank
|
||||
private String text;
|
||||
|
||||
/**
|
||||
* The consecutive number of this answer as part of a question.
|
||||
*/
|
||||
@Column(name = "number", nullable = false)
|
||||
@NotNull
|
||||
private Integer number;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "question", nullable = false, foreignKey = @ForeignKey(name = "fk_answer_question"))
|
||||
@OnDelete(action = OnDeleteAction.CASCADE)
|
||||
@NotNull
|
||||
private Question question;
|
||||
|
||||
@OneToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "id")
|
||||
private AnswerEvaluation evaluation;
|
||||
|
||||
@Override
|
||||
public int compareTo(Answer o) {
|
||||
return this.getNumber().compareTo(o.getNumber());
|
||||
}
|
||||
|
||||
public String toString() {
|
||||
return "Answer(id=" + this.getId() +
|
||||
", text=" + this.getText() +
|
||||
", number=" + this.getNumber() +
|
||||
(Hibernate.isInitialized(getEvaluation()) ? ", evaluation=" + this.getEvaluation() : "") +
|
||||
")";
|
||||
}
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
package eu.jonahbauer.survey.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
import org.hibernate.annotations.Immutable;
|
||||
|
||||
/**
|
||||
* A database view showing how often an {@link Answer} has been selected.
|
||||
*/
|
||||
@Entity
|
||||
@Data
|
||||
@Table(name = "answer_evaluation")
|
||||
@Immutable
|
||||
public class AnswerEvaluation {
|
||||
@Id
|
||||
@Column(name = "answer", nullable = false)
|
||||
private Integer answer;
|
||||
|
||||
@Column(name = "count", nullable = false)
|
||||
@NotNull
|
||||
private Long count;
|
||||
|
||||
@Transient
|
||||
private transient Double percentage;
|
||||
|
||||
@Transient
|
||||
private transient Double barPercentage;
|
||||
}
|
@ -0,0 +1,84 @@
|
||||
package eu.jonahbauer.survey.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.hibernate.Hibernate;
|
||||
import org.hibernate.annotations.OnDelete;
|
||||
import org.hibernate.annotations.OnDeleteAction;
|
||||
import org.hibernate.annotations.SortNatural;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
@Entity
|
||||
@Data
|
||||
@Table(name = "question", uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uq_question_survey_number", columnNames = {"survey", "number"})
|
||||
})
|
||||
@EqualsAndHashCode(of = "id")
|
||||
public class Question implements Comparable<Question> {
|
||||
@Id
|
||||
@Column(name = "id")
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Integer id;
|
||||
|
||||
/**
|
||||
* The question text.
|
||||
*/
|
||||
@Column(name = "text", nullable = false)
|
||||
@NotBlank
|
||||
private String text;
|
||||
|
||||
/**
|
||||
* The consecutive number of this question as part of a survey.
|
||||
*/
|
||||
@Column(name = "number", nullable = false)
|
||||
@NotNull @Min(0)
|
||||
private Integer number;
|
||||
|
||||
/**
|
||||
* The minimum number of answers that have to be selected.
|
||||
*/
|
||||
@Column(name = "min_answers", nullable = false)
|
||||
@NotNull @Min(0)
|
||||
private Integer minAnswers;
|
||||
|
||||
/**
|
||||
* The maximum number of answers that can be selected.
|
||||
*/
|
||||
@Column(name = "max_answers", nullable = false)
|
||||
@NotNull @Min(1)
|
||||
private Integer maxAnswers;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "survey", nullable = false, foreignKey = @ForeignKey(name = "fk_question_survey"))
|
||||
@OnDelete(action = OnDeleteAction.CASCADE)
|
||||
@NotNull
|
||||
private Survey survey;
|
||||
|
||||
@OneToMany(cascade = CascadeType.ALL, mappedBy = "question")
|
||||
@SortNatural
|
||||
private Set<Answer> answers;
|
||||
|
||||
@Override
|
||||
public int compareTo(Question o) {
|
||||
return this.getNumber().compareTo(o.getNumber());
|
||||
}
|
||||
|
||||
public boolean isRadio() {
|
||||
return minAnswers == 1 && maxAnswers == 1;
|
||||
}
|
||||
|
||||
public String toString() {
|
||||
return "Question(id=" + this.getId() +
|
||||
", text=" + this.getText() +
|
||||
", number=" + this.getNumber() +
|
||||
", minAnswers=" + this.getMinAnswers() +
|
||||
", maxAnswers=" + this.getMaxAnswers() +
|
||||
(Hibernate.isInitialized(getAnswers()) ? ", answers=" + this.getAnswers() : "") +
|
||||
")";
|
||||
}
|
||||
}
|
@ -0,0 +1,63 @@
|
||||
package eu.jonahbauer.survey.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.hibernate.Hibernate;
|
||||
import org.hibernate.annotations.OnDelete;
|
||||
import org.hibernate.annotations.OnDeleteAction;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* A submission to a survey modelled as a set of selected answers.
|
||||
*/
|
||||
@Entity
|
||||
@Data
|
||||
@Table(name = "submission")
|
||||
@EqualsAndHashCode(of = "id")
|
||||
public class Submission {
|
||||
@Id
|
||||
@Column(name = "id", nullable = false)
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Integer id;
|
||||
|
||||
@Column(name = "created", nullable = false, insertable = false, updatable = false)
|
||||
private Instant created;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "survey", nullable = false, foreignKey = @ForeignKey(name = "fk_submission_survey"))
|
||||
@OnDelete(action = OnDeleteAction.CASCADE)
|
||||
@NotNull
|
||||
private Survey survey;
|
||||
|
||||
/**
|
||||
* The set of selected answers. The answers must all belong to the same survey as this submission and the number
|
||||
* of answers per question must be within the bounds set by {@link Question#getMinAnswers()} and
|
||||
* {@link Question#getMaxAnswers()}.
|
||||
*/
|
||||
@ManyToMany(fetch = FetchType.LAZY)
|
||||
@JoinTable(
|
||||
name = "submission_answer",
|
||||
joinColumns = @JoinColumn(
|
||||
name = "submission", referencedColumnName = "id", nullable = false,
|
||||
foreignKey = @ForeignKey(name = "fk_submission_answer_submission")
|
||||
),
|
||||
inverseJoinColumns = @JoinColumn(
|
||||
name = "answer", referencedColumnName = "id", nullable = false,
|
||||
foreignKey = @ForeignKey(name = "fk_submission_answer_answer")
|
||||
)
|
||||
)
|
||||
private Set<Answer> answers;
|
||||
|
||||
public String toString() {
|
||||
return "Submission(id=" + this.getId() +
|
||||
", created=" + this.getCreated() +
|
||||
", survey=" + this.getSurvey() +
|
||||
(Hibernate.isInitialized(getAnswers()) ? ", answers=" + this.getAnswers() : "") +
|
||||
")";
|
||||
}
|
||||
}
|
@ -0,0 +1,82 @@
|
||||
package eu.jonahbauer.survey.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.hibernate.Hibernate;
|
||||
import org.hibernate.annotations.OnDelete;
|
||||
import org.hibernate.annotations.OnDeleteAction;
|
||||
import org.hibernate.annotations.SortNatural;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* A survey is a collection of multiple {@linkplain Question questions} each having multiple possible
|
||||
* {@linkplain Answer answers}. A {@linkplain Submission submission} is a set of selected answers.
|
||||
*/
|
||||
@Entity
|
||||
@Data
|
||||
@Table(name = "survey")
|
||||
@EqualsAndHashCode(of = "id")
|
||||
@NoArgsConstructor
|
||||
public class Survey {
|
||||
@Id
|
||||
@Column(name = "id", nullable = false)
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Integer id;
|
||||
|
||||
/**
|
||||
* The survey title.
|
||||
*/
|
||||
@Column(name = "title", nullable = false)
|
||||
@NotBlank
|
||||
private String title;
|
||||
|
||||
/**
|
||||
* A short, descriptive summary of the survey.
|
||||
*/
|
||||
@Column(name = "summary", nullable = false)
|
||||
@NotBlank
|
||||
private String summary;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "owner", nullable = false, foreignKey = @ForeignKey(name = "fk_survey_owner"))
|
||||
@OnDelete(action = OnDeleteAction.CASCADE)
|
||||
@NotNull
|
||||
private User owner;
|
||||
|
||||
@Column(name = "created", insertable = false, updatable = false, nullable = false)
|
||||
private Instant created;
|
||||
|
||||
@OneToMany(cascade = CascadeType.ALL, mappedBy = "survey", fetch = FetchType.LAZY)
|
||||
@SortNatural
|
||||
private Set<Question> questions;
|
||||
|
||||
@OneToMany(mappedBy = "survey", fetch = FetchType.LAZY)
|
||||
private Set<Submission> submissions;
|
||||
|
||||
@Transient
|
||||
private transient Long submissionCount;
|
||||
|
||||
public Survey(Integer id, String title, String summary, Instant created, long submissionCount) {
|
||||
this.id = id;
|
||||
this.title = title;
|
||||
this.summary = summary;
|
||||
this.created = created;
|
||||
this.submissionCount = submissionCount;
|
||||
}
|
||||
|
||||
public String toString() {
|
||||
return "Survey(id=" + this.getId() +
|
||||
", title=" + this.getTitle() +
|
||||
", summary=" + this.getSummary() +
|
||||
", created=" + this.getCreated() +
|
||||
(Hibernate.isInitialized(getQuestions()) ? ", questions=" + this.getQuestions() : "") +
|
||||
(Hibernate.isInitialized(getSubmissions()) ? ", submissions=" + this.getSubmissions() : "") +
|
||||
")";
|
||||
}
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
package eu.jonahbauer.survey.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Set;
|
||||
|
||||
@Entity
|
||||
@Data
|
||||
@Table(name = "\"user\"", uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uq_user_username", columnNames = "username")
|
||||
})
|
||||
@EqualsAndHashCode(of = "id")
|
||||
public class User implements UserDetails {
|
||||
@Id
|
||||
@Column(name = "id")
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Integer id;
|
||||
|
||||
@Column(name = "username", nullable = false)
|
||||
@NotBlank @Size(min = 4, max = 255)
|
||||
private String username;
|
||||
|
||||
@Column(name = "password", nullable = false)
|
||||
@NotBlank @Size(max = 255)
|
||||
private String password;
|
||||
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
return Set.of(new SimpleGrantedAuthority("USER"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonExpired() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonLocked() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCredentialsNonExpired() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return true;
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
package eu.jonahbauer.survey.persistence.repository;
|
||||
|
||||
import eu.jonahbauer.survey.persistence.entity.Submission;
|
||||
import org.springframework.data.repository.CrudRepository;
|
||||
|
||||
public interface SubmissionRepository extends CrudRepository<Submission, Integer> {
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
package eu.jonahbauer.survey.persistence.repository;
|
||||
|
||||
import eu.jonahbauer.survey.persistence.entity.Survey;
|
||||
import eu.jonahbauer.survey.persistence.entity.User;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.EntityGraph;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface SurveyRepository extends JpaRepository<Survey, Integer> {
|
||||
@EntityGraph(attributePaths = {"questions.answers"})
|
||||
Survey findDetailedById(Integer id);
|
||||
|
||||
@EntityGraph(attributePaths = {"questions.answers.evaluation", "owner", "submissions"})
|
||||
Survey findEvaluationById(Integer id);
|
||||
|
||||
@Query(
|
||||
value = "select new Survey(s.id, s.title, s.summary, s.created, count(sub.id)) from Survey s left join s.submissions sub group by s order by count(sub.id) desc",
|
||||
countQuery = "select count(Survey) from Survey"
|
||||
)
|
||||
Page<Survey> findAllSortedBySubmissionCountDesc(Pageable pageable);
|
||||
|
||||
Page<Survey> findAllByOwner(User owner, Pageable pageable);
|
||||
|
||||
@Query("select count(sub.id) from Survey s left join s.submissions sub where s.id = :id")
|
||||
long getSubmissionCountById(@Param("id") Integer id);
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
package eu.jonahbauer.survey.persistence.repository;
|
||||
|
||||
import eu.jonahbauer.survey.persistence.entity.User;
|
||||
import org.springframework.data.repository.CrudRepository;
|
||||
|
||||
public interface UserRepository extends CrudRepository<User, Integer> {
|
||||
User findByUsername(String username);
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
package eu.jonahbauer.survey.spring;
|
||||
|
||||
import org.springframework.boot.web.server.MimeMappings;
|
||||
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
|
||||
import org.springframework.boot.web.servlet.server.ConfigurableServletWebServerFactory;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class MimeTypeConfig implements WebServerFactoryCustomizer<ConfigurableServletWebServerFactory> {
|
||||
@Override
|
||||
public void customize(ConfigurableServletWebServerFactory factory) {
|
||||
MimeMappings mappings = new MimeMappings(MimeMappings.DEFAULT);
|
||||
mappings.add("woff", "application/font-woff");
|
||||
mappings.add("woff2", "application/font-woff2");
|
||||
mappings.add("ttf", "application/x-font-truetype");
|
||||
mappings.add("eot", "application/vnd.ms-fontobject");
|
||||
mappings.add("svg", "image/svg+xml");
|
||||
mappings.add("otf", "application/x-font-opentype");
|
||||
factory.setMimeMappings(mappings);
|
||||
}
|
||||
}
|
@ -0,0 +1,57 @@
|
||||
package eu.jonahbauer.survey.spring;
|
||||
|
||||
import eu.jonahbauer.survey.persistence.repository.UserRepository;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http.csrf();
|
||||
|
||||
http.exceptionHandling()
|
||||
.accessDeniedPage("/error");
|
||||
|
||||
http.formLogin()
|
||||
.loginPage("/login")
|
||||
.loginProcessingUrl("/login")
|
||||
.usernameParameter("username")
|
||||
.passwordParameter("password")
|
||||
.failureUrl("/login?error")
|
||||
.defaultSuccessUrl("/index")
|
||||
.permitAll();
|
||||
|
||||
http.logout()
|
||||
.logoutUrl("/logout")
|
||||
.logoutSuccessUrl("/index")
|
||||
.deleteCookies("JSESSIONID")
|
||||
.permitAll();
|
||||
|
||||
http.authorizeHttpRequests()
|
||||
.requestMatchers("/css/**", "/js/**", "/img/**", "/fonts/**").permitAll()
|
||||
.requestMatchers("/survey/create").authenticated()
|
||||
.requestMatchers("/survey/own").authenticated()
|
||||
.anyRequest().permitAll();
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public UserDetailsService userDetailsService(UserRepository userRepository) {
|
||||
return userRepository::findByUsername;
|
||||
}
|
||||
}
|
@ -0,0 +1,79 @@
|
||||
package eu.jonahbauer.survey.spring;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.experimental.Delegate;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.thymeleaf.context.IExpressionContext;
|
||||
import org.thymeleaf.dialect.AbstractDialect;
|
||||
import org.thymeleaf.dialect.IExpressionObjectDialect;
|
||||
import org.thymeleaf.expression.IExpressionObjectFactory;
|
||||
import org.thymeleaf.expression.Temporals;
|
||||
import org.thymeleaf.spring6.SpringTemplateEngine;
|
||||
import org.thymeleaf.standard.expression.StandardExpressionObjectFactory;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
@Configuration
|
||||
public class ThymeleafConfig {
|
||||
public ThymeleafConfig(SpringTemplateEngine templateEngine) {
|
||||
templateEngine.setRenderHiddenMarkersBeforeCheckboxes(true);
|
||||
templateEngine.addDialect(new CustomDialect());
|
||||
}
|
||||
|
||||
public static final class CustomDialect extends AbstractDialect implements IExpressionObjectDialect {
|
||||
private final IExpressionObjectFactory CUSTOM_EXPRESSION_OBJECT_FACTORY = new CustomExpressionObjectFactory();
|
||||
|
||||
private CustomDialect() {
|
||||
super("custom");
|
||||
}
|
||||
|
||||
@Override
|
||||
public IExpressionObjectFactory getExpressionObjectFactory() {
|
||||
return CUSTOM_EXPRESSION_OBJECT_FACTORY;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class CustomExpressionObjectFactory implements IExpressionObjectFactory {
|
||||
private static final Set<String> ALL_EXPRESSION_OBJECT_NAMES = Set.of(StandardExpressionObjectFactory.TEMPORALS_EXPRESSION_OBJECT_NAME);
|
||||
|
||||
@Override
|
||||
public Set<String> getAllExpressionObjectNames() {
|
||||
return ALL_EXPRESSION_OBJECT_NAMES;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object buildObject(IExpressionContext context, String expressionObjectName) {
|
||||
if (StandardExpressionObjectFactory.TEMPORALS_EXPRESSION_OBJECT_NAME.equals(expressionObjectName)) {
|
||||
return new CustomTemporals(context.getLocale());
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCacheable(String expressionObjectName) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class CustomTemporals {
|
||||
@Delegate
|
||||
private final Temporals temporals;
|
||||
|
||||
public CustomTemporals(@NotNull Locale locale) {
|
||||
this(locale, ZoneId.systemDefault());
|
||||
}
|
||||
|
||||
public CustomTemporals(@NotNull Locale locale, ZoneId zoneId) {
|
||||
this.temporals = new Temporals(locale, zoneId);
|
||||
}
|
||||
|
||||
public Date asDate(Instant instant) {
|
||||
return Date.from(instant);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
package eu.jonahbauer.survey.spring;
|
||||
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
|
||||
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
|
||||
import org.springframework.web.servlet.LocaleResolver;
|
||||
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import org.springframework.web.servlet.i18n.SessionLocaleResolver;
|
||||
import org.thymeleaf.extras.springsecurity6.dialect.SpringSecurityDialect;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
@EnableWebMvc
|
||||
@Configuration
|
||||
@ComponentScan("eu.jonahbauer.survey")
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
registry.addResourceHandler("/**").addResourceLocations("classpath:/static/");
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SpringSecurityDialect securityDialect() {
|
||||
return new SpringSecurityDialect();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Override
|
||||
public LocalValidatorFactoryBean getValidator() {
|
||||
LocalValidatorFactoryBean bean = new LocalValidatorFactoryBean();
|
||||
bean.setValidationMessageSource(messageSource());
|
||||
return bean;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public MessageSource messageSource() {
|
||||
ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
|
||||
messageSource.setBasename("classpath:messages");
|
||||
messageSource.setDefaultEncoding("UTF-8");
|
||||
return messageSource;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public LocaleResolver localeResolver() {
|
||||
SessionLocaleResolver slr = new SessionLocaleResolver();
|
||||
slr.setDefaultLocale(Locale.GERMANY);
|
||||
return slr;
|
||||
}
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
package eu.jonahbauer.survey.validation;
|
||||
|
||||
import eu.jonahbauer.survey.validation.constraints.Password;
|
||||
import jakarta.validation.ConstraintValidator;
|
||||
import jakarta.validation.ConstraintValidatorContext;
|
||||
|
||||
public class PasswordValidator implements ConstraintValidator<Password, String> {
|
||||
|
||||
@Override
|
||||
public boolean isValid(String value, ConstraintValidatorContext context) {
|
||||
if (value == null) return true;
|
||||
|
||||
boolean lowercase = false;
|
||||
boolean uppercase = false;
|
||||
boolean digit = false;
|
||||
boolean special = false;
|
||||
|
||||
for (char c : value.toCharArray()) {
|
||||
if (Character.isLowerCase(c)) {
|
||||
lowercase = true;
|
||||
} else if (Character.isUpperCase(c)) {
|
||||
uppercase = true;
|
||||
} else if (Character.isDigit(c)) {
|
||||
digit = true;
|
||||
} else {
|
||||
special = true;
|
||||
}
|
||||
}
|
||||
|
||||
return lowercase && uppercase && digit && special;
|
||||
}
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
package eu.jonahbauer.survey.validation.constraints;
|
||||
|
||||
import eu.jonahbauer.survey.validation.PasswordValidator;
|
||||
import jakarta.validation.Constraint;
|
||||
import jakarta.validation.Payload;
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
|
||||
@Documented
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Constraint(validatedBy = PasswordValidator.class)
|
||||
public @interface Password {
|
||||
String message() default "{eu.jonahbauer.survey.validation.constraints.Password.message}";
|
||||
|
||||
Class<?>[] groups() default { };
|
||||
|
||||
Class<? extends Payload>[] payload() default { };
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
# Datasource
|
||||
spring.datasource.driver-class-name=org.h2.Driver
|
||||
spring.datasource.url=jdbc:h2:mem:db;DB_CLOSE_DELAY=-1;MODE=MySQL;TIME ZONE=0:00
|
||||
spring.datasource.username=sa
|
||||
spring.datasource.password=sa
|
||||
|
||||
# Thymeleaf
|
||||
spring.thymeleaf.cache=false
|
@ -0,0 +1,6 @@
|
||||
# Datasource
|
||||
spring.datasource.url=jdbc:mariadb://jonahbauer.eu:3306/webdev?sslMode=verify-full
|
||||
spring.datasource.username=webdev
|
||||
spring.datasource.password=webdev!
|
||||
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
|
||||
|
@ -0,0 +1,12 @@
|
||||
spring.profiles.active=@activatedProperties@
|
||||
|
||||
# Context Path
|
||||
server.servlet.context-path=/survey
|
||||
|
||||
# Hibernate / JPA
|
||||
spring.jpa.open-in-view=false
|
||||
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MariaDBDialect
|
||||
spring.jpa.properties.hibernate.jdbc.time_zone=UTC
|
||||
|
||||
# Main
|
||||
spring.main.allow-bean-definition-overriding=true
|
@ -0,0 +1,75 @@
|
||||
CREATE TABLE `user`
|
||||
(
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`password` VARCHAR(255) NOT NULL,
|
||||
`username` VARCHAR(255) NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
CONSTRAINT `uq_user_username` UNIQUE (`username`)
|
||||
);
|
||||
|
||||
CREATE TABLE `survey`
|
||||
(
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`created` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`summary` VARCHAR(255) NOT NULL,
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`owner` INTEGER NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
CONSTRAINT `fk_survey_owner` FOREIGN KEY (`owner`) REFERENCES `user` (`id`)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE `question`
|
||||
(
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`max_answers` INTEGER NOT NULL CHECK (`max_answers` >= 1),
|
||||
`min_answers` INTEGER NOT NULL CHECK (`min_answers` >= 0),
|
||||
`number` INTEGER NOT NULL CHECK (`number` >= 0),
|
||||
`text` VARCHAR(255) NOT NULL,
|
||||
`survey` INTEGER NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
CONSTRAINT `uq_question_survey_number` UNIQUE (`survey`, `number`),
|
||||
CONSTRAINT `fk_question_survey` FOREIGN KEY (`survey`) REFERENCES `survey` (`id`)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE `answer`
|
||||
(
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`number` INTEGER NOT NULL,
|
||||
`text` VARCHAR(255) NOT NULL,
|
||||
`question` INTEGER NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
CONSTRAINT `uq_answer_question_number` UNIQUE (`question`, `number`),
|
||||
CONSTRAINT `fk_answer_question` FOREIGN KEY (`question`) REFERENCES `question` (`id`)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE `submission`
|
||||
(
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`created` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`survey` INTEGER NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
CONSTRAINT `fk_submission_survey` FOREIGN KEY (`survey`) REFERENCES `survey` (`id`)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE `submission_answer`
|
||||
(
|
||||
`submission` INTEGER NOT NULL,
|
||||
`answer` INTEGER NOT NULL,
|
||||
PRIMARY KEY (`submission`, `answer`),
|
||||
CONSTRAINT `fk_submission_answer_answer` FOREIGN KEY (`answer`) REFERENCES `answer` (`id`)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_submission_answer_submission` FOREIGN KEY (`submission`) REFERENCES `submission` (`id`)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE VIEW `answer_evaluation` AS
|
||||
(
|
||||
SELECT `a`.`id` AS `answer`, COUNT(`sa`.`answer`) AS `count`
|
||||
FROM `answer` `a`
|
||||
LEFT JOIN `submission_answer` `sa` ON `a`.`id` = `sa`.`answer`
|
||||
GROUP BY `a`.`id`
|
||||
);
|
@ -0,0 +1,104 @@
|
||||
INSERT INTO `user`(`username`, `password`)
|
||||
VALUES ('Mark-Uwe Kling', 'nologon');
|
||||
|
||||
SET @`user_id` = LAST_INSERT_ID();
|
||||
|
||||
INSERT INTO `survey`(`owner`, `title`, `summary`)
|
||||
VALUES (@`user_id`, 'Angriff der Killer-Soziologen', 'Hallo? Haben Sie einen Moment Zeit? Bleiben Sie doch hier! Ich habe nur ein paar Fragen an Sie!');
|
||||
|
||||
SET @`survey_id` = LAST_INSERT_ID();
|
||||
|
||||
-- Question 0
|
||||
|
||||
INSERT INTO `question`(`survey`, `number`, `text`, `max_answers`, `min_answers`)
|
||||
VALUES (@`survey_id`, 0, 'Wer ist besser? Bud Spencer oder Terrence Hill?', 1, 1);
|
||||
|
||||
SET @`question_id` = LAST_INSERT_ID();
|
||||
|
||||
INSERT INTO `answer`(`question`, `number`, `text`)
|
||||
VALUES (@`question_id`, 0, 'Bud Spencer'),
|
||||
(@`question_id`, 1, 'Terrence Hill');
|
||||
|
||||
-- Question 1
|
||||
|
||||
INSERT INTO `question`(`survey`, `number`, `text`, `max_answers`, `min_answers`)
|
||||
VALUES (@`survey_id`, 1, 'Wenn am Sonntag Wahl wäre, würden Sie dann am Montag eine Digitalkamera kaufen?', 1, 1);
|
||||
|
||||
SET @`question_id` = LAST_INSERT_ID();
|
||||
|
||||
INSERT INTO `answer`(`question`, `number`, `text`)
|
||||
VALUES (@`question_id`, 0, 'ja'),
|
||||
(@`question_id`, 1, 'nein');
|
||||
|
||||
-- Question 2
|
||||
|
||||
INSERT INTO `question`(`survey`, `number`, `text`, `max_answers`, `min_answers`)
|
||||
VALUES (@`survey_id`, 2, 'Haben Sie sich vor kurzem ein neues Mobilphone gekauft? Wenn ja, when do you normalerweise buy das Nächste?', 1, 1);
|
||||
|
||||
SET @`question_id` = LAST_INSERT_ID();
|
||||
|
||||
INSERT INTO `answer`(`question`, `number`, `text`)
|
||||
VALUES (@`question_id`, 0, 'nein'),
|
||||
(@`question_id`, 1, '28 days later'),
|
||||
(@`question_id`, 2, '28 weeks later'),
|
||||
(@`question_id`, 3, '28 months later');
|
||||
|
||||
-- Question 3
|
||||
|
||||
INSERT INTO `question`(`survey`, `number`, `text`, `max_answers`, `min_answers`)
|
||||
VALUES (@`survey_id`, 3, 'Wie bewerten Sie die gegenwärtige wirtschaftliche Lage?', 1, 1);
|
||||
|
||||
SET @`question_id` = LAST_INSERT_ID();
|
||||
|
||||
INSERT INTO `answer`(`question`, `number`, `text`)
|
||||
VALUES (@`question_id`, 0, 'ja'),
|
||||
(@`question_id`, 1, 'nein'),
|
||||
(@`question_id`, 2, 'vielleicht'),
|
||||
(@`question_id`, 3, 'nur f*****');
|
||||
|
||||
-- Question 4
|
||||
|
||||
INSERT INTO `question`(`survey`, `number`, `text`, `max_answers`, `min_answers`)
|
||||
VALUES (@`survey_id`, 4, 'Arbeiten Sie gern für Ihren Konzern?', 1, 1);
|
||||
|
||||
SET @`question_id` = LAST_INSERT_ID();
|
||||
|
||||
INSERT INTO `answer`(`question`, `number`, `text`)
|
||||
VALUES (@`question_id`, 0, 'ja'),
|
||||
(@`question_id`, 1, 'natürlich');
|
||||
|
||||
-- Question 5
|
||||
|
||||
INSERT INTO `question`(`survey`, `number`, `text`, `max_answers`, `min_answers`)
|
||||
VALUES (@`survey_id`, 5, 'Was essen Sie am liebsten? Die Tütensuppen von Maggi oder die Tütensuppen von Knorr?', 1, 1);
|
||||
|
||||
SET @`question_id` = LAST_INSERT_ID();
|
||||
|
||||
INSERT INTO `answer`(`question`, `number`, `text`)
|
||||
VALUES (@`question_id`, 0, 'Maggi'),
|
||||
(@`question_id`, 1, 'Knorr');
|
||||
|
||||
-- Question 6
|
||||
|
||||
INSERT INTO `question`(`survey`, `number`, `text`, `max_answers`, `min_answers`)
|
||||
VALUES (@`survey_id`, 6, 'Wie beurteilen Sie die Arbeit der Bundesregierung?', 1, 1);
|
||||
|
||||
SET @`question_id` = LAST_INSERT_ID();
|
||||
|
||||
INSERT INTO `answer`(`question`, `number`, `text`)
|
||||
VALUES (@`question_id`, 0, 'sehr positiv'),
|
||||
(@`question_id`, 1, 'positiv'),
|
||||
(@`question_id`, 2, 'eher positiv');
|
||||
|
||||
-- Question 7
|
||||
|
||||
INSERT INTO `question`(`survey`, `number`, `text`, `max_answers`, `min_answers`)
|
||||
VALUES (@`survey_id`, 7, 'Wie oft laufen Sie Amok?', 1, 1);
|
||||
|
||||
SET @`question_id` = LAST_INSERT_ID();
|
||||
|
||||
INSERT INTO `answer`(`question`, `number`, `text`)
|
||||
VALUES (@`question_id`, 0, 'ein Mal im Monat'),
|
||||
(@`question_id`, 1, 'ein Mal pro Woche'),
|
||||
(@`question_id`, 2, 'häufiger als ein Mal pro Woche');
|
||||
|
@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Configuration status="WARN" monitorInterval="30">
|
||||
<Properties>
|
||||
<Property name="LOG_PATTERN">[%-5level] %d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %c{1} - %msg%n</Property>
|
||||
</Properties>
|
||||
<Appenders>
|
||||
<Console name="CONSOLE" target="SYSTEM_OUT" follow="true">
|
||||
<PatternLayout pattern="${LOG_PATTERN}" />
|
||||
</Console>
|
||||
</Appenders>
|
||||
<Loggers>
|
||||
<Root level="info">
|
||||
<AppenderRef ref="CONSOLE" />
|
||||
</Root>
|
||||
</Loggers>
|
||||
</Configuration>
|
@ -0,0 +1,112 @@
|
||||
# participation
|
||||
participation.question.too_many_answers=Bitte wählen Sie nicht mehr als {0,choice,1#eine Antwort|1<{0,number} Antworten} aus.
|
||||
participation.question.not_enough_answers=Bitte wählen Sie mindestens {0,choice,1#eine Antwort|1<{0,number} Antworten} aus.
|
||||
participation.question.unknown_answer=Bitte denken Sie sich keine Antworten aus.
|
||||
participation.survey.unknown_question=Bitte denken Sie sich keine Fragen aus.
|
||||
|
||||
# creation
|
||||
survey.title.length=Der Titel muss zwischen {min} und {max} Zeichen lang sein.
|
||||
survey.title.not_blank=Der Titel darf nicht leer sein.
|
||||
survey.summary.length=Die Beschreibung muss zwischen {min} und {max} Zeichen lang sein.
|
||||
survey.summary.not_blank=Die Beschreibung darf nicht leer sein.
|
||||
survey.questions.unique=Die Fragen müssen unterschiedlich sein.
|
||||
survey.questions.size=Die Umfrage muss zwischen {min} und {max} Fragen enthalten.
|
||||
survey.questions.not_null=Die Umfrage muss mindestens eine Frage enthalten.
|
||||
survey.questions.text.length=Die Frage muss zwischen {min} und {max} Zeichen lang sein.
|
||||
survey.questions.text.not_blank=Die Frage darf nicht leer sein.
|
||||
survey.questions.answers.size=Die Frage muss zwischen {min} und {max} Antwortmöglichkeiten haben.
|
||||
survey.questions.answers.not_null=Die Frage muss mindestens eine Antwortmöglichkeiten haben.
|
||||
survey.questions.answers.not_blank=Die Antwortmöglichkeiten dürfen nicht leer sein.
|
||||
survey.questions.answers.unique=Die Antwortmöglichkeiten müssen unterschiedlich sein.
|
||||
|
||||
# list
|
||||
list.data_pattern.hot={0} Teilnehmer
|
||||
list.data_pattern.recent={1,date} {1,time}
|
||||
|
||||
# error
|
||||
error.default.short={0} \u2013 Unbekannter Fehler
|
||||
error.400.short=400 \u2013 Ungültige Anfrage
|
||||
error.401.short=401 \u2013 Unauthorisiert
|
||||
error.403.short=403 \u2013 Zugriff verweigert
|
||||
error.404.short=404 \u2013 Seite nicht gefunden
|
||||
error.405.short=405 \u2013 Methode nicht erlaubt
|
||||
error.500.short=500 \u2013 Interner Server Fehler
|
||||
|
||||
error.default.long=Es ist ein unbekannter Fehler aufgetreten. Bitte versuchen Sie es später erneut.<br>Wenn der Fehler bestehen bleibt, wenden Sie sich bitte an die Betreiber der Website.
|
||||
error.400.long=Anfrage kann nicht verarbeitet werden. Bitte neu formulieren.
|
||||
error.401.long=Um auf diese Ressource zuzugreifen, müssen Sie sich authorisieren.
|
||||
error.403.long=Sie haben nicht die nötigen Berechtigungen, um auf die angeforderte Ressource zuzugreifen.
|
||||
error.404.long=Die von Ihnen gesuchte Seite konnte leider nicht gefunden werden. Das tut uns leid.
|
||||
error.500.long=Es ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut.<br>Wenn der Fehler bestehen bleibt, wenden Sie sich bitte an die Betreiber der Website.
|
||||
|
||||
# Login
|
||||
login.error=Benutzername oder Passwort falsch
|
||||
login.username.label=Benutzername
|
||||
login.username.hint=benutzername
|
||||
login.password.label=Passwort
|
||||
login.password.hint=passwort
|
||||
login.button.submit=Login
|
||||
login.button.register=Registrieren
|
||||
|
||||
# Registration
|
||||
registration.username.label=Benutzername
|
||||
registration.username.hint=benutzername
|
||||
registration.password.label=Passwort
|
||||
registration.password.hint=passwort
|
||||
registration.button.submit=Registrieren
|
||||
registration.success=Registrierung erfolgreich
|
||||
registration.username.not_null=Der Benutzername darf nicht leer sein.
|
||||
registration.username.length=Der Benutzername muss zwischen {min} und {max} Zeichen lang sein.
|
||||
registration.username.pattern=Der Benutzername darf nur aus Klein- und Großbuchstaben, Zahlen und Unterstrichen bestehen.
|
||||
registration.username.unique=Der Benutzername existiert bereits.
|
||||
registration.password.not_null=Das Passwort darf nicht leer sein.
|
||||
registration.password.length=Das Passwort muss zwischen {min} und {max} Zeichen lang sein.
|
||||
registration.password.password=Das Passwort muss mindestens einen Klein- und einen Großbuchstaben, eine Zahl und ein Sonderzeichen enthalten.
|
||||
|
||||
# Evaluation
|
||||
evaluation.title={0} - Auswertung
|
||||
evaluation.heading={0} \u2013 Auswertung
|
||||
evaluation.button.to_participation=Zur Teilnahme
|
||||
evaluation.participants={0,number} Teilnehmer
|
||||
evaluation.created={0,date} {0,time}
|
||||
evaluation.owner={0}
|
||||
evaluation.number_of_answers=(zwischen {0,number,integer} und {1,number,integer} Antworten)
|
||||
evaluation.percent_of_total=({0,number,#.##}% der Stimmen)
|
||||
|
||||
# Participation
|
||||
participation.title={0}
|
||||
participation.heading={0}
|
||||
participation.button.to_evaluation=Zur Auswertung
|
||||
participation.number_of_answers=(zwischen {0,number,integer} und {1,number,integer} Antworten auswählen)
|
||||
participation.button.reset=Eingabe löschen
|
||||
participation.button.submit=Teilnehmen
|
||||
|
||||
# Creation
|
||||
creation.title=Umfrage erstellen
|
||||
creation.heading=Umfrage erstellen
|
||||
creation.title.label=Titel
|
||||
creation.title.hint=geben sie ihrer umfrage einen prägnanten titel
|
||||
creation.summary.label=Beschreibung
|
||||
creation.summary.hint=beschreiben sie den zweck ihrer umfrage
|
||||
creation.question.label=Frage
|
||||
creation.question.hint=was wollen sie wissen?
|
||||
creation.answer.hint=antwortmöglichkeit
|
||||
creation.answer.new.hint=tippen um antwortmöglichkeit hinzuzufügen
|
||||
creation.answer_count.label=Zwischen {0,number,integer} und {1,number,integer} Antworten müssen ausgewählt werden.
|
||||
creation.answer_count.label.js=Zwischen {min} und {max} Antworten müssen ausgewählt werden.
|
||||
creation.button.add_question=Frage hinzufügen
|
||||
creation.button.submit=Umfrage erstellen
|
||||
|
||||
# Index
|
||||
index.heading.own=Eigene Umfragen
|
||||
index.heading.hot=Beliebte Umfragen
|
||||
index.heading.recent=Neuste Umfragen
|
||||
|
||||
# Menu
|
||||
menu.title=Survelopment
|
||||
menu.recent=Neuste Umfragen
|
||||
menu.hot=Beliebte Umfragen
|
||||
menu.own=Eigene Umfragen
|
||||
menu.new=Umfrage erstellen
|
||||
menu.login=Login
|
||||
menu.logout=Logout
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,349 @@
|
||||
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
|
||||
|
||||
/* Document
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Correct the line height in all browsers.
|
||||
* 2. Prevent adjustments of font size after orientation changes in iOS.
|
||||
*/
|
||||
|
||||
html {
|
||||
line-height: 1.15; /* 1 */
|
||||
-webkit-text-size-adjust: 100%; /* 2 */
|
||||
}
|
||||
|
||||
/* Sections
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Remove the margin in all browsers.
|
||||
*/
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the `main` element consistently in IE.
|
||||
*/
|
||||
|
||||
main {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the font size and margin on `h1` elements within `section` and
|
||||
* `article` contexts in Chrome, Firefox, and Safari.
|
||||
*/
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
margin: 0.67em 0;
|
||||
}
|
||||
|
||||
/* Grouping content
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Add the correct box sizing in Firefox.
|
||||
* 2. Show the overflow in Edge and IE.
|
||||
*/
|
||||
|
||||
hr {
|
||||
box-sizing: content-box; /* 1 */
|
||||
height: 0; /* 1 */
|
||||
overflow: visible; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the inheritance and scaling of font size in all browsers.
|
||||
* 2. Correct the odd `em` font sizing in all browsers.
|
||||
*/
|
||||
|
||||
pre {
|
||||
font-family: monospace, monospace; /* 1 */
|
||||
font-size: 1em; /* 2 */
|
||||
}
|
||||
|
||||
/* Text-level semantics
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Remove the gray background on active links in IE 10.
|
||||
*/
|
||||
|
||||
a {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Remove the bottom border in Chrome 57-
|
||||
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
|
||||
*/
|
||||
|
||||
abbr[title] {
|
||||
border-bottom: none; /* 1 */
|
||||
text-decoration: underline; /* 2 */
|
||||
text-decoration: underline dotted; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct font weight in Chrome, Edge, and Safari.
|
||||
*/
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the inheritance and scaling of font size in all browsers.
|
||||
* 2. Correct the odd `em` font sizing in all browsers.
|
||||
*/
|
||||
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: monospace, monospace; /* 1 */
|
||||
font-size: 1em; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct font size in all browsers.
|
||||
*/
|
||||
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent `sub` and `sup` elements from affecting the line height in
|
||||
* all browsers.
|
||||
*/
|
||||
|
||||
sub,
|
||||
sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
/* Embedded content
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Remove the border on images inside links in IE 10.
|
||||
*/
|
||||
|
||||
img {
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
/* Forms
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Change the font styles in all browsers.
|
||||
* 2. Remove the margin in Firefox and Safari.
|
||||
*/
|
||||
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit; /* 1 */
|
||||
font-size: 100%; /* 1 */
|
||||
line-height: 1.15; /* 1 */
|
||||
margin: 0; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the overflow in IE.
|
||||
* 1. Show the overflow in Edge.
|
||||
*/
|
||||
|
||||
button,
|
||||
input { /* 1 */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inheritance of text transform in Edge, Firefox, and IE.
|
||||
* 1. Remove the inheritance of text transform in Firefox.
|
||||
*/
|
||||
|
||||
button,
|
||||
select { /* 1 */
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the inability to style clickable types in iOS and Safari.
|
||||
*/
|
||||
|
||||
button,
|
||||
[type="button"],
|
||||
[type="reset"],
|
||||
[type="submit"] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inner border and padding in Firefox.
|
||||
*/
|
||||
|
||||
button::-moz-focus-inner,
|
||||
[type="button"]::-moz-focus-inner,
|
||||
[type="reset"]::-moz-focus-inner,
|
||||
[type="submit"]::-moz-focus-inner {
|
||||
border-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the focus styles unset by the previous rule.
|
||||
*/
|
||||
|
||||
button:-moz-focusring,
|
||||
[type="button"]:-moz-focusring,
|
||||
[type="reset"]:-moz-focusring,
|
||||
[type="submit"]:-moz-focusring {
|
||||
outline: 1px dotted ButtonText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the padding in Firefox.
|
||||
*/
|
||||
|
||||
fieldset {
|
||||
padding: 0.35em 0.75em 0.625em;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the text wrapping in Edge and IE.
|
||||
* 2. Correct the color inheritance from `fieldset` elements in IE.
|
||||
* 3. Remove the padding so developers are not caught out when they zero out
|
||||
* `fieldset` elements in all browsers.
|
||||
*/
|
||||
|
||||
legend {
|
||||
box-sizing: border-box; /* 1 */
|
||||
color: inherit; /* 2 */
|
||||
display: table; /* 1 */
|
||||
max-width: 100%; /* 1 */
|
||||
padding: 0; /* 3 */
|
||||
white-space: normal; /* 1 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct vertical alignment in Chrome, Firefox, and Opera.
|
||||
*/
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the default vertical scrollbar in IE 10+.
|
||||
*/
|
||||
|
||||
textarea {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Add the correct box sizing in IE 10.
|
||||
* 2. Remove the padding in IE 10.
|
||||
*/
|
||||
|
||||
[type="checkbox"],
|
||||
[type="radio"] {
|
||||
box-sizing: border-box; /* 1 */
|
||||
padding: 0; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the cursor style of increment and decrement buttons in Chrome.
|
||||
*/
|
||||
|
||||
[type="number"]::-webkit-inner-spin-button,
|
||||
[type="number"]::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the odd appearance in Chrome and Safari.
|
||||
* 2. Correct the outline style in Safari.
|
||||
*/
|
||||
|
||||
[type="search"] {
|
||||
-webkit-appearance: textfield; /* 1 */
|
||||
outline-offset: -2px; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inner padding in Chrome and Safari on macOS.
|
||||
*/
|
||||
|
||||
[type="search"]::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the inability to style clickable types in iOS and Safari.
|
||||
* 2. Change font properties to `inherit` in Safari.
|
||||
*/
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
-webkit-appearance: button; /* 1 */
|
||||
font: inherit; /* 2 */
|
||||
}
|
||||
|
||||
/* Interactive
|
||||
========================================================================== */
|
||||
|
||||
/*
|
||||
* Add the correct display in Edge, IE 10+, and Firefox.
|
||||
*/
|
||||
|
||||
details {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/*
|
||||
* Add the correct display in all browsers.
|
||||
*/
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
/* Misc
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 10+.
|
||||
*/
|
||||
|
||||
template {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 10.
|
||||
*/
|
||||
|
||||
[hidden] {
|
||||
display: none;
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
.double-slider {
|
||||
width: 100%;
|
||||
height: 0.5em;
|
||||
position: relative;
|
||||
background: #c9c9c9;
|
||||
border-radius: 0.25em;
|
||||
margin: 0.25em;
|
||||
}
|
||||
|
||||
.double-slider .bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: #585858;
|
||||
transform: translateX(0.5em);
|
||||
}
|
||||
|
||||
.double-slider .left-thumb,
|
||||
.double-slider .right-thumb {
|
||||
position: absolute;
|
||||
top: -0.25em;
|
||||
bottom: 0.25em;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
border-radius: 0.5em;
|
||||
background: #585858;
|
||||
cursor: pointer;
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 355 B |
@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
version="1.1"
|
||||
width="36" height="36"
|
||||
viewBox="0 0 36 36">
|
||||
<rect x="3" y="3"
|
||||
width="30" height="30"
|
||||
fill="#5bb0fd" />
|
||||
|
||||
<path fill="#5d98cc"
|
||||
d="M 8,28 l 5,5 H33 V18 l -5,-5 H25.75 l-5,-5 l -5.5,10.75 l -1.75,-1.75 Z"/>
|
||||
|
||||
<g fill="#c4e5ff">
|
||||
<rect x="8" y="17"
|
||||
width="5.5" height="11" />
|
||||
<rect x="15.25" y="8"
|
||||
width="5.5" height="20" />
|
||||
<rect x="22.5" y="13"
|
||||
width="5.5" height="15" />
|
||||
</g>
|
||||
|
||||
</svg>
|
After Width: | Height: | Size: 578 B |
@ -0,0 +1,8 @@
|
||||
document.addEventListener('keydown', function (event) {
|
||||
if (event.keyCode === 13 && event.target.nodeName === 'INPUT') {
|
||||
var form = event.target.form;
|
||||
var index = Array.prototype.indexOf.call(form, event.target);
|
||||
form.elements[index + 1].focus();
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
@ -0,0 +1,162 @@
|
||||
function initSliders() {
|
||||
document.querySelectorAll('.double-slider').forEach(setupSlider);
|
||||
}
|
||||
|
||||
function setupSlider(slider) {
|
||||
const left_input = slider.querySelector('input.left');
|
||||
const right_input = slider.querySelector('input.right');
|
||||
|
||||
const left_thumb = slider.querySelector('.left-thumb');
|
||||
const right_thumb = slider.querySelector('.right-thumb');
|
||||
|
||||
left_thumb.onmousedown = event => {
|
||||
selectLeft();
|
||||
startDrag(event, left_thumb);
|
||||
}
|
||||
|
||||
right_thumb.onmousedown = event => {
|
||||
selectRight()
|
||||
startDrag(event, right_thumb);
|
||||
}
|
||||
|
||||
updateSlider(slider);
|
||||
|
||||
let x0 = 0;
|
||||
let clientX0 = 0;
|
||||
let input = null;
|
||||
let effective_width = 0;
|
||||
let slider_min_value = 0;
|
||||
let slider_max_value = 0;
|
||||
let slider_range = 0;
|
||||
let thumb_min_value = 0;
|
||||
let thumb_max_value = 0;
|
||||
|
||||
function selectRight() {
|
||||
input = right_input;
|
||||
thumb_min_value = Math.max(right_input.min, parseInt(left_input.value));
|
||||
thumb_max_value = right_input.max;
|
||||
}
|
||||
|
||||
function selectLeft() {
|
||||
input = left_input;
|
||||
thumb_min_value = left_input.min;
|
||||
thumb_max_value = Math.min(left_input.max, parseInt(right_input.value));
|
||||
}
|
||||
|
||||
function startDrag(event, thumb) {
|
||||
event = event || window.event;
|
||||
event.preventDefault();
|
||||
|
||||
clientX0 = event.clientX;
|
||||
x0 = event.clientX - thumb.getBoundingClientRect().left + slider.getBoundingClientRect().left;
|
||||
effective_width = slider.getBoundingClientRect().width - thumb.getBoundingClientRect().width;
|
||||
slider_min_value = left_input.min;
|
||||
slider_max_value = right_input.max;
|
||||
slider_range = slider_max_value - slider_min_value;
|
||||
|
||||
document.onmouseup = endDrag;
|
||||
document.onmousemove = drag;
|
||||
}
|
||||
|
||||
function drag(event) {
|
||||
event = event || window.event;
|
||||
event.preventDefault();
|
||||
|
||||
let forceUpdate = false;
|
||||
|
||||
// calculate mouse position relative to slider
|
||||
let new_thumb_x = event.clientX - x0;
|
||||
new_thumb_x = Math.max(0, Math.min(effective_width, new_thumb_x));
|
||||
|
||||
// convert position into value
|
||||
let progress = new_thumb_x / parseFloat(effective_width);
|
||||
let value = Math.round(slider_min_value + (progress * slider_range));
|
||||
|
||||
// switch selected thumb if outside thumb value range
|
||||
if (value < parseInt(left_input.value) && input === right_input) {
|
||||
input.value = thumb_min_value;
|
||||
selectLeft();
|
||||
forceUpdate = true;
|
||||
} else if (value > parseInt(right_input.value) && input === left_input) {
|
||||
input.value = thumb_max_value;
|
||||
selectRight();
|
||||
forceUpdate = true;
|
||||
}
|
||||
|
||||
// clamp value
|
||||
value = Math.max(thumb_min_value, Math.min(thumb_max_value, value));
|
||||
|
||||
// apply changes
|
||||
if (value !== parseInt(input.value)) {
|
||||
changeValue(input, value);
|
||||
forceUpdate = true;
|
||||
}
|
||||
|
||||
// update gui if necessary
|
||||
if (forceUpdate) {
|
||||
updateSlider(slider);
|
||||
}
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
document.onmouseup = null;
|
||||
document.onmousemove = null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateSlider(slider) {
|
||||
const left_input = slider.querySelector('input.left');
|
||||
const right_input = slider.querySelector('input.right');
|
||||
|
||||
const slider_min_value = left_input.min;
|
||||
const slider_max_value = right_input.max;
|
||||
const slider_range = slider_max_value - slider_min_value;
|
||||
|
||||
const left_max_value = left_input.max;
|
||||
const right_min_value = right_input.min;
|
||||
|
||||
const left_thumb = slider.querySelector('.left-thumb');
|
||||
const right_thumb = slider.querySelector('.right-thumb');
|
||||
|
||||
const bar = slider.querySelector('.bar');
|
||||
|
||||
const clampedLeft = Math.max(slider_min_value, Math.min(left_max_value, parseInt(left_input.value)));
|
||||
const clampedRight = Math.max(Math.max(clampedLeft, right_min_value), Math.min(slider_max_value, parseInt(right_input.value)));
|
||||
|
||||
if (parseInt(left_input.value) !== clampedLeft) {
|
||||
changeValue(left_input, clampedLeft);
|
||||
}
|
||||
|
||||
if (parseInt(right_input.value) !== clampedRight) {
|
||||
changeValue(right_input, clampedRight);
|
||||
}
|
||||
|
||||
positionThumbInSlider(left_thumb, left_input.value - slider_min_value, slider_range);
|
||||
positionThumbInSlider(right_thumb, right_input.value - slider_min_value, slider_range);
|
||||
positionBarInSlider(bar, left_input.value - slider_min_value, right_input.value - slider_min_value, slider_range);
|
||||
}
|
||||
|
||||
function positionThumbInSlider(thumb, position, range) {
|
||||
let progress = position / parseFloat(range);
|
||||
progress = Math.max(0, Math.min(1, progress));
|
||||
thumb.style.left = 'calc(' + (100 * progress) + '% - ' + (progress) + 'em)';
|
||||
}
|
||||
|
||||
function positionBarInSlider(bar, left, right, range) {
|
||||
let leftProgress = left / parseFloat(range);
|
||||
leftProgress = Math.max(0, Math.min(1, leftProgress));
|
||||
|
||||
let rightProgress = right / parseFloat(range);
|
||||
rightProgress = Math.max(0, Math.min(1, rightProgress));
|
||||
|
||||
bar.style.left = 'calc(' + (100 * leftProgress) + '% - ' + (leftProgress) + 'em)';
|
||||
bar.style.width = 'calc(' + (100 * (rightProgress - leftProgress)) + '% - ' + (rightProgress - leftProgress) + 'em)';
|
||||
}
|
||||
|
||||
function changeValue(input, value) {
|
||||
input.value = value;
|
||||
|
||||
let evt = document.createEvent('HTMLEvents');
|
||||
evt.initEvent('change', false, true);
|
||||
input.dispatchEvent(evt);
|
||||
}
|
@ -0,0 +1,134 @@
|
||||
const max_questions_per_survey = 20;
|
||||
const max_answers_per_question = 20;
|
||||
const answer_count_pattern = document.getElementById('answer-count-text').innerText;
|
||||
|
||||
function addQuestion(survey) {
|
||||
if (survey.querySelectorAll(".question").length >= max_questions_per_survey) return;
|
||||
|
||||
// load question from template
|
||||
let template = document.getElementById('question-template');
|
||||
let questionNode = template.content.cloneNode(true);
|
||||
|
||||
setupSlider(questionNode.querySelector('.double-slider'));
|
||||
|
||||
// add answer
|
||||
addAnswer(questionNode);
|
||||
|
||||
// append to dom
|
||||
survey.insertBefore(questionNode, survey.querySelector('.survey > footer'));
|
||||
|
||||
// set visibility of "add question" button
|
||||
if (survey.children.length >= max_questions_per_survey) {
|
||||
document.getElementById('add-question').style.display = 'none';
|
||||
} else {
|
||||
document.getElementById('add-question').style.display = null;
|
||||
}
|
||||
|
||||
return questionNode;
|
||||
}
|
||||
|
||||
function removeQuestion(question) {
|
||||
if (!question.matches(':only-of-type')) {
|
||||
let survey = question.closest('.survey');
|
||||
|
||||
survey.removeChild(question);
|
||||
|
||||
if (survey.children.length >= max_questions_per_survey) {
|
||||
document.getElementById('add-question').style.display = 'none';
|
||||
} else {
|
||||
document.getElementById('add-question').style.display = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addAnswer(question) {
|
||||
if (question.querySelectorAll(".answer").length >= max_answers_per_question) return;
|
||||
|
||||
// load template
|
||||
let template = document.getElementById('answer-template');
|
||||
let answerNode = template.content.cloneNode(true);
|
||||
|
||||
// add to dom
|
||||
question.querySelector('.answers ol').appendChild(answerNode);
|
||||
|
||||
updateAnswerCount(question);
|
||||
|
||||
return answerNode;
|
||||
}
|
||||
|
||||
function addAnswerOnInput(question, answer) {
|
||||
if (answer.matches(".new")) {
|
||||
answer.children[0].placeholder = 'antwortmöglichkeit';
|
||||
answer.children[0].oninput = '';
|
||||
answer.classList.toggle('new', false);
|
||||
|
||||
let question = answer.closest('.question');
|
||||
|
||||
if (addAnswer(question) === undefined) {
|
||||
updateAnswerCount(question);
|
||||
}
|
||||
} else {
|
||||
answer.children[0].oninput = '';
|
||||
}
|
||||
}
|
||||
|
||||
function removeAnswer(answer) {
|
||||
if (!answer.matches(".new")) {
|
||||
let ol = answer.parentElement;
|
||||
|
||||
ol.removeChild(answer);
|
||||
|
||||
updateAnswerCount(ol.closest('.question'));
|
||||
}
|
||||
}
|
||||
|
||||
function updateAnswerCount(question) {
|
||||
let slider = question.querySelector('.double-slider');
|
||||
|
||||
let answerCount = question.querySelectorAll('.answer:not(.new)').length;
|
||||
|
||||
if (answerCount < max_answers_per_question && question.querySelector('.answer.new') == null) {
|
||||
addAnswer(question);
|
||||
}
|
||||
|
||||
slider.querySelector('input.left').max = answerCount;
|
||||
slider.querySelector('input.right').max = answerCount;
|
||||
updateSlider(slider);
|
||||
|
||||
updateAnswerCountText(question);
|
||||
}
|
||||
|
||||
function updateAnswerCountText(question) {
|
||||
let text = question.querySelector('.answer-count-text');
|
||||
|
||||
let slider = question.querySelector('.double-slider');
|
||||
let left = slider.querySelector('.left').value;
|
||||
let right = slider.querySelector('.right').value;
|
||||
|
||||
question.querySelector('.answers').classList.toggle('multiple', right !== '1' || left !== '1');
|
||||
|
||||
text.innerText = answer_count_pattern.replace('{min}', left).replace('{max}', right);
|
||||
}
|
||||
|
||||
function prepareNames(survey) {
|
||||
let questions = survey.querySelectorAll('.question');
|
||||
|
||||
questions.forEach((question, qindex) => {
|
||||
let qBaseName = `questions[${qindex}]`;
|
||||
|
||||
question.querySelector('header > input').name = qBaseName + '.text';
|
||||
question.querySelector('footer > .double-slider > .left').name = qBaseName + '.minAnswers';
|
||||
question.querySelector('footer > .double-slider > .right').name = qBaseName + '.maxAnswers';
|
||||
|
||||
question.querySelectorAll('.answers .answer:not(.new)').forEach((answer, aindex) => {
|
||||
answer.querySelector('input').name = qBaseName + `.answers[${aindex}]`;
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
document.getElementById('survey-form')
|
||||
.addEventListener('submit', () => {
|
||||
prepareNames(document.getElementById('survey-form'));
|
||||
});
|
||||
});
|
@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en"
|
||||
xmlns="http://www.w3.org/1999/xhtml"
|
||||
xmlns:th="https://www.thymeleaf.org">
|
||||
<head>
|
||||
<title>Fehler</title>
|
||||
<th:block th:replace="~{fragments/generic :: head}"/>
|
||||
</head>
|
||||
<body>
|
||||
<header th:replace="~{fragments/generic :: header}"></header>
|
||||
<main>
|
||||
<div class="inner">
|
||||
<div class="aln-center" style="padding-top: 5em">
|
||||
<h1 th:text="${#messages.msgOrNull('error.' + code + '.short') ?: #messages.msg('error.default.short', code)}"
|
||||
style="letter-spacing: 0">
|
||||
404 – Seite nicht gefunden
|
||||
</h1>
|
||||
<p style="font-size: 150%"
|
||||
th:utext="${#messages.msgOrNull('error.' + code + '.long') ?: #messages.msg('error.default.long', code)}">
|
||||
Die von Ihnen gesuchte Seite konnte leider nicht gefunden werden. Das tut uns leid.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de"
|
||||
xmlns="http://www.w3.org/1999/xhtml"
|
||||
xmlns:th="https://www.thymeleaf.org"
|
||||
xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity4">
|
||||
|
||||
<head>
|
||||
<th:block th:fragment="head">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<link rel="icon" th:href="@{/img/favicon.png}"/>
|
||||
<link rel="stylesheet" th:href="@{/css/normalize.css}"/>
|
||||
<link rel="stylesheet" th:href="@{/css/main.css}"/>
|
||||
<link rel="stylesheet" th:href="@{/css/slider.css}"/>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.11.2/css/all.css" rel="stylesheet" type="text/css" />
|
||||
</th:block>
|
||||
</head>
|
||||
<body>
|
||||
<header id="header" th:fragment="header">
|
||||
<nav id="nav-bar">
|
||||
<div class="spacer"></div>
|
||||
<ul>
|
||||
<li>
|
||||
<a class="logo" th:href="@{/index}">
|
||||
<img th:src="@{/img/icon.svg}" width="36" height="36" alt="Logo"/>
|
||||
<span class="title" th:text="#{menu.title}">Survelopment</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="title" th:href="@{/survey/recent}" th:text="#{menu.recent}">Neuste Umfragen</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="title" th:href="@{/survey/hot}" th:text="#{menu.hot}">Beliebte Umfragen</a>
|
||||
</li>
|
||||
<li sec:authorize="isAuthenticated()">
|
||||
<a class="title" th:href="@{/survey/own}" th:text="#{menu.own}">Eigene Umfragen</a>
|
||||
</li>
|
||||
<li sec:authorize="isAuthenticated()">
|
||||
<a class="title" th:href="@{/survey/create}" th:text="#{menu.new}">Umfrage erstellen</a>
|
||||
</li>
|
||||
<li sec:authorize="isAuthenticated()">
|
||||
<form th:action="@{/logout}" method="post">
|
||||
<a class="title" href="javascript:void(0)" onclick="this.closest('form').submit()" th:text="#{menu.logout}">Logout</a>
|
||||
</form>
|
||||
</li>
|
||||
<li sec:authorize="!isAuthenticated()">
|
||||
<a class="title" th:href="@{/login}" th:text="#{menu.login}">Login</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="spacer"></div>
|
||||
</nav>
|
||||
<hr>
|
||||
</header>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en"
|
||||
xmlns="http://www.w3.org/1999/xhtml"
|
||||
xmlns:th="https://www.thymeleaf.org">
|
||||
<body>
|
||||
<main>
|
||||
<div class="inner">
|
||||
<section th:fragment="list(title, more, surveys, pattern)">
|
||||
<header class="title">
|
||||
<div class="button-wrapper float-right">
|
||||
<a class="button" th:if="${more != null}" th:href="${more}">Mehr</a>
|
||||
</div>
|
||||
<h1 th:text="${title}">Liste aller Umfragen</h1>
|
||||
</header>
|
||||
<div class="row">
|
||||
<div class="col-6 col-12-medium" th:each="survey : ${surveys}">
|
||||
<!--/* @thymesVar id="survey" type="eu.jonahbauer.survey.persistence.entity.Survey" */-->
|
||||
<a th:href="@{/survey/{id}/evaluate(id=${survey.id})}">
|
||||
<div class='survey-panel'>
|
||||
<header>
|
||||
<h2 th:text="${survey.title}">Titel</h2>
|
||||
<span th:text="#{${pattern}(${survey.submissionCount}, ${#temporals.asDate(survey.created)})}">30 Teilnehmer</span>
|
||||
</header>
|
||||
<div th:text="${survey.summary}">
|
||||
Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor
|
||||
invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam
|
||||
et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est.
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,34 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en"
|
||||
xmlns="http://www.w3.org/1999/xhtml"
|
||||
xmlns:th="https://www.thymeleaf.org">
|
||||
<head>
|
||||
<title>Survelopment</title>
|
||||
<th:block th:replace="~{fragments/generic :: head}"/>
|
||||
</head>
|
||||
<body>
|
||||
<header th:replace="~{fragments/generic :: header}"></header>
|
||||
<main>
|
||||
<div class="inner">
|
||||
<section id="index-header">
|
||||
<header>
|
||||
<img src="img/icon.svg" alt="">
|
||||
<h1>Survelopment</h1>
|
||||
</header>
|
||||
<blockquote>
|
||||
You can think of each survey (or group of surveys) as having a baseline and with each repetition, keep adding
|
||||
one more survey. Simple, huh? After X surveys, X stations. Best of all, data points are average and consistency
|
||||
is guaranteed via satellite sitting almost off the moon. It'll make your wife more productive.
|
||||
While the most current version of the data is available for local travel, the core demographic will never complain.
|
||||
- GPT-2
|
||||
</blockquote>
|
||||
</section>
|
||||
<th:block th:if="${own != null && not own['empty']}">
|
||||
<section th:replace="~{fragments/survey :: list(#{index.heading.own}, @{/survey/own}, ${own}, 'list.data_pattern.recent')}"></section>
|
||||
</th:block>
|
||||
<section th:replace="~{fragments/survey :: list(#{index.heading.hot}, @{/survey/hot}, ${hot}, 'list.data_pattern.hot')}"></section>
|
||||
<section th:replace="~{fragments/survey :: list(#{index.heading.recent}, @{/survey/recent}, ${recent}, 'list.data_pattern.recent')}"></section>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de"
|
||||
xmlns="http://www.w3.org/1999/xhtml"
|
||||
xmlns:th="https://www.thymeleaf.org">
|
||||
<head>
|
||||
<title>Login</title>
|
||||
<th:block th:replace="~{fragments/generic :: head}"/>
|
||||
</head>
|
||||
<body>
|
||||
<main style="padding: 0">
|
||||
<div class="inner" style="display: flex; height: 100vh; flex-direction: column; justify-content: center">
|
||||
<div class="login">
|
||||
<div class="row aln-center">
|
||||
<a class="logo" th:href="@{/index}">
|
||||
<img th:src="@{/img/icon.svg}"/>
|
||||
<h1>Survelopment</h1>
|
||||
</a>
|
||||
</div>
|
||||
<div class="row aln-center">
|
||||
<div class="col-4 col-6-xlarge col-8-large col-12-medium">
|
||||
<div class="panel">
|
||||
<form th:action="@{/login}" method="post">
|
||||
<div class="error" th:if="${error}">
|
||||
<span th:text="#{login.error}">Benutzername oder Passwort falsch</span>
|
||||
</div>
|
||||
<div class="success" th:if="${success}">
|
||||
<span th:text="#{registration.success}">Registrierung erfolgreich</span>
|
||||
</div>
|
||||
<div class="row gtr-uniform">
|
||||
<div class="col-12">
|
||||
<label for="username" th:text="#{login.username.label}">Benutzername</label>
|
||||
<input type="text" name="username" id="username" placeholder="benutzername" th:placeholder="#{login.username.hint}">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="password" th:text="#{login.password.label}">Passwort</label>
|
||||
<input type="password" name="password" id="password" placeholder="passwort" th:placeholder="#{login.password.hint}">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<ul class="actions aln-center">
|
||||
<li>
|
||||
<input type="submit" name="login" value="Login" class="default" th:value="#{login.button.submit}">
|
||||
</li>
|
||||
<li>
|
||||
<input type="submit" name="register" value="Registrieren" th:value="#{login.button.register}">
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de"
|
||||
xmlns="http://www.w3.org/1999/xhtml"
|
||||
xmlns:th="https://www.thymeleaf.org">
|
||||
<head>
|
||||
<title>Login</title>
|
||||
<th:block th:replace="~{fragments/generic :: head}"/>
|
||||
</head>
|
||||
<body>
|
||||
<main style="padding: 0">
|
||||
<div class="inner" style="display: flex; height: 100vh; flex-direction: column; justify-content: center">
|
||||
<div class="register">
|
||||
<div class="row aln-center">
|
||||
<a class="logo" th:href="@{/index}">
|
||||
<img th:src="@{/img/icon.svg}"/>
|
||||
<h1>Survelopment</h1>
|
||||
</a>
|
||||
</div>
|
||||
<div class="row aln-center">
|
||||
<div class="col-4 col-6-xlarge col-8-large col-12-medium">
|
||||
<div class="panel">
|
||||
<form th:action="@{/register}" method="post" th:object="${form}">
|
||||
<div class="error" th:if="${#fields.hasAnyErrors()}">
|
||||
<ul>
|
||||
<li th:each="err : ${#fields.allErrors()}" th:text="${err}"></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="row gtr-uniform">
|
||||
<div class="col-12">
|
||||
<label for="username" th:text="#{registration.username.label}">Benutzername</label>
|
||||
<input type="text" name="username" id="username" placeholder="benutzername"
|
||||
th:field="*{username}" th:placeholder="#{registration.username.hint}">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="password" th:text="#{registration.password.label}">Passwort</label>
|
||||
<input type="password" name="password" id="password" placeholder="passwort"
|
||||
th:field="*{password}" th:placeholder="#{registration.password.hint}">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<ul class="actions aln-center">
|
||||
<li>
|
||||
<input class="default" type="submit" name="register" value="Registrieren"
|
||||
th:value="#{registration.button.submit}">
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,119 @@
|
||||
<!DOCTYPE html>
|
||||
<!--suppress ThymeleafVariablesResolveInspection -->
|
||||
<html lang="en"
|
||||
xmlns="http://www.w3.org/1999/xhtml"
|
||||
xmlns:th="https://www.thymeleaf.org">
|
||||
<head>
|
||||
<title th:text="#{creation.title}">Umfrage erstellen</title>
|
||||
<th:block th:replace="~{fragments/generic :: head}"/>
|
||||
<template id="question-template">
|
||||
<section id="question" class="question" th:fragment="question(question,field)">
|
||||
<header class="title">
|
||||
<label th:text="#{creation.question.label}">Frage</label>
|
||||
<input type="text" placeholder="was wollen sie wissen?"
|
||||
th:value="${question != null && question.text != null ? question.text : ''}"
|
||||
th:placeholder="#{creation.question.hint}">
|
||||
<a class="close" onclick="removeQuestion(this.closest('.question'));" href="javascript:void(0)"></a>
|
||||
</header>
|
||||
<div class="error" th:if="${field != null && (#fields.hasErrors(field) || #fields.hasErrors(field + '.*'))}">
|
||||
<ul>
|
||||
<li th:each="err : ${#fields.errors(field)}" th:text="${err}">Fehler</li>
|
||||
<li th:each="err : ${#fields.errors(field + '.*')}" th:text="${err}">Fehler</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="answers multiple">
|
||||
<ol>
|
||||
<th:block th:if="${question != null}">
|
||||
<th:block th:each="answer : ${question.answers}">
|
||||
<div th:replace="~{:: answer(${answer})}"></div>
|
||||
</th:block>
|
||||
<div th:replace="~{:: answer(null)}"></div>
|
||||
</th:block>
|
||||
</ol>
|
||||
</div>
|
||||
<footer>
|
||||
<div class="answer-count-text" style="padding: 0 1em; margin-bottom: 1em"
|
||||
th:text="#{creation.answer_count.label(${question?.minAnswers?:0},${question?.maxAnswers?:1})}">
|
||||
Zwischen 0 und 0 Antworten müssen ausgewählt werden.
|
||||
</div>
|
||||
<div class="double-slider">
|
||||
<input type="hidden" class="left" value="0" onchange="updateAnswerCountText(this.closest('.question'))"
|
||||
th:value="${question != null ? question.minAnswers : '0'}"
|
||||
th:max="${question?.answers?.size()?:'1'}"
|
||||
min="0" max="0"/>
|
||||
<input type="hidden" class="right" onchange="updateAnswerCountText(this.closest('.question'))"
|
||||
th:value="${question != null ? question.maxAnswers : '1'}"
|
||||
th:max="${question?.answers?.size()?:'1'}"
|
||||
min="1" max="1"/>
|
||||
<div class="bar"></div>
|
||||
<div class="left-thumb"></div>
|
||||
<div class="right-thumb"></div>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</template>
|
||||
<template id="answer-template">
|
||||
<li class="answer" th:fragment="answer(answer)" th:classappend="${answer == null ? 'new' : ''}">
|
||||
<input type="text" oninput="addAnswerOnInput(this.closest('.question'), this.closest('.answer'))"
|
||||
th:placeholder="#{${answer == null ? 'creation.answer.new.hint' : 'creation.answer.hint'}}"
|
||||
th:value="${answer ?: ''}" >
|
||||
<a class="close" onclick="removeAnswer(this.closest('.answer'));" href="javascript:void(0)"></a>
|
||||
</li>
|
||||
</template>
|
||||
</head>
|
||||
<body>
|
||||
<span style="display: none;" id="answer-count-text" th:text="#{creation.answer_count.label.js}"></span>
|
||||
<script th:src="@{/js/entertab.js}"></script>
|
||||
<script th:src="@{/js/survey.js}"></script>
|
||||
<script th:src="@{/js/slider.js}"></script>
|
||||
<header th:replace="~{fragments/generic :: header}"></header>
|
||||
<main>
|
||||
<div class="inner">
|
||||
<h1 th:text="#{creation.heading}">Umfrage erstellen</h1>
|
||||
<form id="survey-form" method="post" th:action="@{/survey/create}" th:object="${survey}">
|
||||
<input name="survey" id="survey-json" type="hidden">
|
||||
<article id="survey" class="survey draft">
|
||||
<div class="error" th:if="${#fields.hasErrors('title') || #fields.hasErrors('summary') || #fields.hasGlobalErrors() }">
|
||||
<ul>
|
||||
<li th:each="err : ${#fields.errors('title')}" th:text="${err}">Fehler</li>
|
||||
<li th:each="err : ${#fields.errors('summary')}" th:text="${err}">Fehler</li>
|
||||
<li th:each="err : ${#fields.globalErrors()}" th:text="${err}">Fehler</li>
|
||||
</ul>
|
||||
</div>
|
||||
<header class="title">
|
||||
<label for="survey-title" th:text="#{creation.title.label}">Titel</label>
|
||||
<input id="survey-title" type="text" th:field="*{title}"
|
||||
placeholder="geben sie ihrer umfrage einen prägnanten titel"
|
||||
th:placeholder="#{creation.title.hint}">
|
||||
</header>
|
||||
<div class="summary">
|
||||
<label for="survey-summary" th:text="#{creation.summary.label}">Beschreibung</label>
|
||||
<input type="text" id="survey-summary" th:field="*{summary}"
|
||||
placeholder="beschreiben sie den zweck ihrer umfrage"
|
||||
th:placeholder="#{creation.summary.hint}">
|
||||
</div>
|
||||
<th:block th:each="question, qstat : *{questions}">
|
||||
<div th:replace="~{:: question(${question}, 'questions[' + ${qstat.index} + ']')}"></div>
|
||||
</th:block>
|
||||
<footer>
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<button type="button" id="add-question" onclick="addQuestion(document.getElementById('survey'))"
|
||||
th:text="#{creation.button.add_question}">
|
||||
Frage hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<input type="submit" class="default float-right" value="Umfrage erstellen" th:value="#{creation.button.submit}">
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
<script>
|
||||
initSliders()
|
||||
</script>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,65 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en"
|
||||
xmlns="http://www.w3.org/1999/xhtml"
|
||||
xmlns:th="https://www.thymeleaf.org">
|
||||
<head>
|
||||
<title th:text="#{evaluation.title(${survey.title})}">Umfragetitel - Auswertung</title>
|
||||
<th:block th:replace="~{fragments/generic :: head}"/>
|
||||
</head>
|
||||
<body>
|
||||
<header th:replace="~{fragments/generic :: header}"></header>
|
||||
<main>
|
||||
<div class="inner">
|
||||
<article id="survey" class="survey evaluation">
|
||||
<header class="title">
|
||||
<div class="button-wrapper float-right">
|
||||
<a class="button"
|
||||
th:href="@{/survey/{id}/participate(id = ${survey.id})}"
|
||||
th:text="#{evaluation.button.to_participation}">
|
||||
Zur Teilnahme
|
||||
</a>
|
||||
</div>
|
||||
<h1 th:text="#{evaluation.heading(${survey.title})}">Umfragetitel - Auswertung</h1>
|
||||
<div>
|
||||
<span th:text="#{evaluation.participants(${survey.submissions.size()})}">0 Teilnehmer</span>
|
||||
<span>–</span>
|
||||
<span th:text="#{evaluation.created(${#temporals.asDate(survey.created)})}">00.00.0000</span>
|
||||
<span>–</span>
|
||||
<span th:text="#{evaluation.owner(${survey.owner.username})}">Owner</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="summary" th:text="${survey.summary}">
|
||||
Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor
|
||||
invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam
|
||||
et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est.
|
||||
</div>
|
||||
<section th:each="question : ${survey.questions}" class="question">
|
||||
<header class="title">
|
||||
<h2 th:text="${question.text}">Frage</h2>
|
||||
<span th:text="#{evaluation.number_of_answers(${question.minAnswers}, ${question.maxAnswers})}">
|
||||
(zwischen 1 und 1 Antworten)
|
||||
</span>
|
||||
</header>
|
||||
<div class="answers">
|
||||
<ol>
|
||||
<li class="answer" th:each="answer : ${question.answers}">
|
||||
<label th:text="${answer.text}">
|
||||
Antwortmöglichkeit
|
||||
</label>
|
||||
<span th:text="#{evaluation.percent_of_total(${answer.evaluation.percentage})}">
|
||||
xx% der Stimmen
|
||||
</span>
|
||||
<div class="meter">
|
||||
<span class="bar"
|
||||
th:style="${'width: ' + #numbers.formatDecimal(answer.evaluation.barPercentage, 1, 2, 'POINT') + '%;'}">
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en"
|
||||
xmlns="http://www.w3.org/1999/xhtml"
|
||||
xmlns:th="https://www.thymeleaf.org">
|
||||
<head>
|
||||
<title th:text="${title}">Titel</title>
|
||||
<th:block th:replace="~{fragments/generic :: head}"/>
|
||||
</head>
|
||||
<body>
|
||||
<header th:replace="~{fragments/generic :: header}"></header>
|
||||
<main>
|
||||
<div class="inner">
|
||||
<div class="row" th:replace="~{fragments/survey :: list(${title}, null, ${surveys}, ${pattern})}">
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en"
|
||||
xmlns="http://www.w3.org/1999/xhtml"
|
||||
xmlns:th="https://www.thymeleaf.org">
|
||||
<head>
|
||||
<title th:text="#{participation.title(${survey.title})}">Titel</title>
|
||||
<th:block th:replace="~{fragments/generic :: head}"/>
|
||||
</head>
|
||||
<body>
|
||||
<header th:replace="~{fragments/generic :: header}"></header>
|
||||
<main>
|
||||
<div class="inner">
|
||||
<form method="post" th:action="@{/survey/{id}/participate(id = ${survey.id})}" th:object="${form}">
|
||||
<article id="survey" class="survey">
|
||||
<header class="title">
|
||||
<div class="button-wrapper float-right">
|
||||
<a class="button" th:href="@{/survey/{id}/evaluate(id = ${survey.id})}"
|
||||
onclick="if (!confirm('Möchten Sie die Seite wirklich verlassen?')) event.preventDefault();"
|
||||
th:text="#{participation.button.to_evaluation}">
|
||||
Zur Auswertung
|
||||
</a>
|
||||
</div>
|
||||
<h1 th:text="#{participation.heading(${survey.title})}">Umfragetitel</h1>
|
||||
</header>
|
||||
<div class="summary" th:text="${survey.summary}">
|
||||
Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor
|
||||
invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam
|
||||
et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est.
|
||||
</div>
|
||||
<section th:each="question : ${survey.questions}" class="question"
|
||||
th:with="field=${'selectedAnswers[' + __${question.id}__ + ']'}">
|
||||
<header class="title">
|
||||
<h2 th:text="${question.text}">Frage</h2>
|
||||
<span th:if="${!question.radio}"
|
||||
th:text="#{participation.number_of_answers(${question.minAnswers}, ${question.maxAnswers})}">
|
||||
(zwischen 1 und 3 Antworten auswählen)
|
||||
</span>
|
||||
</header>
|
||||
<div class="error"
|
||||
th:if="${#fields.hasErrors(field)}">
|
||||
<ul>
|
||||
<li th:each="err : ${#fields.errors(field)}" th:text="${err}">Fehler</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="answers">
|
||||
<ol>
|
||||
<li class="answer" th:each="answer : ${question.answers}">
|
||||
<input th:type="${question.radio ? 'radio' : 'checkbox'}"
|
||||
th:value="${answer.id}"
|
||||
th:field="*{selectedAnswers[__${question.id}__]}">
|
||||
<label th:for="${#ids.prev('selectedAnswers' + __${question.id}__)}"
|
||||
th:text="${answer.text}">
|
||||
Antwortmöglichkeit
|
||||
</label>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="row aln-right">
|
||||
<ul class="actions">
|
||||
<li>
|
||||
<input type="reset" value="Eingabe löschen" th:value="#{participation.button.reset}">
|
||||
</li>
|
||||
<li>
|
||||
<input class="default" type="submit" value="Teilnehmen" th:value="#{participation.button.submit}">
|
||||
</li>
|
||||
</ul>
|
||||
</footer>
|
||||
</article>
|
||||
<script>
|
||||
document.querySelectorAll('input').forEach(input => {
|
||||
let value = input.value;
|
||||
let checked = input.checked;
|
||||
input.defaultValue = '';
|
||||
input.defaultChecked = false;
|
||||
input.value = value;
|
||||
input.checked = checked;
|
||||
});
|
||||
</script>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
Loading…
Reference in New Issue