add existing code

main
jbb01 2 years ago
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

240
gradlew vendored

@ -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" "$@"

91
gradlew.bat vendored

@ -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 &ndash; 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)">&#xf00d;</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)">&#xf00d;</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>&#x2013;</span>
<span th:text="#{evaluation.created(${#temporals.asDate(survey.created)})}">00.00.0000</span>
<span>&#x2013;</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…
Cancel
Save