From ef59fa35b12f278d2839ee6f92373de1cc28c628 Mon Sep 17 00:00:00 2001 From: stefan Date: Thu, 5 Jun 2025 12:57:07 +0200 Subject: [PATCH 01/13] erste Version Online-Nennen --- data/meldestelle.db | Bin 0 -> 32768 bytes meldestelle.db | 0 server/build.gradle.kts | 9 +- .../src/main/kotlin/at/mocode/Application.kt | 101 +--- .../at/mocode/config/DependencyInjection.kt | 44 ++ .../kotlin/at/mocode/config/EmailConfig.kt | 107 ++++ .../kotlin/at/mocode/email/EmailService.kt | 298 ++++++++++ .../src/main/kotlin/at/mocode/model/Bewerb.kt | 22 + .../main/kotlin/at/mocode/model/Nennung.kt | 29 +- .../main/kotlin/at/mocode/model/Turnier.kt | 18 +- .../main/kotlin/at/mocode/plugins/Database.kt | 79 +-- .../at/mocode/repository/NennungRepository.kt | 80 +++ .../at/mocode/repository/TurnierRepository.kt | 246 ++++++++ .../kotlin/at/mocode/routes/AdminRoutes.kt | 162 +++++ .../kotlin/at/mocode/routes/HomeRoutes.kt | 37 ++ .../kotlin/at/mocode/routes/NennungRoutes.kt | 119 ++++ .../main/kotlin/at/mocode/tables/Tables.kt | 146 ++++- .../main/kotlin/at/mocode/views/AdminView.kt | 561 ++++++++++++++++++ .../main/kotlin/at/mocode/views/HomeView.kt | 135 +++++ .../kotlin/at/mocode/views/LayoutTemplate.kt | 477 +++++++++++++++ .../kotlin/at/mocode/views/NennungView.kt | 392 ++++++++++++ .../test/kotlin/at/mocode/ApplicationTest.kt | 14 +- .../src/test/kotlin/at/mocode/TestDatabase.kt | 86 ++- .../at/mocode/email/EmailServiceTest.kt | 54 ++ .../kotlin/at/mocode/model/Bewerb.kt | 22 + .../kotlin/at/mocode/model/Nennung.kt | 31 +- .../kotlin/at/mocode/model/Turnier.kt | 19 +- 27 files changed, 3081 insertions(+), 207 deletions(-) create mode 100644 data/meldestelle.db create mode 100644 meldestelle.db create mode 100644 server/src/main/kotlin/at/mocode/config/DependencyInjection.kt create mode 100644 server/src/main/kotlin/at/mocode/config/EmailConfig.kt create mode 100644 server/src/main/kotlin/at/mocode/email/EmailService.kt create mode 100644 server/src/main/kotlin/at/mocode/model/Bewerb.kt create mode 100644 server/src/main/kotlin/at/mocode/repository/NennungRepository.kt create mode 100644 server/src/main/kotlin/at/mocode/repository/TurnierRepository.kt create mode 100644 server/src/main/kotlin/at/mocode/routes/AdminRoutes.kt create mode 100644 server/src/main/kotlin/at/mocode/routes/HomeRoutes.kt create mode 100644 server/src/main/kotlin/at/mocode/routes/NennungRoutes.kt create mode 100644 server/src/main/kotlin/at/mocode/views/AdminView.kt create mode 100644 server/src/main/kotlin/at/mocode/views/HomeView.kt create mode 100644 server/src/main/kotlin/at/mocode/views/LayoutTemplate.kt create mode 100644 server/src/main/kotlin/at/mocode/views/NennungView.kt create mode 100644 server/src/test/kotlin/at/mocode/email/EmailServiceTest.kt create mode 100644 shared/src/commonMain/kotlin/at/mocode/model/Bewerb.kt diff --git a/data/meldestelle.db b/data/meldestelle.db new file mode 100644 index 0000000000000000000000000000000000000000..13b965f3c9732337b9d23be82e654e62cfeb4f8c GIT binary patch literal 32768 zcmeI5&u<$=6vy|6W5o5@&=^Ipx3s2_(320ZwrbR6?i{NE~=;@7h_{u3B-RsoqM~&g}cn zdv88(*3nw4$?D}~!C*AMt(UR}o#75~JkMRAl;b!re7fOdA6?LJ*&R6Z``VqFUM{44 zC6T{4Pfv^^Gvr(UPf}hw+ItPUcpw2JfCP{L5mDOc$w`6`XArI!~| zFmg#wr6(Js>S~%=PSey{GC4_W0W(^qq%xhp9#i74#+32!^mK5aCcd0nO)D`7L-Si1 zX9A6GA(zR3_l0FeUc8v9G0oGC64M?RkY30!J)>nytU)0XjqazgxviJk zBNeQaEfhNq-`U*On!Z~IuWy%1X5ndCz5jOv1?E z?RQ+Bz`y|i;Y7`+%C0kAW!9<7;lsN4xaEeam0-a;ff|r;$NFs*v*mKf0vg%!R-^Lu z;?{02C+C)U&H`F9_}|L+w&)2=4Dq-4f|g@9>{Vc`9=q$Zps%GCFR#fkpM-pc+D3b& z!R>2bqRmxwtB>~th9E$tR!m!Ap9M!b?W@i44tD#$>L%%kxnM>b_*?GVT-K8x8d^I%{rzv@jLTklprdXwv)whh(OQA`+y!+W) z=r#Byf<~qz(K!Lejj7dmqA@PfaopJuZ48{@UEcHO>fz)ZD{gM*X>3P_U-C@1;n~pi zY{+cUDH@L2ogSEzNbYmwA99~Oc%liS7zrQ&B!C2v01`j~NB{{S0VIF~kN^^RDhaqn zfmip$+NmmtqSuSh|4(J1FexN}1dsp{Kmter2_OL^fCP{L5@Za%o`epyHpO=1?zLY+aHl>%P0pA1P_rA}4w|sBGAUu!& z5y=gRP!v9__J@7~SB<_|O+j?(p# zx;HWa!kGXk^sX9(;=>|S^Wt&S8EASsIh&4}y3u2%F9_;w=WDtiaO`;y&4H+UwVqL) z={eTS%aWZ8fNZ{>6FhQ3D`qSCmel5J5l5agBSL2GHWBCR0Z;XV<~(S+R<^bGXlLQ* zQPUY@=4zukI}Ny~x_9b`YA}W59c+(cJO7%1`mO&3DPI{ z5;`lFE4oc9nT)8_lLV}Dq+jT@tU80e1)7?Kk_@ZW6Mj%d!ADOmR;#0xGir5Evch;< ztg+ZEgz;Hny4!|{C6t&Mrq>GNX%{B80+PWVkent^vxVKeoBGY&yBn-n-|=8dtp>bc z9Rq9ELP0MZR4L?gOasGIHyGwm91&b?GcR;k0VIF~kN^@u0!RP}AOR$R1dsp{IH&|9 zK^*2A+lQJv`$=AV!_vc|7_@EHo~&XoFAnh5CXIvj{r^7Y$ZzCp^65cEfO#VUB!C2v z01`j~NB{{S0VIF~kN^@mfCNUxZth5bP&(#a%HD{DBQpjo8y7ar|H=`9|FChrqLtS- z#{^3xJVK;ahxocoYVWK5{C|%lf0IARy#vS!Q$_+v00|%gB!C2v01`j~NB{{S0VIF~ YngraUi{ptHaSP^Ih;;ET&PyWy0&) { EngineMain.main(args) } +/** + * Application module configuration. + */ fun Application.module() { - - // Als Erstes die Datenbank konfigurieren: + // Configure database first configureDatabase() - // Danach deine anderen Konfigurationen (Routing etc.): - routing { - get("/") { - // Logger holen (optional, aber nützlich) - val log = LoggerFactory.getLogger("RootRoute") - // --- Datenbankoperationen --- - // alle DB-Zugriffe mit Exposed sollten in einer Transaktion stattfinden - val turniereFromDb = transaction { - // Optional: Füge ein Test-Turnier hinzu, WENN die Tabelle leer ist. - // Das ist nur für den ersten Test praktisch. - if (TurniereTable.selectAll().count() == 0L) { - log.info("Turnier table is empty, inserting dummy tournament...") - TurniereTable.insert { - it[id] = "dummy-01" // Eindeutige ID - it[name] = "Erstes DB Turnier" - it[datum] = "19.04.2025" // Heutiges Datum? - it[logoUrl] = null // Optional, kann null sein - it[ausschreibungUrl] = "/pdfs/ausschreibung_dummy.pdf" // Beispielpfad - } - } - - // Lese ALLE Einträge aus der TurniereTable - log.info("Fetching all tournaments from database...") - TurniereTable.selectAll().map { row -> - // Wandle jede Datenbank-Zeile (row) wieder in ein Turnier-Objekt um - Turnier( - id = row[TurniereTable.id], - name = row[TurniereTable.name], - datum = row[TurniereTable.datum], - logoUrl = row[TurniereTable.logoUrl], - ausschreibungUrl = row[TurniereTable.ausschreibungUrl] - ) - } // Das Ergebnis ist eine List - } // Ende der Transaktion - - // --- HTML-Antwort generieren --- - call.respondHtml(HttpStatusCode.OK) { - head { - title { +"Meldestelle Portal" } - } - body { - h1 { +"Willkommen beim Meldestelle Portal!" } - p { +"Datenbankverbindung erfolgreich!" } // Kleine Bestätigung - hr() - h2 { +"Aktuelle Turniere (aus Datenbank):" } // Geänderte Überschrift - - // Gib die Turnierliste aus der Datenbank aus - ul { - if (turniereFromDb.isEmpty()) { - li { +"Keine Turniere in der Datenbank gefunden." } - } else { - // Schleife über die Liste aus der DB - turniereFromDb.forEach { turnier -> - li { - strong { +turnier.name } - +" (${turnier.datum})" - // Füge die Buttons wieder hinzu - +" " - if (turnier.ausschreibungUrl != null) { - a(href = turnier.ausschreibungUrl, target = "_blank") { - button { +"Ausschreibung" } - } - +" " - } - a(href = "/nennung/${turnier.id}") { - button { +"Online Nennen" } - } - } - } - } - } - // Link zum (noch nicht funktionierenden) Admin-Bereich - hr() - p { a(href = "/admin/tournaments") { +"Zur Turnierverwaltung (TODO)" } } - } - } // <--- HIER endet der respondHtml-Block - } // Ende get("/") - } + // Configure routes + configureHomeRoutes() + configureNennungRoutes() + configureAdminRoutes() } diff --git a/server/src/main/kotlin/at/mocode/config/DependencyInjection.kt b/server/src/main/kotlin/at/mocode/config/DependencyInjection.kt new file mode 100644 index 00000000..6d07b300 --- /dev/null +++ b/server/src/main/kotlin/at/mocode/config/DependencyInjection.kt @@ -0,0 +1,44 @@ +package at.mocode.config + +import at.mocode.email.EmailService +import at.mocode.repository.NennungRepository +import at.mocode.repository.TurnierRepository +import at.mocode.views.HomeView +import at.mocode.views.NennungView +import org.slf4j.LoggerFactory + +/** + * Simple dependency injection container for the application. + * This singleton provides access to all services and repositories. + */ +object DependencyInjection { + private val log = LoggerFactory.getLogger(DependencyInjection::class.java) + + // Repositories + val turnierRepository by lazy { + log.debug("Creating TurnierRepository") + TurnierRepository() + } + + val nennungRepository by lazy { + log.debug("Creating NennungRepository") + NennungRepository() + } + + // Services + val emailService by lazy { + log.debug("Creating EmailService") + EmailService.getInstance() + } + + // Views + val homeView by lazy { + log.debug("Creating HomeView") + HomeView() + } + + val nennungView by lazy { + log.debug("Creating NennungView") + NennungView() + } +} diff --git a/server/src/main/kotlin/at/mocode/config/EmailConfig.kt b/server/src/main/kotlin/at/mocode/config/EmailConfig.kt new file mode 100644 index 00000000..a12e85a4 --- /dev/null +++ b/server/src/main/kotlin/at/mocode/config/EmailConfig.kt @@ -0,0 +1,107 @@ +package at.mocode.config + +import java.io.File +import java.io.FileInputStream +import java.util.Properties +import org.slf4j.LoggerFactory + +/** + * Configuration for email service. + * Loads configuration from environment variables with fallbacks to the .env file and then default values. + */ +object EmailConfig { + private val log = LoggerFactory.getLogger(EmailConfig::class.java) + + // Load values from the .env file if they exist + private val envProperties = loadEnvFile() + + /** + * Loads environment variables from .env file + * @return Property object containing the variables from .env file or empty Properties if a file doesn't exist + */ + private fun loadEnvFile(): Properties { + val properties = Properties() + + // Try multiple possible locations for the .env file + val possibleLocations = listOf( + ".env", // Current working directory + "../.env", // Parent directory + "../../.env" // Grandparent directory + ) + + for (location in possibleLocations) { + val envFile = File(location) + if (envFile.exists()) { + try { + FileInputStream(envFile).use { fis -> + properties.load(fis) + } + log.info("Loaded .env file from: ${envFile.absolutePath}") + break // Stop after successfully loading the file + } catch (e: Exception) { + log.error("Error loading .env file from ${envFile.absolutePath}: ${e.message}") + } + } + } + + return properties + } + + /** + * Helper function to get value from environment, .env file, or default + * @param key The environment variable key + * @param defaultValue The default value to use if the key is not found + * @return The value from environment, .env file, or default + */ + private fun getConfigValue(key: String, defaultValue: String): String { + val value = System.getenv(key) ?: envProperties.getProperty(key) ?: defaultValue + if (value == defaultValue) { + log.debug("Using default value for $key: $defaultValue") + } + return value + } + + // SMTP server configuration + val smtpHost: String = getConfigValue("SMTP_HOST", "smtp.gmail.com") + val smtpPort: Int = System.getenv("SMTP_PORT")?.toIntOrNull() + ?: envProperties.getProperty("SMTP_PORT")?.toIntOrNull() + ?: 587 + + // Authentication credentials + val smtpUsername: String = getConfigValue("SMTP_USER", "") + val smtpPassword: String = getConfigValue("SMTP_PASSWORD", "") + + // Email addresses + val recipientEmail: String = getConfigValue("RECIPIENT_EMAIL", "") + val senderEmail: String = getConfigValue("SMTP_SENDER_EMAIL", smtpUsername) + + /** + * Validates that all required configuration is present. + * @return true if the configuration is valid, false otherwise + */ + fun isValid(): Boolean { + return smtpHost.isNotBlank() && + smtpPort > 0 && + smtpUsername.isNotBlank() && + smtpPassword.isNotBlank() && + recipientEmail.isNotBlank() && + senderEmail.isNotBlank() + } + + /** + * Returns a string describing any missing configuration. + * @return A string with error messages or empty string if the configuration is valid + */ + fun getValidationErrors(): String { + val errors = mutableListOf() + + if (smtpHost.isBlank()) errors.add("SMTP_HOST is not configured") + if (smtpPort <= 0) errors.add("SMTP_PORT is invalid") + if (smtpUsername.isBlank()) errors.add("SMTP_USER is not configured") + if (smtpPassword.isBlank()) errors.add("SMTP_PASSWORD is not configured") + if (recipientEmail.isBlank()) errors.add("RECIPIENT_EMAIL is not configured") + if (senderEmail.isBlank()) errors.add("SMTP_SENDER_EMAIL is not configured") + + return errors.joinToString(", ") + } +} diff --git a/server/src/main/kotlin/at/mocode/email/EmailService.kt b/server/src/main/kotlin/at/mocode/email/EmailService.kt new file mode 100644 index 00000000..7641ecc4 --- /dev/null +++ b/server/src/main/kotlin/at/mocode/email/EmailService.kt @@ -0,0 +1,298 @@ +package at.mocode.email + +import at.mocode.config.EmailConfig +import at.mocode.model.Nennung +import jakarta.mail.* +import jakarta.mail.internet.InternetAddress +import jakarta.mail.internet.MimeBodyPart +import jakarta.mail.internet.MimeMessage +import jakarta.mail.internet.MimeMultipart +import java.util.* +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock +import org.slf4j.LoggerFactory + +/** + * Service for sending email notifications for form submissions. + * Implemented as a singleton to avoid multiple initializations. + * Thread-safe implementation with improved error handling and HTML support. + */ +class EmailService private constructor( + private val smtpHost: String, + private val smtpPort: Int, + private val smtpUsername: String, + private val smtpPassword: String, + private val recipientEmail: String, + private val senderEmail: String = smtpUsername +) { + private val log = LoggerFactory.getLogger(EmailService::class.java) + private val maxRetries = 3 + private val retryDelayMs = 1000L + + companion object { + private var instance: EmailService? = null + private val lock = ReentrantLock() + + /** + * Gets the singleton instance of EmailService. + * Initializes it if it doesn't exist yet. + * Thread-safe implementation using a lock. + */ + fun getInstance(): EmailService { + return lock.withLock { + if (instance == null) { + instance = EmailService( + smtpHost = EmailConfig.smtpHost, + smtpPort = EmailConfig.smtpPort, + smtpUsername = EmailConfig.smtpUsername, + smtpPassword = EmailConfig.smtpPassword, + recipientEmail = EmailConfig.recipientEmail, + senderEmail = EmailConfig.senderEmail + ) + } + instance!! + } + } + } + + /** + * Creates email session with the configured properties. + * + * @param debug Whether to enable debug mode for the mail session + * @return The configured mail session + */ + private fun createSession(debug: Boolean = false): Session { + val properties = Properties().apply { + put("mail.smtp.auth", "true") + put("mail.smtp.starttls.enable", "true") + put("mail.smtp.host", smtpHost) + put("mail.smtp.port", smtpPort.toString()) + put("mail.debug", debug.toString()) + put("mail.smtp.ssl.protocols", "TLSv1.2") + put("mail.smtp.connectiontimeout", "10000") + put("mail.smtp.timeout", "10000") + } + + return Session.getInstance(properties, object : Authenticator() { + override fun getPasswordAuthentication(): PasswordAuthentication { + return PasswordAuthentication(smtpUsername, smtpPassword) + } + }) + } + + /** + * Validates an email address format. + * + * @param email The email address to validate + * @return true if the email format is valid, false otherwise + */ + private fun isValidEmail(email: String): Boolean { + val emailRegex = Regex("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,6}$") + return email.matches(emailRegex) + } + + /** + * Sends an email notification with the form submission data. + * Includes retry mechanism for transient failures. + * + * @param nennung The form submission data + * @return true if the email was sent successfully, false otherwise + */ + fun sendNennungEmail(nennung: Nennung): Boolean { + log.info("Attempting to send email for ${nennung.riderName} with ${nennung.horseName}") + log.debug("SMTP Configuration: Host=$smtpHost, Port=$smtpPort, Username=$smtpUsername") + log.debug("Email addresses: From=$senderEmail, To=$recipientEmail") + + // Validate email addresses + if (!isValidEmail(senderEmail) || !isValidEmail(recipientEmail)) { + log.error("Invalid email address format: sender=$senderEmail, recipient=$recipientEmail") + return false + } + + var attempts = 0 + var lastException: Exception? = null + + while (attempts < maxRetries) { + attempts++ + try { + val session = createSession() + + val message = MimeMessage(session).apply { + setFrom(InternetAddress(senderEmail)) + setRecipients(Message.RecipientType.TO, InternetAddress.parse(recipientEmail)) + subject = nennung.turnier?.let { "Neue Nennung für ${it.name}: ${nennung.riderName} mit ${nennung.horseName}" } + ?: "Neue Nennung: ${nennung.riderName} mit ${nennung.horseName}" + + // Create multipart message with both plain text and HTML versions + val multipart = MimeMultipart("alternative") + + // Plain text part + val textPart = MimeBodyPart().apply { + setText(createEmailContent(nennung, false)) + } + multipart.addBodyPart(textPart) + + // HTML part + val htmlPart = MimeBodyPart().apply { + setContent(createEmailContent(nennung, true), "text/html; charset=utf-8") + } + multipart.addBodyPart(htmlPart) + + // Set content to the multipart + setContent(multipart) + } + + log.info("Email message prepared, attempting to send... (Attempt $attempts of $maxRetries)") + Transport.send(message) + log.info("Email sent successfully!") + return true + } catch (e: MessagingException) { + lastException = e + log.warn("Failed to send email (Attempt $attempts of $maxRetries): ${e.message}") + if (attempts < maxRetries) { + log.info("Retrying in ${retryDelayMs}ms...") + Thread.sleep(retryDelayMs) + } + } catch (e: Exception) { + lastException = e + log.error("Unexpected error sending email: ${e.message}", e) + break // Don't retry on non-messaging exceptions + } + } + + log.error("Failed to send email after $attempts attempts", lastException) + return false + } + + /** + * Sends a test email to verify the email configuration. + * + * @param recipient Optional recipient email address (defaults to configured recipient) + * @param debug Whether to enable debug mode for the mail session + * @return true if the test email was sent successfully, false otherwise + */ + fun sendTestEmail(recipient: String = recipientEmail, debug: Boolean = false): Boolean { + log.info("Sending test email to $recipient") + + if (!isValidEmail(recipient)) { + log.error("Invalid recipient email address format: $recipient") + return false + } + + try { + val session = createSession(debug) + + val message = MimeMessage(session).apply { + setFrom(InternetAddress(senderEmail)) + setRecipients(Message.RecipientType.TO, InternetAddress.parse(recipient)) + subject = "Test Email from Meldestelle Application" + + val multipart = MimeMultipart("alternative") + + // Plain text part + val textPart = MimeBodyPart().apply { + setText("Dies ist eine Test-Email vom Meldestelle System.\n\nWenn Sie diese Email erhalten haben, funktioniert die Email-Konfiguration korrekt.") + } + multipart.addBodyPart(textPart) + + // HTML part + val htmlPart = MimeBodyPart().apply { + setContent(""" + + +

Dies ist eine Test-Email vom Meldestelle System

+

Wenn Sie diese Email erhalten haben, funktioniert die Email-Konfiguration korrekt.

+

SMTP-Konfiguration:

+
    +
  • Host: $smtpHost
  • +
  • Port: $smtpPort
  • +
  • Benutzername: $smtpUsername
  • +
+ + + """.trimIndent(), "text/html; charset=utf-8") + } + multipart.addBodyPart(htmlPart) + + setContent(multipart) + } + + log.info("Test email prepared, attempting to send...") + Transport.send(message) + log.info("Test email sent successfully!") + return true + } catch (e: MessagingException) { + log.error("Failed to send test email: ${e.message}", e) + return false + } catch (e: Exception) { + log.error("Unexpected error sending test email: ${e.message}", e) + return false + } + } + + /** + * Creates the email content from the form submission data. + * + * @param nennung The form submission data + * @param html Whether to generate HTML content (true) or plain text (false) + * @return The formatted email content + */ + private fun createEmailContent(nennung: Nennung, html: Boolean = false): String { + if (!html) { + // Plain text version + val sb = StringBuilder() + + // Add tournament information if available + nennung.turnier?.let { turnier -> + sb.appendLine(turnier.name) + sb.appendLine() + } + + sb.appendLine("Reiter: ${nennung.riderName}") + sb.appendLine("Pferd: ${nennung.horseName}") + sb.appendLine("E-Mail: ${nennung.email}") + sb.appendLine("Telefon: ${nennung.phone}") + sb.appendLine() + sb.appendLine("Bewerbe:") + nennung.selectedEvents.forEach { event -> + sb.appendLine("- $event") + } + sb.appendLine() + if (nennung.comments.isNotBlank()) { + sb.appendLine("Bemerkungen:") + sb.appendLine(nennung.comments) + } + + return sb.toString() + } else { + // HTML version + val sb = StringBuilder() + sb.append("") + + // Add tournament information if available + nennung.turnier?.let { turnier -> + sb.append("

${turnier.name}

") + } + + sb.append("

Reiter: ${nennung.riderName}

") + sb.append("

Pferd: ${nennung.horseName}

") + sb.append("

E-Mail: ${nennung.email}

") + sb.append("

Telefon: ${nennung.phone}

") + + sb.append("

Bewerbe:

") + sb.append("
    ") + nennung.selectedEvents.forEach { event -> + sb.append("
  • $event
  • ") + } + sb.append("
") + + if (nennung.comments.isNotBlank()) { + sb.append("

Bemerkungen:

") + sb.append("

${nennung.comments.replace("\n", "
")}

") + } + + sb.append("") + return sb.toString() + } + } +} diff --git a/server/src/main/kotlin/at/mocode/model/Bewerb.kt b/server/src/main/kotlin/at/mocode/model/Bewerb.kt new file mode 100644 index 00000000..3d38288a --- /dev/null +++ b/server/src/main/kotlin/at/mocode/model/Bewerb.kt @@ -0,0 +1,22 @@ +package at.mocode.model + +import kotlinx.serialization.Serializable + +/** + * Represents a competition (Bewerb) within a tournament. + * A competition has specific details like number, title, class, and optional task. + */ +@Serializable +data class Bewerb( + /** Competition number, e.g. 1, 2, etc. */ + val nummer: Int, + + /** Title of the competition, e.g. "Stilspringprüfung" or "Dressurprüfung" */ + val titel: String, + + /** Class/level of the competition, e.g. "60 cm" or "Kl. A" */ + val klasse: String = "", + + /** Optional task identifier, e.g. "DRA 1" */ + val task: String? = null +) diff --git a/server/src/main/kotlin/at/mocode/model/Nennung.kt b/server/src/main/kotlin/at/mocode/model/Nennung.kt index 0082c2cf..b030e366 100644 --- a/server/src/main/kotlin/at/mocode/model/Nennung.kt +++ b/server/src/main/kotlin/at/mocode/model/Nennung.kt @@ -4,13 +4,24 @@ import kotlinx.serialization.Serializable @Serializable data class Nennung( - // Wir brauchen die Turnier-ID, um die Nennung zuzuordnen - val turnierId: String, - // Einfache Felder für den Start - val riderName: String = "", // Standardwerte für leeres Formular - val horseName: String = "", - val email: String = "", - val comments: String? = null - // Hier kommen später Felder hinzu: Verein, Lizenznr., Tel, - // und vor allem: die Auswahl der Prüfungen! + /** Name of the rider */ + val riderName: String, + + /** Name of the horse */ + val horseName: String, + + /** Email address for contact */ + val email: String, + + /** Phone number for contact */ + val phone: String, + + /** List of selected event numbers */ + val selectedEvents: List, + + /** Additional comments or wishes */ + val comments: String, + + /** The tournament this registration is for */ + val turnier: Turnier ) diff --git a/server/src/main/kotlin/at/mocode/model/Turnier.kt b/server/src/main/kotlin/at/mocode/model/Turnier.kt index 5e703109..23013f10 100644 --- a/server/src/main/kotlin/at/mocode/model/Turnier.kt +++ b/server/src/main/kotlin/at/mocode/model/Turnier.kt @@ -4,11 +4,15 @@ import kotlinx.serialization.Serializable @Serializable data class Turnier( - val id: String, // Eine eindeutige ID für das Turnier (z.B. eine UUID als String) - val name: String, // Der Name, z.B. "CDN-C Edelhof April 2025" - val datum: String, // Das Datum oder der Zeitraum, erstmal als Text, z.B. "14.04.2025 - 15.04.2025" - val logoUrl: String? = null, // Optional: Link zum Logo des Veranstalters - val ausschreibungUrl: String? = null // Optional: Link zur Ausschreibungs-PDF - // Hier können später viele weitere Felder hinzukommen: - // Ort, Veranstalter, Status (geplant, läuft, beendet), Disziplinen etc. + /** The name of the tournament, e.g. "CSN-C NEU CSNP-C NEU NEUMARKT/M., OÖ" */ + val name: String, + + /** The date of the tournament as a formatted string, e.g. "7.JUNI 2025" */ + val datum: String, + + /** Unique identifier for the tournament */ + val number: Int, + + /** List of competitions (Bewerbe) associated with this tournament */ + var bewerbe: List = emptyList() ) diff --git a/server/src/main/kotlin/at/mocode/plugins/Database.kt b/server/src/main/kotlin/at/mocode/plugins/Database.kt index ba3feea4..fb64169d 100644 --- a/server/src/main/kotlin/at/mocode/plugins/Database.kt +++ b/server/src/main/kotlin/at/mocode/plugins/Database.kt @@ -4,52 +4,67 @@ import com.zaxxer.hikari.HikariConfig import com.zaxxer.hikari.HikariDataSource import org.jetbrains.exposed.sql.Database import org.slf4j.LoggerFactory - import org.jetbrains.exposed.sql.SchemaUtils import org.jetbrains.exposed.sql.transactions.transaction import at.mocode.tables.TurniereTable +import at.mocode.tables.BewerbeTable +import at.mocode.tables.NennungenTable +import at.mocode.tables.NennungEventsTable +import java.io.File +/** + * Configures the database connection based on the environment. + * Supports three environments: + * - Test: Uses in-memory SQLite + * - Development (IDEA): Uses file-based SQLite + * - Production/Docker: Uses PostgreSQL with connection pooling + */ fun configureDatabase() { val log = LoggerFactory.getLogger("DatabaseInitialization") - var connectionSuccessful = false // Flag: Wurde irgendeine Verbindung hergestellt? + var connectionSuccessful = false // Flag: Was any connection established? - // Prüfen, ob wir in einer Testumgebung sind (z.B. über System Property) + // Check if we're in a test environment (e.g. via System Property) val isTestEnvironment = System.getProperty("isTestEnvironment")?.toBoolean() ?: false if (isTestEnvironment) { - log.info("Test environment detected, using in-memory H2 database (test)...") + log.info("Test environment detected, using SQLite in-memory database (test)...") try { - // H2 im PostgreSQL-Kompatibilitätsmodus starten, kann helfen - Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;MODE=PostgreSQL", driver = "org.h2.Driver") - log.info("Connected to H2 (test) successfully.") + Database.connect("jdbc:sqlite::memory:", driver = "org.sqlite.JDBC") + log.info("Connected to SQLite in-memory (test) successfully.") connectionSuccessful = true } catch (e: Exception) { - log.error("Failed to connect to H2 (test)!", e) - throw e // Fehler weiterwerfen, Test soll fehlschlagen + log.error("Failed to connect to SQLite (test)!", e) + throw e // Rethrow error, test should fail } } else { - // Prüfen, ob wir in IDEA laufen (keine Docker Umgebungsvariablen gesetzt) - // wir prüfen nur eine Variable, das reicht meistens + // Check if we're running in IDEA (no Docker environment variables set) + // We only check one variable, that's usually enough val dbHostFromEnv = System.getenv("DB_HOST") val isIdeaEnvironment = (dbHostFromEnv == null) if (isIdeaEnvironment) { - log.info("IDEA environment detected (missing DB_HOST), using in-memory H2 database (dev)...") + // Ensure the data directory exists + val dataDir = File("data") + if (!dataDir.exists()) { + dataDir.mkdir() + } + + log.info("IDEA environment detected (missing DB_HOST), using SQLite file database (dev)...") try { - Database.connect("jdbc:h2:mem:dev;DB_CLOSE_DELAY=-1;MODE=PostgreSQL", driver = "org.h2.Driver") - log.info("Connected to H2 (dev) successfully.") + Database.connect("jdbc:sqlite:data/meldestelle.db", driver = "org.sqlite.JDBC") + log.info("Connected to SQLite file database (dev) successfully.") connectionSuccessful = true } catch (e: Exception) { - log.error("Failed to connect to H2 (dev)!", e) - // Hier vielleicht nicht werfen, damit App in IDE trotzdem startet? Oder doch? → Aktuell wirft es. + log.error("Failed to connect to SQLite (dev)!", e) + // Maybe don't throw here so the app starts in IDE anyway? Currently it throws. throw e } } else { - // Normale Docker/Produktionsumgebung -> PostgreSQL verwenden + // Normal Docker/Production environment -> use PostgreSQL log.info("Production/Docker environment detected, connecting to PostgreSQL...") try { - // Lese Konfiguration direkt aus Umgebungsvariablen - val dbHost = dbHostFromEnv // Sicherer Fallback + // Read configuration directly from environment variables + val dbHost = dbHostFromEnv // Safe fallback val dbPort = System.getenv("DB_PORT") ?: "5432" val dbName = System.getenv("DB_NAME") ?: error("DB_NAME not set in environment") val dbUser = System.getenv("DB_USER") ?: error("DB_USER not set in environment") @@ -74,34 +89,24 @@ fun configureDatabase() { connectionSuccessful = true } catch (e: Exception) { log.error("Failed to initialize PostgreSQL connection pool!", e) - throw e // Fehler weiterwerfen, App soll nicht starten ohne DB in Prod + throw e // Rethrow error, app should not start without DB in prod } } } - // --- Schema Initialisierung (JETZT ZENTRALISIERT) --- - // Führe dies nur aus, wenn *irgendeine* DB-Verbindung erfolgreich war - transaction { // Führe Schema-Operationen in einer Transaktion aus + // --- Schema Initialization (NOW CENTRALIZED) --- + // Only execute this if *any* DB connection was successful + transaction { // Execute schema operations in a transaction log.info("Initializing/Verifying database schema...") try { - // Erstellt die Tabelle(n), falls sie noch nicht existieren - SchemaUtils.create(TurniereTable) - // Füge hier später weitere Tabellen hinzu: - // SchemaUtils.create(TurniereTable, NennungenTable, ...) + // Create the table(s) if they don't exist yet + SchemaUtils.create(TurniereTable, BewerbeTable, NennungenTable, NennungEventsTable) log.info("Database schema initialized successfully (tables created/verified).") } catch (e: Exception) { log.error("Failed to initialize database schema!", e) - // Hier könntest du entscheiden, ob ein Fehler beim Schema kritisch ist - // throw e // Auskommentiert: App startet evtl. trotzdem, auch wenn Schema fehlt/falsch ist + // Here you could decide if a schema error is critical + // throw e // Commented out: App might start anyway, even if schema is missing/wrong } } - - // --- TODO für den NÄCHSTEN Schritt --- - // Hier kommt später die Logik zum Erstellen der Tabellen hin, - // z.B. innerhalb einer Transaktion: - // transaction { - // SchemaUtils.create(TurniereTable) // Erstellt die Tabelle, wenn sie nicht existiert - // } - // ------------------------------------ } diff --git a/server/src/main/kotlin/at/mocode/repository/NennungRepository.kt b/server/src/main/kotlin/at/mocode/repository/NennungRepository.kt new file mode 100644 index 00000000..a5222325 --- /dev/null +++ b/server/src/main/kotlin/at/mocode/repository/NennungRepository.kt @@ -0,0 +1,80 @@ +package at.mocode.repository + +import at.mocode.config.DependencyInjection +import at.mocode.model.Nennung +import at.mocode.tables.NennungEventsTable +import at.mocode.tables.NennungenTable +import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.transactions.transaction +import org.slf4j.LoggerFactory + +/** + * Repository class for handling registration-related database operations. + */ +class NennungRepository { + private val log = LoggerFactory.getLogger(this::class.java) + private val turnierRepository by lazy { DependencyInjection.turnierRepository } + + /** + * Saves a registration to the database. + * @param nennung The registration to save + * @return The ID of the saved registration + */ + fun saveNennung(nennung: Nennung): Int = transaction { + log.info("Saving registration for ${nennung.riderName} with ${nennung.horseName}") + + // Insert the registration + val nennungId = NennungenTable.insert { + it[riderName] = nennung.riderName + it[horseName] = nennung.horseName + it[email] = nennung.email + it[phone] = nennung.phone + it[comments] = nennung.comments + it[turnierNumber] = nennung.turnier.number + } get NennungenTable.id + + // Insert the selected events + nennung.selectedEvents.forEach { eventNumber -> + NennungEventsTable.insert { + it[NennungEventsTable.nennungId] = nennungId + it[NennungEventsTable.eventNumber] = eventNumber + } + } + + log.info("Registration saved with ID: $nennungId") + nennungId + } + + /** + * Creates a Nennung object from form parameters. + * @param riderName The name of the rider + * @param horseName The name of the horse + * @param email The email address for contact + * @param phone The phone number for contact + * @param selectedEvents The list of selected event numbers + * @param comments Additional comments or wishes + * @param turnierNumber The number of the tournament + * @return The created Nennung object, or null if the tournament was not found + */ + fun createNennung( + riderName: String, + horseName: String, + email: String, + phone: String, + selectedEvents: List, + comments: String, + turnierNumber: Int + ): Nennung? { + val turnier = turnierRepository.getTurnierByNumber(turnierNumber) ?: return null + + return Nennung( + riderName = riderName, + horseName = horseName, + email = email, + phone = phone, + selectedEvents = selectedEvents, + comments = comments, + turnier = turnier + ) + } +} diff --git a/server/src/main/kotlin/at/mocode/repository/TurnierRepository.kt b/server/src/main/kotlin/at/mocode/repository/TurnierRepository.kt new file mode 100644 index 00000000..f7ff4e46 --- /dev/null +++ b/server/src/main/kotlin/at/mocode/repository/TurnierRepository.kt @@ -0,0 +1,246 @@ +package at.mocode.repository + +import at.mocode.model.Bewerb +import at.mocode.model.Turnier +import at.mocode.tables.BewerbeTable +import at.mocode.tables.TurniereTable +import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.selectAll +import org.jetbrains.exposed.sql.transactions.transaction +import org.slf4j.LoggerFactory + +/** + * Repository class for handling tournament-related database operations. + */ +class TurnierRepository { + private val log = LoggerFactory.getLogger(this::class.java) + + /** + * Creates a new tournament with competitions. + * @param number The tournament number + * @param name The tournament name + * @param datum The tournament date + * @param bewerbe List of competitions for the tournament + * @return The created tournament or null if creation failed + */ + fun createTurnier(number: Int, name: String, datum: String, bewerbe: List): Turnier? = transaction { + try { + // Check if a tournament with this number already exists + val existingTurnier = TurniereTable.select { TurniereTable.number eq number }.singleOrNull() + if (existingTurnier != null) { + log.error("Tournament with number $number already exists") + return@transaction null + } + + // Insert tournament + TurniereTable.insert { + it[TurniereTable.number] = number + it[TurniereTable.name] = name + it[TurniereTable.datum] = datum + } + + // Insert competitions + bewerbe.forEach { bewerb -> + BewerbeTable.insert { + it[BewerbeTable.nummer] = bewerb.nummer + it[BewerbeTable.titel] = bewerb.titel + it[BewerbeTable.klasse] = bewerb.klasse + it[BewerbeTable.task] = bewerb.task + it[BewerbeTable.turnierNumber] = number + } + } + + // Return the created tournament + Turnier( + number = number, + name = name, + datum = datum, + bewerbe = bewerbe + ) + } catch (e: Exception) { + log.error("Error creating tournament", e) + null + } + } + + /** + * Updates an existing tournament with competitions. + * @param number The tournament number + * @param name The new tournament name + * @param datum The new tournament date + * @param bewerbe The new list of competitions + * @return The updated tournament or null if update failed + */ + fun updateTurnier(number: Int, name: String, datum: String, bewerbe: List): Turnier? = transaction { + try { + // Check if the tournament exists + val existingTurnier = TurniereTable.select { TurniereTable.number eq number }.singleOrNull() + if (existingTurnier == null) { + log.error("Tournament with number $number not found") + return@transaction null + } + + // Update tournament + TurniereTable.update({ TurniereTable.number eq number }) { + it[TurniereTable.name] = name + it[TurniereTable.datum] = datum + } + + // Delete existing competitions + BewerbeTable.deleteWhere { BewerbeTable.turnierNumber eq number } + + // Insert new competitions + bewerbe.forEach { bewerb -> + BewerbeTable.insert { + it[BewerbeTable.nummer] = bewerb.nummer + it[BewerbeTable.titel] = bewerb.titel + it[BewerbeTable.klasse] = bewerb.klasse + it[BewerbeTable.task] = bewerb.task + it[BewerbeTable.turnierNumber] = number + } + } + + // Return the updated tournament + Turnier( + number = number, + name = name, + datum = datum, + bewerbe = bewerbe + ) + } catch (e: Exception) { + log.error("Error updating tournament", e) + null + } + } + + /** + * Retrieves all tournaments from the database with their associated competitions. + * @return List of Turnier objects + */ + fun getAllTurniere(): List = transaction { + log.info("Fetching all tournaments from database...") + + // Get all tournaments + val turniere = TurniereTable.selectAll().map { row -> + Turnier( + name = row[TurniereTable.name], + datum = row[TurniereTable.datum], + number = row[TurniereTable.number] + ) + } + + // For each tournament, get its competitions + turniere.forEach { turnier -> + val bewerbeList = BewerbeTable.selectAll().where { BewerbeTable.turnierNumber eq turnier.number }.map { row -> + Bewerb( + nummer = row[BewerbeTable.nummer], + titel = row[BewerbeTable.titel], + klasse = row[BewerbeTable.klasse], + task = row[BewerbeTable.task] + ) + } + turnier.bewerbe = bewerbeList + } + + turniere + } + + /** + * Inserts dummy tournaments with competitions if the table is empty. + */ + fun insertDummyTurnierIfEmpty() = transaction { + if (TurniereTable.selectAll().count() == 0L) { + log.info("Turnier table is empty, inserting dummy tournaments...") + + // Insert first tournament + val turnierNumber1 = 25319 + TurniereTable.insert { + it[TurniereTable.number] = turnierNumber1 + it[TurniereTable.name] = "CSN-C Edelhof April 2025" + it[TurniereTable.datum] = "14.04.2025 - 15.04.2025" + } + + // Insert competitions for first tournament + BewerbeTable.insert { + it[BewerbeTable.nummer] = 1 + it[BewerbeTable.titel] = "Stilspringprüfung" + it[BewerbeTable.klasse] = "60 cm" + it[BewerbeTable.task] = null + it[BewerbeTable.turnierNumber] = turnierNumber1 + } + + BewerbeTable.insert { + it[BewerbeTable.nummer] = 2 + it[BewerbeTable.titel] = "Dressurprüfung" + it[BewerbeTable.klasse] = "Kl. A" + it[BewerbeTable.task] = "DRA 1" + it[BewerbeTable.turnierNumber] = turnierNumber1 + } + + // Insert second tournament (as specified in the issue description) + val turnierNumber2 = 25320 + TurniereTable.insert { + it[TurniereTable.number] = turnierNumber2 + it[TurniereTable.name] = "CDN-C CDNP-C NEU Neumarkt/M., OÖ" + it[TurniereTable.datum] = "8. JUNI 2025" + } + + // Insert competitions for second tournament + BewerbeTable.insert { + it[BewerbeTable.nummer] = 1 + it[BewerbeTable.titel] = "Dressurprüfung" + it[BewerbeTable.klasse] = "Kl. A" + it[BewerbeTable.task] = "DRA 2" + it[BewerbeTable.turnierNumber] = turnierNumber2 + } + + BewerbeTable.insert { + it[BewerbeTable.nummer] = 2 + it[BewerbeTable.titel] = "Dressurprüfung" + it[BewerbeTable.klasse] = "Kl. L" + it[BewerbeTable.task] = "DRL 1" + it[BewerbeTable.turnierNumber] = turnierNumber2 + } + + BewerbeTable.insert { + it[BewerbeTable.nummer] = 3 + it[BewerbeTable.titel] = "Dressurprüfung" + it[BewerbeTable.klasse] = "Kl. L" + it[BewerbeTable.task] = "DRL 2" + it[BewerbeTable.turnierNumber] = turnierNumber2 + } + + log.info("Dummy tournaments and competitions inserted successfully.") + } + } + + /** + * Gets a tournament by its number. + * @param number The tournament number + * @return The tournament or null if not found + */ + fun getTurnierByNumber(number: Int): Turnier? = transaction { + // Get the tournament + val turnierRow = TurniereTable.selectAll().where { TurniereTable.number eq number }.singleOrNull() ?: return@transaction null + + val turnier = Turnier( + name = turnierRow[TurniereTable.name], + datum = turnierRow[TurniereTable.datum], + number = turnierRow[TurniereTable.number] + ) + + // Get competitions for this tournament + val bewerbeList = BewerbeTable.selectAll().where { BewerbeTable.turnierNumber eq number }.map { row -> + Bewerb( + nummer = row[BewerbeTable.nummer], + titel = row[BewerbeTable.titel], + klasse = row[BewerbeTable.klasse], + task = row[BewerbeTable.task] + ) + } + + turnier.bewerbe = bewerbeList + turnier + } +} diff --git a/server/src/main/kotlin/at/mocode/routes/AdminRoutes.kt b/server/src/main/kotlin/at/mocode/routes/AdminRoutes.kt new file mode 100644 index 00000000..2f2f7097 --- /dev/null +++ b/server/src/main/kotlin/at/mocode/routes/AdminRoutes.kt @@ -0,0 +1,162 @@ +package at.mocode.routes + +import at.mocode.model.Bewerb +import at.mocode.repository.TurnierRepository +import at.mocode.views.AdminView +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import org.slf4j.LoggerFactory + +/** + * Configures routes for tournament administration. + */ +fun Route.adminRoutes() { + val log = LoggerFactory.getLogger("AdminRoutes") + val turnierRepository = TurnierRepository() + val adminView = AdminView() + + // Route to display the tournament management page + get("/admin/tournaments") { + log.info("Handling request to tournament management page") + + // Get all tournaments + val turniere = turnierRepository.getAllTurniere() + + // Render the tournament management page + adminView.renderTournamentManagementPage(call, turniere) + } + + // Route to display the tournament edit page + get("/admin/tournaments/edit/{number}") { + val turnierNumber = call.parameters["number"]?.toIntOrNull() + if (turnierNumber == null) { + call.respond(HttpStatusCode.BadRequest, "Invalid tournament number") + return@get + } + + log.info("Displaying edit form for tournament $turnierNumber") + + // Get the tournament + val turnier = turnierRepository.getTurnierByNumber(turnierNumber) + if (turnier == null) { + call.respond(HttpStatusCode.NotFound, "Tournament not found") + return@get + } + + // Render the edit form + adminView.renderTournamentEditPage(call, turnier) + } + + // Route to handle tournament creation + post("/admin/tournaments/add") { + log.info("Processing tournament creation") + + // Parse form parameters + val formParameters = call.receiveParameters() + val number = formParameters["number"]?.toIntOrNull() + val name = formParameters["name"] + val datum = formParameters["datum"] + + // Validate required fields + if (number == null || name.isNullOrBlank() || datum.isNullOrBlank()) { + call.respond(HttpStatusCode.BadRequest, "All fields are required") + return@post + } + + // Parse competitions + val bewerbNummern = formParameters.getAll("bewerb-nummer[]")?.mapNotNull { it.toIntOrNull() } ?: emptyList() + val bewerbTitel = formParameters.getAll("bewerb-titel[]") ?: emptyList() + val bewerbKlasse = formParameters.getAll("bewerb-klasse[]") ?: emptyList() + val bewerbTask = formParameters.getAll("bewerb-task[]") ?: emptyList() + + // Create list of competitions + val bewerbe = mutableListOf() + for (i in bewerbNummern.indices) { + if (i < bewerbTitel.size && i < bewerbKlasse.size && i < bewerbTask.size) { + bewerbe.add( + Bewerb( + nummer = bewerbNummern[i], + titel = bewerbTitel[i], + klasse = bewerbKlasse[i], + task = if (bewerbTask[i].isNotBlank()) bewerbTask[i] else null + ) + ) + } + } + + // Create the tournament + val turnier = turnierRepository.createTurnier(number, name, datum, bewerbe) + if (turnier == null) { + call.respond(HttpStatusCode.InternalServerError, "Failed to create tournament") + return@post + } + + // Redirect to tournament management page + call.respondRedirect("/admin/tournaments") + } + + // Route to handle tournament update + post("/admin/tournaments/update/{number}") { + val turnierNumber = call.parameters["number"]?.toIntOrNull() + if (turnierNumber == null) { + call.respond(HttpStatusCode.BadRequest, "Invalid tournament number") + return@post + } + + log.info("Processing tournament update for tournament $turnierNumber") + + // Parse form parameters + val formParameters = call.receiveParameters() + val name = formParameters["name"] + val datum = formParameters["datum"] + + // Validate required fields + if (name.isNullOrBlank() || datum.isNullOrBlank()) { + call.respond(HttpStatusCode.BadRequest, "All fields are required") + return@post + } + + // Parse competitions + val bewerbNummern = formParameters.getAll("bewerb-nummer[]")?.mapNotNull { it.toIntOrNull() } ?: emptyList() + val bewerbTitel = formParameters.getAll("bewerb-titel[]") ?: emptyList() + val bewerbKlasse = formParameters.getAll("bewerb-klasse[]") ?: emptyList() + val bewerbTask = formParameters.getAll("bewerb-task[]") ?: emptyList() + + // Create list of competitions + val bewerbe = mutableListOf() + for (i in bewerbNummern.indices) { + if (i < bewerbTitel.size && i < bewerbKlasse.size && i < bewerbTask.size) { + bewerbe.add( + Bewerb( + nummer = bewerbNummern[i], + titel = bewerbTitel[i], + klasse = bewerbKlasse[i], + task = if (bewerbTask[i].isNotBlank()) bewerbTask[i] else null + ) + ) + } + } + + // Update the tournament + val turnier = turnierRepository.updateTurnier(turnierNumber, name, datum, bewerbe) + if (turnier == null) { + call.respond(HttpStatusCode.InternalServerError, "Failed to update tournament") + return@post + } + + // Redirect to tournament management page + call.respondRedirect("/admin/tournaments") + } +} + +/** + * Extension function to register admin routes. + */ +fun Application.configureAdminRoutes() { + routing { + adminRoutes() + } +} diff --git a/server/src/main/kotlin/at/mocode/routes/HomeRoutes.kt b/server/src/main/kotlin/at/mocode/routes/HomeRoutes.kt new file mode 100644 index 00000000..8a31dc97 --- /dev/null +++ b/server/src/main/kotlin/at/mocode/routes/HomeRoutes.kt @@ -0,0 +1,37 @@ +package at.mocode.routes + +import at.mocode.config.DependencyInjection +import io.ktor.server.application.* +import io.ktor.server.routing.* +import org.slf4j.LoggerFactory + +/** + * Configures routes for the home page. + */ +fun Route.homeRoutes() { + val log = LoggerFactory.getLogger("HomeRoutes") + val turnierRepository = DependencyInjection.turnierRepository + val homeView = DependencyInjection.homeView + + get("/") { + log.info("Handling request to home page") + + // Insert dummy tournament if needed + turnierRepository.insertDummyTurnierIfEmpty() + + // Get all tournaments + val turniere = turnierRepository.getAllTurniere() + + // Render the home page + homeView.renderHomePage(call, turniere) + } +} + +/** + * Extension function to register home routes. + */ +fun Application.configureHomeRoutes() { + routing { + homeRoutes() + } +} diff --git a/server/src/main/kotlin/at/mocode/routes/NennungRoutes.kt b/server/src/main/kotlin/at/mocode/routes/NennungRoutes.kt new file mode 100644 index 00000000..ef44f274 --- /dev/null +++ b/server/src/main/kotlin/at/mocode/routes/NennungRoutes.kt @@ -0,0 +1,119 @@ +package at.mocode.routes + +import at.mocode.config.DependencyInjection +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import org.slf4j.LoggerFactory + +/** + * Configures routes for tournament registration forms. + */ +fun Route.nennungRoutes() { + val log = LoggerFactory.getLogger("NennungRoutes") + val turnierRepository = DependencyInjection.turnierRepository + val nennungRepository = DependencyInjection.nennungRepository + val nennungView = DependencyInjection.nennungView + val emailService = DependencyInjection.emailService + + // Route to display the registration form for a specific tournament + get("/nennung/{number}") { + val turnierNumber = call.parameters["number"]?.toIntOrNull() + if (turnierNumber == null) { + call.respond(HttpStatusCode.BadRequest, "Invalid tournament number") + return@get + } + + log.info("Displaying registration form for tournament $turnierNumber") + + // Get the tournament + val turnier = turnierRepository.getTurnierByNumber(turnierNumber) + if (turnier == null) { + call.respond(HttpStatusCode.NotFound, "Tournament not found") + return@get + } + + // Render the registration form + nennungView.renderNennungForm(call, turnier) + } + + // Route to handle form submissions + post("/nennung/{number}/submit") { + val turnierNumber = call.parameters["number"]?.toIntOrNull() + if (turnierNumber == null) { + call.respond(HttpStatusCode.BadRequest, "Invalid tournament number") + return@post + } + + log.info("Processing registration form submission for tournament $turnierNumber") + + // Get the tournament + val turnier = turnierRepository.getTurnierByNumber(turnierNumber) + if (turnier == null) { + call.respond(HttpStatusCode.NotFound, "Tournament not found") + return@post + } + + // Parse form parameters + val formParameters = call.receiveParameters() + val riderName = formParameters["riderName"] ?: "" + val horseName = formParameters["horseName"] ?: "" + val email = formParameters["email"] ?: "" + val phone = formParameters["phone"] ?: "" + val comments = formParameters["comments"] ?: "" + val selectedEvents = formParameters.getAll("selectedEvents") ?: emptyList() + + // Validate required fields + if (riderName.isBlank() || horseName.isBlank()) { + call.respond(HttpStatusCode.BadRequest, "Rider name and horse name are required") + return@post + } + + // Validate that at least one contact method is provided + if (email.isBlank() && phone.isBlank()) { + call.respond(HttpStatusCode.BadRequest, "Either email or phone number is required") + return@post + } + + // Create Nennung object using repository + val nennung = nennungRepository.createNennung( + riderName = riderName, + horseName = horseName, + email = email, + phone = phone, + selectedEvents = selectedEvents, + comments = comments, + turnierNumber = turnierNumber + ) + + if (nennung == null) { + call.respond(HttpStatusCode.InternalServerError, "Failed to create registration") + return@post + } + + // Save the registration to the database + val nennungId = nennungRepository.saveNennung(nennung) + log.info("Registration saved with ID: $nennungId") + + // Send email notification + val emailSent = emailService.sendNennungEmail(nennung) + if (!emailSent) { + log.error("Failed to send email notification for registration: $riderName with $horseName") + // Continue anyway, we don't want to block the user if email fails + } + + // Render confirmation page + nennungView.renderConfirmationPage(call, turnier, riderName, horseName) + } +} + +/** + * Extension function to register nennung routes. + */ +fun Application.configureNennungRoutes() { + routing { + nennungRoutes() + } +} diff --git a/server/src/main/kotlin/at/mocode/tables/Tables.kt b/server/src/main/kotlin/at/mocode/tables/Tables.kt index d22205f0..db3e2d2d 100644 --- a/server/src/main/kotlin/at/mocode/tables/Tables.kt +++ b/server/src/main/kotlin/at/mocode/tables/Tables.kt @@ -3,30 +3,142 @@ package at.mocode.tables import org.jetbrains.exposed.sql.Table import org.jetbrains.exposed.sql.Column -// Definiert die Struktur der Tabelle "turniere" in der Datenbank -object TurniereTable : Table("turniere") { // "turniere" ist der Name der Tabelle in PostgreSQL +/** + * Defines the structure of the "turniere" (tournaments) table in the database. + */ +object TurniereTable : Table("turniere") { + /** + * Unique number for the tournament, used as primary key. + */ + val number: Column = integer("number").uniqueIndex() - // Spaltendefinitionen - wir mappen die Felder unserer data class Turnier - // wir wählen hier passende SQL-Datentypen aus. - - // id: Wir nehmen VARCHAR(36) an, falls wir UUIDs als Strings speichern. - // uniqueIndex() sorgt für Eindeutigkeit und ist gut für Primärschlüssel. - val id: Column = varchar("id", 36).uniqueIndex() - - // name: Ein Textfeld, max. 255 Zeichen + /** + * Name of the tournament, max 255 characters. + */ val name: Column = varchar("name", 255) - // datum: Vorerst einfacher Text, max. 100 Zeichen + /** + * Date of the tournament as text, max 100 characters. + */ val datum: Column = varchar("datum", 100) - // logoUrl: Textfeld, max. 500 Zeichen, kann NULL sein (.nullable()) - val logoUrl: Column = varchar("logo_url", 500).nullable() + // Define the 'number' column as the primary key for this table + override val primaryKey = PrimaryKey(number) +} - // ausschreibungUrl: Textfeld, max. 500 Zeichen, kann NULL sein - val ausschreibungUrl: Column = varchar("ausschreibung_url", 500).nullable() +/** + * Defines the structure of the "bewerbe" (competitions) table in the database. + */ +object BewerbeTable : Table("bewerbe") { + /** + * Auto-generated ID for the competition. + */ + val id: Column = integer("id").autoIncrement() - // Definiert die Spalte 'id' als Primärschlüssel für diese Tabelle + /** + * Number of the competition. + */ + val nummer: Column = integer("nummer") + + /** + * Title of the competition. + */ + val titel: Column = varchar("titel", 255) + + /** + * Class/level of the competition. + */ + val klasse: Column = varchar("klasse", 100) + + /** + * Optional task identifier. + */ + val task: Column = varchar("task", 100).nullable() + + /** + * Foreign key to the tournament table. + */ + val turnierNumber: Column = integer("turnier_number") + + init { + foreignKey(turnierNumber to TurniereTable.number) + } + + // Define the 'id' column as the primary key for this table override val primaryKey = PrimaryKey(id) } -// Hier können später weitere Table-Objekte für Nennung, Prüfung etc. hinzukommen. +/** + * Defines the structure of the "nennungen" (registrations) table in the database. + */ +object NennungenTable : Table("nennungen") { + /** + * Auto-generated ID for the registration. + */ + val id: Column = integer("id").autoIncrement() + + /** + * Name of the rider. + */ + val riderName: Column = varchar("rider_name", 255) + + /** + * Name of the horse. + */ + val horseName: Column = varchar("horse_name", 255) + + /** + * Email address for contact. + */ + val email: Column = varchar("email", 255) + + /** + * Phone number for contact. + */ + val phone: Column = varchar("phone", 100) + + /** + * Additional comments or wishes. + */ + val comments: Column = text("comments") + + /** + * Foreign key to the tournament table. + */ + val turnierNumber: Column = integer("turnier_number") + + init { + foreignKey(turnierNumber to TurniereTable.number) + } + + // Define the 'id' column as the primary key for this table + override val primaryKey = PrimaryKey(id) +} + +/** + * Defines the structure of the "nennung_events" table in the database. + * This table stores the selected competitions for each registration. + */ +object NennungEventsTable : Table("nennung_events") { + /** + * Auto-generated ID for the entry. + */ + val id: Column = integer("id").autoIncrement() + + /** + * Foreign key to the registration table. + */ + val nennungId: Column = integer("nennung_id") + + /** + * Number of the selected competition. + */ + val eventNumber: Column = varchar("event_number", 100) + + init { + foreignKey(nennungId to NennungenTable.id) + } + + // Define the 'id' column as the primary key for this table + override val primaryKey = PrimaryKey(id) +} diff --git a/server/src/main/kotlin/at/mocode/views/AdminView.kt b/server/src/main/kotlin/at/mocode/views/AdminView.kt new file mode 100644 index 00000000..6dfccb67 --- /dev/null +++ b/server/src/main/kotlin/at/mocode/views/AdminView.kt @@ -0,0 +1,561 @@ +package at.mocode.views + +import at.mocode.model.Turnier +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.html.* +import kotlinx.html.* + +/** + * View class for generating HTML for the admin pages. + */ +class AdminView { + private val layoutTemplate = LayoutTemplate() + + /** + * Generates the HTML response for the tournament management page. + * @param call The ApplicationCall to respond to + * @param turniere List of tournaments to display + */ + suspend fun renderTournamentManagementPage(call: ApplicationCall, turniere: List) { + call.respondHtml(HttpStatusCode.OK) { + layoutTemplate.apply { + applyLayout("Turnierverwaltung") { + h1 { +"Turnierverwaltung" } + + // Form to add a new tournament + div(classes = "admin-section mb-4") { + h2 { + i("fas fa-plus-circle") {} + +" Neues Turnier hinzufügen" + } + form(action = "/admin/tournaments/add", method = FormMethod.post, classes = "admin-form") { + div(classes = "form-row") { + div(classes = "form-group form-group-third") { + label { + htmlFor = "turnier-number" + +"Turniernummer:" + } + input(type = InputType.number) { + id = "turnier-number" + name = "number" + required = true + placeholder = "z.B. 12345" + } + } + div(classes = "form-group form-group-third") { + label { + htmlFor = "turnier-name" + +"Turniername:" + } + input(type = InputType.text) { + id = "turnier-name" + name = "name" + required = true + placeholder = "Name des Turniers" + } + } + div(classes = "form-group form-group-third") { + label { + htmlFor = "turnier-datum" + +"Datum:" + } + input(type = InputType.text) { + id = "turnier-datum" + name = "datum" + required = true + placeholder = "z.B. 01.01.2023" + } + } + } + + div(classes = "bewerbe-section mt-3") { + h3 { + i("fas fa-trophy") {} + +" Bewerbe" + } + div { + id = "bewerbe-container" + } + button(type = ButtonType.button, classes = "button button-secondary mt-2") { + onClick = "addBewerbField()" + i("fas fa-plus") {} + +" Bewerb hinzufügen" + } + } + + div(classes = "form-actions mt-4") { + button(type = ButtonType.submit, classes = "button") { + i("fas fa-save") {} + +" Turnier speichern" + } + } + } + } + + // Table of existing tournaments + div(classes = "admin-section") { + h2 { + i("fas fa-list") {} + +" Vorhandene Turniere" + } + + if (turniere.isEmpty()) { + div(classes = "empty-state") { + i("fas fa-info-circle") {} + p { +"Keine Turniere vorhanden" } + } + } else { + div(classes = "table-responsive") { + table { + thead { + tr { + th { +"Nummer" } + th { +"Name" } + th { +"Datum" } + th { +"Bewerbe" } + th { +"Aktionen" } + } + } + tbody { + turniere.forEach { turnier -> + tr { + td { +turnier.number.toString() } + td { +turnier.name } + td { +turnier.datum } + td { + if (turnier.bewerbe.isEmpty()) { + +"Keine Bewerbe" + } else { + ul(classes = "competition-list") { + turnier.bewerbe.forEach { bewerb -> + li { + +"${bewerb.nummer}. ${bewerb.titel} - ${bewerb.klasse}" + if (bewerb.task != null) { + +" (${bewerb.task})" + } + } + } + } + } + } + td { + button(type = ButtonType.button, classes = "button button-secondary") { + onClick = "loadTurnierForEdit(${turnier.number})" + i("fas fa-edit") {} + +" Bearbeiten" + } + } + } + } + } + } + } + } + } + + // Additional styles specific to the admin page + style { + +""" + .admin-section { + background-color: var(--container-bg); + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 2rem; + box-shadow: 0 2px 10px rgba(0,0,0,0.05); + } + + .admin-section h2 { + border-bottom: 1px solid var(--border-color); + padding-bottom: 0.75rem; + margin-bottom: 1.5rem; + color: var(--primary-color); + } + + .admin-form { + max-width: 100%; + } + + .form-row { + display: flex; + flex-wrap: wrap; + margin: 0 -10px; + } + + .form-group-third { + flex: 0 0 calc(33.333% - 20px); + margin: 0 10px; + } + + .bewerbe-section { + border-top: 1px solid var(--border-color); + padding-top: 1.5rem; + } + + .bewerb-container { + background-color: var(--light-bg); + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 1rem; + border: 1px solid var(--border-color); + } + + .bewerb-container h4 { + margin-top: 0; + color: var(--secondary-color); + border-bottom: 1px solid var(--border-color); + padding-bottom: 0.5rem; + margin-bottom: 1rem; + } + + .table-responsive { + overflow-x: auto; + } + + .competition-list { + margin: 0; + padding-left: 1.2rem; + } + + .competition-list li { + margin-bottom: 0.25rem; + } + + .empty-state { + text-align: center; + padding: 2rem; + color: var(--light-text); + } + + .empty-state i { + font-size: 3rem; + margin-bottom: 1rem; + display: block; + } + + @media (max-width: 768px) { + .form-group-third { + flex: 0 0 calc(100% - 20px); + } + } + """ + } + + // JavaScript for dynamic form handling + script(type = "text/javascript") { + unsafe { + +""" + let bewerbCounter = 0; + + function addBewerbField() { + bewerbCounter++; + const container = document.getElementById('bewerbe-container'); + const bewerbDiv = document.createElement('div'); + bewerbDiv.className = 'bewerb-container'; + bewerbDiv.id = 'bewerb-' + bewerbCounter; + + bewerbDiv.innerHTML = '

Bewerb ' + bewerbCounter + '

' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + ''; + + container.appendChild(bewerbDiv); + } + + function removeBewerbField(id) { + const bewerbDiv = document.getElementById('bewerb-' + id); + bewerbDiv.remove(); + } + + function loadTurnierForEdit(number) { + window.location.href = '/admin/tournaments/edit/' + number; + } + """ + } + } + } + } + } + } + + /** + * Generates the HTML response for the tournament edit page. + * @param call The ApplicationCall to respond to + * @param turnier The tournament to edit + */ + suspend fun renderTournamentEditPage(call: ApplicationCall, turnier: Turnier) { + call.respondHtml(HttpStatusCode.OK) { + layoutTemplate.apply { + applyLayout("Turnier bearbeiten") { + h1 { +"Turnier bearbeiten" } + + div(classes = "admin-section") { + form(action = "/admin/tournaments/update/${turnier.number}", method = FormMethod.post, classes = "admin-form") { + div(classes = "form-row") { + div(classes = "form-group form-group-third") { + label { + htmlFor = "turnier-number" + +"Turniernummer:" + } + input(type = InputType.number) { + id = "turnier-number" + name = "number" + value = turnier.number.toString() + readonly = true + classes = setOf("readonly-field") + } + } + div(classes = "form-group form-group-third") { + label { + htmlFor = "turnier-name" + +"Turniername:" + } + input(type = InputType.text) { + id = "turnier-name" + name = "name" + value = turnier.name + required = true + } + } + div(classes = "form-group form-group-third") { + label { + htmlFor = "turnier-datum" + +"Datum:" + } + input(type = InputType.text) { + id = "turnier-datum" + name = "datum" + value = turnier.datum + required = true + } + } + } + + div(classes = "bewerbe-section mt-3") { + h3 { + i("fas fa-trophy") {} + +" Bewerbe" + } + div { + id = "bewerbe-container" + + // Render existing competitions + turnier.bewerbe.forEachIndexed { index, bewerb -> + val bewerbIndex = index + 1 + div(classes = "bewerb-container") { + id = "bewerb-$bewerbIndex" + h4 { + i("fas fa-trophy") {} + +" Bewerb $bewerbIndex" + } + + div(classes = "form-row") { + div(classes = "form-group form-group-third") { + label { + htmlFor = "bewerb-nummer-$bewerbIndex" + +"Nummer:" + } + input(type = InputType.number) { + id = "bewerb-nummer-$bewerbIndex" + name = "bewerb-nummer[]" + value = bewerb.nummer.toString() + required = true + } + } + div(classes = "form-group form-group-third") { + label { + htmlFor = "bewerb-titel-$bewerbIndex" + +"Titel:" + } + input(type = InputType.text) { + id = "bewerb-titel-$bewerbIndex" + name = "bewerb-titel[]" + value = bewerb.titel + required = true + } + } + div(classes = "form-group form-group-third") { + label { + htmlFor = "bewerb-klasse-$bewerbIndex" + +"Klasse:" + } + input(type = InputType.text) { + id = "bewerb-klasse-$bewerbIndex" + name = "bewerb-klasse[]" + value = bewerb.klasse + required = true + } + } + } + div(classes = "form-group") { + label { + htmlFor = "bewerb-task-$bewerbIndex" + +"Task (optional):" + } + input(type = InputType.text) { + id = "bewerb-task-$bewerbIndex" + name = "bewerb-task[]" + value = bewerb.task ?: "" + } + } + button(type = ButtonType.button, classes = "button button-secondary") { + onClick = "removeBewerbField($bewerbIndex)" + i("fas fa-trash") {} + +" Bewerb entfernen" + } + } + } + } + button(type = ButtonType.button, classes = "button button-secondary mt-2") { + onClick = "addBewerbField()" + i("fas fa-plus") {} + +" Bewerb hinzufügen" + } + } + + div(classes = "form-actions mt-4") { + button(type = ButtonType.submit, classes = "button") { + i("fas fa-save") {} + +" Turnier aktualisieren" + } + a(href = "/admin/tournaments", classes = "button button-secondary ml-2") { + i("fas fa-arrow-left") {} + +" Zurück zur Turnierverwaltung" + } + } + } + } + + // Additional styles specific to the edit page + style { + +""" + .admin-section { + background-color: var(--container-bg); + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 2rem; + box-shadow: 0 2px 10px rgba(0,0,0,0.05); + } + + .admin-form { + max-width: 100%; + } + + .form-row { + display: flex; + flex-wrap: wrap; + margin: 0 -10px; + } + + .form-group-third { + flex: 0 0 calc(33.333% - 20px); + margin: 0 10px; + } + + .bewerbe-section { + border-top: 1px solid var(--border-color); + padding-top: 1.5rem; + } + + .bewerb-container { + background-color: var(--light-bg); + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 1rem; + border: 1px solid var(--border-color); + } + + .bewerb-container h4 { + margin-top: 0; + color: var(--secondary-color); + border-bottom: 1px solid var(--border-color); + padding-bottom: 0.5rem; + margin-bottom: 1rem; + } + + .readonly-field { + background-color: var(--light-bg); + cursor: not-allowed; + } + + .ml-2 { + margin-left: 0.5rem; + } + + @media (max-width: 768px) { + .form-group-third { + flex: 0 0 calc(100% - 20px); + } + + .ml-2 { + margin-left: 0; + margin-top: 0.5rem; + } + } + """ + } + + // JavaScript for dynamic form handling + script(type = "text/javascript") { + unsafe { + raw("let bewerbCounter = ${turnier.bewerbe.size};") + +""" + function addBewerbField() { + bewerbCounter++; + const container = document.getElementById('bewerbe-container'); + const bewerbDiv = document.createElement('div'); + bewerbDiv.className = 'bewerb-container'; + bewerbDiv.id = 'bewerb-' + bewerbCounter; + + bewerbDiv.innerHTML = '

Neuer Bewerb

' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + ''; + + container.appendChild(bewerbDiv); + } + + function removeBewerbField(id) { + const bewerbDiv = document.getElementById('bewerb-' + id); + bewerbDiv.remove(); + } + """ + } + } + } + } + } + } +} diff --git a/server/src/main/kotlin/at/mocode/views/HomeView.kt b/server/src/main/kotlin/at/mocode/views/HomeView.kt new file mode 100644 index 00000000..03d2d91c --- /dev/null +++ b/server/src/main/kotlin/at/mocode/views/HomeView.kt @@ -0,0 +1,135 @@ +package at.mocode.views + +import at.mocode.model.Turnier +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.html.* +import kotlinx.html.* + +/** + * View class for generating HTML for the home page. + */ +class HomeView { + private val layoutTemplate = LayoutTemplate() + + /** + * Generates the HTML response for the home page. + * @param call The ApplicationCall to respond to + * @param turniere List of tournaments to display + */ + suspend fun renderHomePage(call: ApplicationCall, turniere: List) { + call.respondHtml(HttpStatusCode.OK) { + layoutTemplate.apply { + applyLayout("Meldestelle Portal - Startseite") { + h1 { +"Willkommen beim Meldestelle Portal!" } + p(classes = "text-center mb-3") { +"Hier finden Sie alle aktuellen Turniere und können sich online anmelden." } + + div(classes = "mt-4") { + h2 { +"Aktuelle Turniere" } + + if (turniere.isEmpty()) { + div(classes = "text-center mt-3 mb-3") { + p { +"Keine Turniere in der Datenbank gefunden." } + } + } else { + div(classes = "tournament-list") { + turniere.forEach { turnier -> + div(classes = "tournament-item mb-3") { + div(classes = "tournament-header") { + h3 { +turnier.name } + p { + i("far fa-calendar-alt") {} + +" ${turnier.datum}" + } + p { + i("fas fa-hashtag") {} + +" Turnier-Nr.: ${turnier.number}" + } + } + + div(classes = "tournament-competitions mt-2") { + h4 { +"Bewerbe:" } + if (turnier.bewerbe.isEmpty()) { + p { +"Keine Bewerbe verfügbar" } + } else { + ul { + turnier.bewerbe.forEach { bewerb -> + li { + +"${bewerb.nummer}. ${bewerb.titel} - ${bewerb.klasse}" + if (bewerb.task != null) { + +" (${bewerb.task})" + } + } + } + } + } + } + + div(classes = "tournament-actions mt-2") { + a(href = "/nennung/${turnier.number}", classes = "button") { + i("fas fa-edit") {} + +" Online Nennen" + } + } + } + } + } + } + } + + style { + +""" + .tournament-list { + display: flex; + flex-direction: column; + gap: 16px; + max-width: 800px; + margin: 0 auto; + } + + .tournament-item { + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 24px; + transition: box-shadow 0.3s; + background-color: var(--container-bg); + box-shadow: 0 2px 8px rgba(0,0,0,0.05); + } + + .tournament-item:hover { + box-shadow: 0 5px 15px rgba(0,0,0,0.08); + } + + .tournament-header h3 { + margin-top: 0; + color: var(--primary-color); + font-size: 1.4rem; + } + + .tournament-header p { + color: var(--light-text); + margin-bottom: 0.5rem; + font-size: 0.95rem; + } + + .tournament-competitions h4 { + font-size: 1.1rem; + margin-bottom: 0.5rem; + color: var(--secondary-color); + } + + .tournament-competitions ul { + padding-left: 1.2rem; + } + + .tournament-actions { + margin-top: 1rem; + text-align: right; + } + """ + } + } + } + } + } +} diff --git a/server/src/main/kotlin/at/mocode/views/LayoutTemplate.kt b/server/src/main/kotlin/at/mocode/views/LayoutTemplate.kt new file mode 100644 index 00000000..0451350a --- /dev/null +++ b/server/src/main/kotlin/at/mocode/views/LayoutTemplate.kt @@ -0,0 +1,477 @@ +package at.mocode.views + +import kotlinx.html.* + +/** + * Common layout template for all pages in the application. + * Provides consistent styling, header, footer, and responsive design. + */ +class LayoutTemplate { + /** + * Applies the common layout template to the provided content. + * @param title The page title + * @param showNavbar Whether to show the navigation bar + * @param showAdminLink Whether to show the admin link in the navbar + * @param content The content builder function + */ + fun HTML.applyLayout( + title: String, + showNavbar: Boolean = true, + showAdminLink: Boolean = true, + content: FlowContent.() -> Unit + ) { + head { + meta(charset = "UTF-8") + meta(name = "viewport", content = "width=device-width, initial-scale=1.0") + title { +title } + link(rel = "stylesheet", href = "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css") + link(rel = "stylesheet", href = "https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap") + link(rel = "stylesheet", href = "https://fonts.googleapis.com/icon?family=Material+Icons") + link(rel = "stylesheet", href = "https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css") + style { + +""" + /* Base styles */ + :root { + --primary-color: #5d8aa8; + --primary-hover: #4a7a98; + --secondary-color: #7d9eb1; + --secondary-hover: #6a8ca1; + --text-color: #333; + --light-text: #666; + --lighter-text: #999; + --border-color: #e0e0e0; + --light-bg: #f5f7fa; + --container-bg: #fff; + --success-color: #66bb6a; + --warning-color: #ffa726; + --error-color: #ef5350; + } + + * { + box-sizing: border-box; + margin: 0; + padding: 0; + } + + body { + font-family: 'Roboto', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + line-height: 1.6; + color: var(--text-color); + background-color: var(--light-bg); + padding: 0; + margin: 0; + } + + .container { + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 0 20px; + } + + /* Navigation */ + nav.nav-extended { + background-color: var(--primary-color); + margin-bottom: 20px; + } + + nav .brand-logo { + font-size: 1.6rem; + font-weight: 500; + padding-left: 10px; + } + + nav .brand-logo i { + margin-right: 8px; + } + + nav ul li a { + font-weight: 500; + transition: background-color 0.3s; + } + + nav ul li a:hover { + background-color: rgba(255,255,255,0.1); + } + + .sidenav { + width: 280px; + } + + .sidenav .user-view { + padding: 20px 16px 12px; + } + + .sidenav .user-view .name { + font-size: 1.4rem; + font-weight: 500; + margin-top: 8px; + color: var(--primary-color); + } + + .sidenav li > a { + display: flex; + align-items: center; + font-weight: 500; + } + + .sidenav li > a > i { + margin-right: 16px; + color: var(--primary-color); + } + + /* Main content */ + main { + padding: 2rem 0; + } + + .content-card { + background-color: var(--container-bg); + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.05); + padding: 2rem; + margin-bottom: 2rem; + } + + /* Typography */ + h1, h2, h3, h4, h5, h6 { + margin-bottom: 1rem; + line-height: 1.2; + color: var(--text-color); + } + + h1 { + font-size: 2.2rem; + text-align: center; + margin-bottom: 1.5rem; + } + + h2 { + font-size: 1.8rem; + margin-top: 1.5rem; + } + + h3 { + font-size: 1.5rem; + margin-top: 1.2rem; + } + + p { + margin-bottom: 1rem; + } + + /* Forms */ + .form-group { + margin-bottom: 1.5rem; + } + + label { + display: block; + margin-bottom: 0.5rem; + font-weight: 600; + } + + input[type="text"], + input[type="email"], + input[type="tel"], + input[type="number"], + textarea, + select { + width: 100%; + padding: 1.2rem; + border: none; + border-radius: 6px; + font-size: 2rem; + transition: all 0.3s; + margin-bottom: 1.2rem; + box-shadow: 0 1px 3px rgba(0,0,0,0.05); + background-color: white; + } + + input[type="text"]:focus, + input[type="email"]:focus, + input[type="tel"]:focus, + input[type="number"]:focus, + textarea:focus, + select:focus { + border-color: var(--primary-color); + outline: none; + box-shadow: 0 2px 8px rgba(93,138,168,0.2); + } + + .required:after { + content: " *"; + color: var(--error-color); + } + + /* Buttons */ + .button, button { + display: inline-block; + background-color: var(--primary-color); + color: white; + padding: 0.9rem 1.8rem; + border: none; + border-radius: 4px; + font-size: 1.1rem; + cursor: pointer; + text-decoration: none; + transition: all 0.3s; + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 500; + box-shadow: 0 2px 5px rgba(0,0,0,0.2); + width: 100%; + text-align: center; + } + + .button:hover, button:hover { + background-color: var(--primary-hover); + box-shadow: 0 4px 8px rgba(0,0,0,0.3); + transform: translateY(-2px); + } + + .button-secondary { + background-color: var(--secondary-color); + } + + .button-secondary:hover { + background-color: var(--secondary-hover); + } + + /* Tables */ + table { + width: 100%; + border-collapse: collapse; + margin-bottom: 1.5rem; + } + + th, td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid var(--border-color); + } + + th { + background-color: var(--light-bg); + font-weight: 600; + } + + tr:hover { + background-color: rgba(0,0,0,0.02); + } + + /* Lists */ + ul, ol { + margin-bottom: 1rem; + padding-left: 1.5rem; + } + + li { + margin-bottom: 0.5rem; + } + + /* Utilities */ + .text-center { + text-align: center; + } + + .mt-1 { margin-top: 0.5rem; } + .mt-2 { margin-top: 1rem; } + .mt-3 { margin-top: 1.5rem; } + .mt-4 { margin-top: 2rem; } + + .mb-1 { margin-bottom: 0.5rem; } + .mb-2 { margin-bottom: 1rem; } + .mb-3 { margin-bottom: 1.5rem; } + .mb-4 { margin-bottom: 2rem; } + + /* Footer */ + footer { + background-color: var(--text-color); + color: white; + padding: 2rem 0; + margin-top: 2rem; + } + + footer a { + color: white; + text-decoration: none; + } + + footer a:hover { + text-decoration: underline; + } + + /* Responsive design */ + @media (max-width: 768px) { + .menu-toggle { + display: block; + } + + nav ul { + display: none; + position: absolute; + top: 60px; + left: 0; + right: 0; + flex-direction: column; + background-color: var(--primary-color); + padding: 1rem; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); + } + + nav ul.show { + display: flex; + } + + nav ul li { + margin: 0.5rem 0; + } + + .content-card { + padding: 1.5rem; + } + + h1 { + font-size: 1.8rem; + } + + h2 { + font-size: 1.5rem; + } + + h3 { + font-size: 1.3rem; + } + } + + @media (max-width: 480px) { + .container { + padding: 0 15px; + } + + .content-card { + padding: 1rem; + } + + h1 { + font-size: 1.6rem; + } + + .button, button { + width: 100%; + text-align: center; + margin-bottom: 0.5rem; + } + } + """ + } + script(src = "https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js") {} + script { + unsafe { + +""" + document.addEventListener('DOMContentLoaded', function() { + // Mobile menu toggle + const menuToggle = document.querySelector('.menu-toggle'); + const navMenu = document.querySelector('nav ul'); + + if (menuToggle && navMenu) { + menuToggle.addEventListener('click', function() { + navMenu.classList.toggle('show'); + }); + } + + // Initialize Materialize components + M.AutoInit(); + + // Enhance form elements + const inputs = document.querySelectorAll('input, textarea, select'); + inputs.forEach(input => { + input.classList.add('browser-default'); + }); + }); + """ + } + } + } + body { + if (showNavbar) { + nav(classes = "nav-extended z-depth-1") { + div("nav-wrapper") { + div("container") { + a(href = "/", classes = "brand-logo") { + i("material-icons left") { +"sports_handball" } + +"Meldestelle Portal" + } + a(href = "#", classes = "sidenav-trigger") { + attributes["data-target"] = "mobile-nav" + i("material-icons") { +"menu" } + } + ul(classes = "right hide-on-med-and-down") { + li { + a(href = "/") { + i("material-icons left") { +"home" } + +"Home" + } + } + if (showAdminLink) { + li { + a(href = "/admin/tournaments") { + i("material-icons left") { +"event" } + +"Turnierverwaltung" + } + } + } + } + } + } + } + + // Mobile sidenav + ul(classes = "sidenav") { + attributes["id"] = "mobile-nav" + li { + div("user-view") { + div("background blue-grey lighten-4") { + style = "height: 80px;" + } + span("name") { +"Meldestelle Portal" } + } + } + li { + a(href = "/") { + i("material-icons") { +"home" } + +"Home" + } + } + if (showAdminLink) { + li { + a(href = "/admin/tournaments") { + i("material-icons") { +"event" } + +"Turnierverwaltung" + } + } + } + } + } + main { + div("container") { + div("content-card") { + content() + } + } + } + footer { + div("container") { + div("text-center") { + p { +"© ${java.time.Year.now().value} Meldestelle Portal. Alle Rechte vorbehalten." } + p { + +"Entwickelt von " + a(href = "#") { +"mocode" } + } + } + } + } + } + } +} diff --git a/server/src/main/kotlin/at/mocode/views/NennungView.kt b/server/src/main/kotlin/at/mocode/views/NennungView.kt new file mode 100644 index 00000000..76bb5b51 --- /dev/null +++ b/server/src/main/kotlin/at/mocode/views/NennungView.kt @@ -0,0 +1,392 @@ +package at.mocode.views + +import at.mocode.model.Turnier +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.html.* +import kotlinx.html.* + +/** + * View class for generating HTML for the tournament registration forms. + */ +class NennungView { + private val layoutTemplate = LayoutTemplate() + + /** + * Generates the HTML response for the tournament registration form. + * @param call The ApplicationCall to respond to + * @param turnier The tournament to display the registration form for + */ + suspend fun renderNennungForm(call: ApplicationCall, turnier: Turnier) { + call.respondHtml(HttpStatusCode.OK) { + layoutTemplate.apply { + applyLayout( + title = "Online-Nennen - ${turnier.name}", + showNavbar = false, + showAdminLink = false + ) { + h1 { +"Online-Nennen" } + + // Tournament description + div(classes = "tournament-info text-center mb-4") { + h2 { +turnier.name } + p { +turnier.datum } + p { +"Turnier-Nr.: ${turnier.number}" } + } + + form(action = "/nennung/${turnier.number}/submit", method = FormMethod.post, classes = "registration-form") { + div(classes = "form-section mb-4") { + h3 { +"Teilnehmer-Informationen" } + + // Participant information list + div(classes = "competitions-list") { + // Rider information + div(classes = "competition-item") { + div(classes = "participant-details") { + label(classes = "required") { +"Reiter-Name" } + input(type = InputType.text, name = "riderName") { + attributes["required"] = "required" + attributes["placeholder"] = "Vor- und Nachname" + } + } + } + + // Horse information + div(classes = "competition-item") { + div(classes = "participant-details") { + label(classes = "required") { +"Kopf-Nr./Pferd" } + input(type = InputType.text, name = "horseName") { + attributes["required"] = "required" + attributes["placeholder"] = "Name des Pferdes" + } + } + } + + // Contact information + div(classes = "competition-item") { + div(classes = "participant-details") { + div(classes = "form-row") { + div(classes = "form-group form-group-half") { + label { +"E-Mail" } + input(type = InputType.email, name = "email") { + attributes["placeholder"] = "ihre@email.com" + } + } + } + } + } + + // Contact information + div(classes = "competition-item") { + div(classes = "participant-details") { + div(classes = "form-row") { + div(classes = "form-group form-group-half") { + label { +"Telefon-Nr." } + input(type = InputType.tel, name = "phone") { + attributes["placeholder"] = "Ihre Telefonnummer" + } + } + } + } + } + + } + p(classes = "form-hint") { +"Bitte geben Sie mindestens eine Kontaktmöglichkeit an (E-Mail oder Telefon)." } + } + + // Competitions list + div(classes = "form-section mb-4") { + h3 { +"Bewerbe" } + + if (turnier.bewerbe.isEmpty()) { + p { +"Keine Bewerbe verfügbar." } + } else { + div(classes = "competitions-list") { + turnier.bewerbe.forEach { bewerb -> + div(classes = "competition-item") { + label { + input(type = InputType.checkBox, name = "selectedEvents") { + attributes["value"] = bewerb.nummer.toString() + } + span(classes = "competition-details") { + strong { +"${bewerb.nummer}. ${bewerb.titel}" } + +" - ${bewerb.klasse}" + if (bewerb.task != null) { + +" - ${bewerb.task}" + } + } + } + } + } + } + } + } + + // Comments + div(classes = "form-section mb-4") { + h3 { +"Zusätzliche Informationen" } + + div(classes = "form-group") { + label { +"Wünsche/Bemerkungen" } + textArea { + attributes["rows"] = "4" + attributes["name"] = "comments" + attributes["placeholder"] = "Ihre Wünsche oder Bemerkungen zur Nennung..." + } + } + } + + // Submit button + div(classes = "form-actions text-center mt-4") { + button(type = ButtonType.submit, classes = "button") { + +"Jetzt Nennen" + } + } + } + + // Additional styles specific to the registration form + style { + +""" + .tournament-info { + margin-bottom: 2rem; + } + + .tournament-info h2 { + color: var(--primary-color); + } + + .form-section { + background-color: white; + border-radius: 8px; + padding: 2rem; + margin-bottom: 2rem; + box-shadow: 0 2px 10px rgba(0,0,0,0.08); + border-left: 4px solid var(--primary-color); + } + + .form-section h3 { + border-bottom: 1px solid var(--border-color); + padding-bottom: 0.8rem; + margin-bottom: 1.5rem; + text-align: left; + color: var(--primary-color); + font-size: 1.4rem; + } + + .form-row { + display: flex; + flex-direction: column; + width: 100%; + margin: 0; + } + + @media (min-width: 768px) { + .form-row { + flex-direction: row; + gap: 20px; + } + + .form-group-half { + flex: 1; + } + } + + .form-group { + width: 100%; + margin: 0 0 1.5rem 0; + } + + .form-group-half { + flex: 0 0 100%; + margin: 0 0 1.5rem 0; + max-width: 100%; + } + + .form-hint { + font-size: 0.9rem; + color: var(--light-text); + margin-top: 0.5rem; + text-align: center; + } + + .competitions-list { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 15px; + width: 100%; + margin: 0; + } + + .competition-item { + padding: 18px; + border-radius: 8px; + transition: all 0.3s; + width: 100%; + text-align: left; + border: 1px solid var(--border-color); + background-color: white; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + } + + .competition-item:hover { + background-color: rgba(0,0,0,0.02); + box-shadow: 0 3px 8px rgba(0,0,0,0.12); + } + + .competition-item label { + display: flex; + align-items: center; + justify-content: flex-start; + cursor: pointer; + } + + .competition-item input[type="checkbox"] { + margin-right: 15px; + transform: scale(1.2); + } + + .competition-details { + text-align: left; + } + + /* Participant information styling */ + .participant-details { + width: 100%; + } + + .participant-details label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + } + + .participant-details input { + width: 100%; + } + + @media (max-width: 768px) { + /* Mobile styles already handled by the responsive layout */ + } + """ + } + } + } + } + } + + /** + * Generates the HTML response for the confirmation page after successful registration. + * @param call The ApplicationCall to respond to + * @param turnier The tournament the registration was for + * @param riderName The name of the rider + * @param horseName The name of the horse + */ + suspend fun renderConfirmationPage(call: ApplicationCall, turnier: Turnier, riderName: String, horseName: String) { + call.respondHtml(HttpStatusCode.OK) { + layoutTemplate.apply { + applyLayout( + title = "Nennung bestätigt - ${turnier.name}", + showNavbar = false, + showAdminLink = false + ) { + h1 { +"Nennung bestätigt" } + + div(classes = "confirmation-box") { + div(classes = "confirmation-icon") { + /* Icon removed as per request */ + } + + h2 { +"Vielen Dank für Ihre Nennung!" } + + div(classes = "confirmation-details") { + p { + +"Ihre Nennung für " + strong { +turnier.name } + +" wurde erfolgreich übermittelt." + } + + div(classes = "detail-item") { + span(classes = "detail-label") { +"Reiter:" } + span(classes = "detail-value") { +riderName } + } + + div(classes = "detail-item") { + span(classes = "detail-label") { +"Pferd:" } + span(classes = "detail-value") { +horseName } + } + } + + p(classes = "confirmation-message") { + +"Sie erhalten in Kürze eine Bestätigung per E-Mail." + } + + div(classes = "confirmation-actions") { + a(href = "/nennung/${turnier.number}", classes = "button") { + +"Weitere Nennung abgeben" + } + } + } + + // Additional styles specific to the confirmation page + style { + +""" + .confirmation-box { + background-color: var(--light-bg); + border-radius: 8px; + padding: 2rem; + text-align: center; + max-width: 600px; + margin: 0 auto; + box-shadow: 0 4px 15px rgba(0,0,0,0.05); + } + + .confirmation-icon { + font-size: 4rem; + color: var(--success-color); + margin-bottom: 1rem; + } + + .confirmation-details { + margin: 1.5rem 0; + padding: 1rem; + background-color: var(--container-bg); + border-radius: 8px; + text-align: left; + } + + .detail-item { + display: flex; + margin-bottom: 0.5rem; + padding: 0.5rem 0; + border-bottom: 1px solid var(--border-color); + } + + .detail-item:last-child { + border-bottom: none; + } + + .detail-label { + font-weight: bold; + width: 100px; + } + + .detail-value { + flex: 1; + } + + .confirmation-message { + margin: 1.5rem 0; + color: var(--light-text); + } + + .confirmation-actions { + margin-top: 1.5rem; + } + """ + } + } + } + } + } +} diff --git a/server/src/test/kotlin/at/mocode/ApplicationTest.kt b/server/src/test/kotlin/at/mocode/ApplicationTest.kt index efebaac2..cd622627 100644 --- a/server/src/test/kotlin/at/mocode/ApplicationTest.kt +++ b/server/src/test/kotlin/at/mocode/ApplicationTest.kt @@ -14,11 +14,9 @@ class ApplicationTest { fun testRootRouteShowsTournamentList() { // Erstelle ein Beispiel-Turnier, das in der Datenbank sein würde val mockTurnier = Turnier( - id = "dummy-01", name = "Erstes DB Turnier", datum = "19.04.2025", - logoUrl = null, - ausschreibungUrl = "/pdfs/ausschreibung_dummy.pdf" + number = 1 ) // Erstelle eine Liste von Turnieren, wie sie aus der Datenbank kommen würde @@ -45,13 +43,7 @@ class ApplicationTest { strong { +turnier.name } +" (${turnier.datum})" +" " - if (turnier.ausschreibungUrl != null) { - a(href = turnier.ausschreibungUrl, target = "_blank") { - button { +"Ausschreibung" } - } - +" " - } - a(href = "/nennung/${turnier.id}") { + a(href = "/nennung/${turnier.number}") { button { +"Online Nennen" } } } @@ -82,7 +74,7 @@ class ApplicationTest { htmlContent.contains("(19.04.2025)"), "Dummy tournament date missing or incorrect" ) - assertTrue(htmlContent.contains("/nennung/dummy-01"), "Link to dummy tournament '/nennung/dummy-01' missing") + assertTrue(htmlContent.contains("/nennung/1"), "Link to dummy tournament '/nennung/1' missing") assertFalse( htmlContent.contains("Keine Turniere in der Datenbank gefunden."), "'No tournaments' message should not be present if dummy was inserted" diff --git a/server/src/test/kotlin/at/mocode/TestDatabase.kt b/server/src/test/kotlin/at/mocode/TestDatabase.kt index 6f238d59..21b0eb01 100644 --- a/server/src/test/kotlin/at/mocode/TestDatabase.kt +++ b/server/src/test/kotlin/at/mocode/TestDatabase.kt @@ -1,16 +1,23 @@ package at.mocode +import at.mocode.model.Bewerb import at.mocode.model.Turnier +import at.mocode.tables.BewerbeTable import at.mocode.tables.TurniereTable +import `import org`.jetbrains.exposed.sql.selectAll import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.html.* import io.ktor.server.routing.* import kotlinx.html.* import org.jetbrains.exposed.sql.Database +import org.jetbrains.exposed.sql.Op import org.jetbrains.exposed.sql.SchemaUtils +import org.jetbrains.exposed.sql.SqlExpressionBuilder import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.selectAll +import org.jetbrains.exposed.sql.select +import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction import org.slf4j.LoggerFactory @@ -19,24 +26,40 @@ import org.slf4j.LoggerFactory */ fun configureTestDatabase() { val log = LoggerFactory.getLogger("TestDatabaseInitialization") - log.info("Initializing in-memory H2 database for testing...") + log.info("Initializing in-memory SQLite database for testing...") - // Verbinde mit einer In-Memory-H2-Datenbank - Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", driver = "org.h2.Driver") + // Verbinde mit einer In-Memory-SQLite-Datenbank + Database.connect("jdbc:sqlite::memory:", driver = "org.sqlite.JDBC") // Initialisiere das Datenbankschema transaction { log.info("Creating test database schema...") - SchemaUtils.create(TurniereTable) + SchemaUtils.create(TurniereTable, BewerbeTable) // Füge ein Test-Turnier hinzu log.info("Inserting test tournament data...") + val turnierNumber = 1 TurniereTable.insert { - it[id] = "dummy-01" - it[name] = "Erstes DB Turnier" - it[datum] = "19.04.2025" - it[logoUrl] = null - it[ausschreibungUrl] = "/pdfs/ausschreibung_dummy.pdf" + it[TurniereTable.number] = turnierNumber + it[TurniereTable.name] = "CSN-C Edelhof April 2025" + it[TurniereTable.datum] = "14.04.2025 - 15.04.2025" + } + + // Füge Test-Bewerbe hinzu + BewerbeTable.insert { + it[BewerbeTable.nummer] = 1 + it[BewerbeTable.titel] = "Stilspringprüfung" + it[BewerbeTable.klasse] = "60 cm" + it[BewerbeTable.task] = null + it[BewerbeTable.turnierNumber] = turnierNumber + } + + BewerbeTable.insert { + it[BewerbeTable.nummer] = 2 + it[BewerbeTable.titel] = "Dressurprüfung" + it[BewerbeTable.klasse] = "Kl. A" + it[BewerbeTable.task] = "DRA 1" + it[BewerbeTable.turnierNumber] = turnierNumber } log.info("Test database initialized successfully!") @@ -57,15 +80,30 @@ fun Application.testModule() { // Lese Daten aus der Test-Datenbank val turniereFromDb = transaction { - TurniereTable.selectAll().map { row -> + // Get all tournaments + val turniere = TurniereTable.selectAll().map { row -> Turnier( - id = row[TurniereTable.id], name = row[TurniereTable.name], datum = row[TurniereTable.datum], - logoUrl = row[TurniereTable.logoUrl], - ausschreibungUrl = row[TurniereTable.ausschreibungUrl] + number = row[TurniereTable.number] ) } + + // For each tournament, get its competitions + turniere.forEach { turnier -> + val bewerbeList = BewerbeTable.selectAll().where { BewerbeTable.turnierNumber eq turnier.number } + .map { row -> + Bewerb( + nummer = row[BewerbeTable.nummer], + titel = row[BewerbeTable.titel], + klasse = row[BewerbeTable.klasse], + task = row[BewerbeTable.task] + ) + } + turnier.bewerbe = bewerbeList + } + + turniere } // HTML-Antwort generieren (wie in Application.kt) @@ -88,13 +126,25 @@ fun Application.testModule() { strong { +turnier.name } +" (${turnier.datum})" +" " - if (turnier.ausschreibungUrl != null) { - a(href = turnier.ausschreibungUrl, target = "_blank") { - button { +"Ausschreibung" } + div { + +"Bewerbe: " + if (turnier.bewerbe.isEmpty()) { + +"Keine" + } else { + ul { + turnier.bewerbe.forEach { bewerb -> + li { + +"${bewerb.nummer}. ${bewerb.titel} - ${bewerb.klasse}" + if (bewerb.task != null) { + +" (${bewerb.task})" + } + } + } + } } - +" " } - a(href = "/nennung/${turnier.id}") { + +" " + a(href = "/nennung/${turnier.number}") { button { +"Online Nennen" } } } diff --git a/server/src/test/kotlin/at/mocode/email/EmailServiceTest.kt b/server/src/test/kotlin/at/mocode/email/EmailServiceTest.kt new file mode 100644 index 00000000..107421c9 --- /dev/null +++ b/server/src/test/kotlin/at/mocode/email/EmailServiceTest.kt @@ -0,0 +1,54 @@ +package at.mocode.email + +import at.mocode.config.EmailConfig +import org.slf4j.LoggerFactory +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Tests for the EmailService class. + * This test verifies that the email configuration is valid and that test emails can be sent. + */ +class EmailServiceTest { + private val log = LoggerFactory.getLogger(EmailServiceTest::class.java) + + @Test + fun testEmailConfigurationIsValid() { + // Check if the email configuration is valid + val isValid = EmailConfig.isValid() + + if (!isValid) { + log.warn("Email configuration is not valid: ${EmailConfig.getValidationErrors()}") + log.warn("Make sure the .env file contains the correct email configuration") + log.warn("SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, RECIPIENT_EMAIL, and SMTP_SENDER_EMAIL must be set") + } + + // We don't assert here because the test environment might not have valid email configuration + log.info("Email configuration valid: $isValid") + } + + @Test + fun testSendTestEmail() { + // Only run this test if the email configuration is valid + if (!EmailConfig.isValid()) { + log.warn("Skipping test because email configuration is not valid") + return + } + + // Get the EmailService instance + val emailService = EmailService.getInstance() + + // Send a test email with debug enabled + val result = emailService.sendTestEmail(debug = true) + + // Log the result + if (result) { + log.info("Test email sent successfully") + } else { + log.error("Failed to send test email") + } + + // Assert that the email was sent successfully + assertTrue(result, "Test email should be sent successfully") + } +} diff --git a/shared/src/commonMain/kotlin/at/mocode/model/Bewerb.kt b/shared/src/commonMain/kotlin/at/mocode/model/Bewerb.kt new file mode 100644 index 00000000..ee25964b --- /dev/null +++ b/shared/src/commonMain/kotlin/at/mocode/model/Bewerb.kt @@ -0,0 +1,22 @@ +package at.mocode.model + +import kotlinx.serialization.Serializable + +/** + * Represents a competition (Bewerb) within a tournament. + * A competition has specific details like number, title, class, and optional task. + */ +@Serializable +data class Bewerb( + /** Competition number, e.g. 1, 2, etc. */ + val nummer: Int, + + /** Title of the competition, e.g. "Stilspringprüfung" or "Dressurprüfung" */ + val titel: String, + + /** Class/level of the competition, e.g. "60 cm" or "Kl. A" */ + val klasse: String = "", + + /** Optional task identifier, e.g. "DRA 1" */ + val task: String? = null +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/at/mocode/model/Nennung.kt b/shared/src/commonMain/kotlin/at/mocode/model/Nennung.kt index 600956cd..b030e366 100644 --- a/shared/src/commonMain/kotlin/at/mocode/model/Nennung.kt +++ b/shared/src/commonMain/kotlin/at/mocode/model/Nennung.kt @@ -4,13 +4,24 @@ import kotlinx.serialization.Serializable @Serializable data class Nennung( - // Wir brauchen die Turnier-ID, um die Nennung zuzuordnen - val turnierId: String, - // Einfache Felder für den Start - val riderName: String = "", // Standardwerte für leeres Formular - val horseName: String = "", - val email: String = "", - val comments: String? = null - // Hier kommen später Felder hinzu: Verein, Lizenznr., Tel, - // und vor allem: die Auswahl der Prüfungen! -) \ No newline at end of file + /** Name of the rider */ + val riderName: String, + + /** Name of the horse */ + val horseName: String, + + /** Email address for contact */ + val email: String, + + /** Phone number for contact */ + val phone: String, + + /** List of selected event numbers */ + val selectedEvents: List, + + /** Additional comments or wishes */ + val comments: String, + + /** The tournament this registration is for */ + val turnier: Turnier +) diff --git a/shared/src/commonMain/kotlin/at/mocode/model/Turnier.kt b/shared/src/commonMain/kotlin/at/mocode/model/Turnier.kt index 16c256aa..23013f10 100644 --- a/shared/src/commonMain/kotlin/at/mocode/model/Turnier.kt +++ b/shared/src/commonMain/kotlin/at/mocode/model/Turnier.kt @@ -1,13 +1,18 @@ package at.mocode.model import kotlinx.serialization.Serializable + @Serializable data class Turnier( - val id: String, // Eine eindeutige ID für das Turnier (z.B. eine UUID als String) - val name: String, // Der Name, z.B. "CDN-C Edelhof April 2025" - val datum: String, // Das Datum oder der Zeitraum, erstmal als Text, z.B. "14.04.2025 - 15.04.2025" - val logoUrl: String? = null, // Optional: Link zum Logo des Veranstalters - val ausschreibungUrl: String? = null // Optional: Link zum Ausschreibung-PDF - // Hier können später viele weitere Felder hinzukommen: - // Ort, Veranstalter, Status (geplant, läuft, beendet), Disziplinen etc. + /** The name of the tournament, e.g. "CSN-C NEU CSNP-C NEU NEUMARKT/M., OÖ" */ + val name: String, + + /** The date of the tournament as a formatted string, e.g. "7.JUNI 2025" */ + val datum: String, + + /** Unique identifier for the tournament */ + val number: Int, + + /** List of competitions (Bewerbe) associated with this tournament */ + var bewerbe: List = emptyList() ) From de2526fe56685ce2d1943ee46d4213cf1de0c3e0 Mon Sep 17 00:00:00 2001 From: stefan Date: Thu, 5 Jun 2025 13:16:15 +0200 Subject: [PATCH 02/13] erste Version Online-Nennen --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 96a854dc..e38f062e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +Jetzt funktioniert es + This is a Kotlin Multiplatform project targeting Web, Desktop, Server. * `/composeApp` is for code that will be shared across your Compose Multiplatform applications. @@ -20,4 +22,4 @@ Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-mu We would appreciate your feedback on Compose/Web and Kotlin/Wasm in the public Slack channel [#compose-web](https://slack-chats.kotlinlang.org/c/compose-web). If you face any issues, please report them on [GitHub](https://github.com/JetBrains/compose-multiplatform/issues). -You can open the web application by running the `:composeApp:wasmJsBrowserDevelopmentRun` Gradle task. \ No newline at end of file +You can open the web application by running the `:composeApp:wasmJsBrowserDevelopmentRun` Gradle task. From 9cba52235ebb0c7f46e43b61c56c0af2186fb1a9 Mon Sep 17 00:00:00 2001 From: stefan Date: Thu, 5 Jun 2025 13:35:44 +0200 Subject: [PATCH 03/13] funktioniert es jetzt --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index e38f062e..a2b8c971 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ Jetzt funktioniert es +Funktioniert es jetzt This is a Kotlin Multiplatform project targeting Web, Desktop, Server. From 56ae11a9e4e759892c5feb5086b89f0173cddb1f Mon Sep 17 00:00:00 2001 From: stefan Date: Thu, 5 Jun 2025 13:36:47 +0200 Subject: [PATCH 04/13] funktioniert es jetzt --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index a2b8c971..bb6dd497 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ Jetzt funktioniert es Funktioniert es jetzt +ich versuche noch einmal einen pull request This is a Kotlin Multiplatform project targeting Web, Desktop, Server. From d435b3dcca9193c6f2becb0d2c2415f9cb4b6ed8 Mon Sep 17 00:00:00 2001 From: stefan Date: Thu, 5 Jun 2025 14:05:53 +0200 Subject: [PATCH 05/13] fix Docker --- Dockerfile | 6 +++-- docker-compose.yml | 57 +++------------------------------------------- 2 files changed, 7 insertions(+), 56 deletions(-) diff --git a/Dockerfile b/Dockerfile index 63061096..35267b09 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,5 +11,7 @@ RUN gradle :server:shadowJar --no-configure-on-demand FROM openjdk:21-slim-bookworm AS runtime WORKDIR /app COPY --from=build /home/gradle/src/server/build/libs/*.jar ./app.jar -EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "/app/app.jar"] \ No newline at end of file +# Create data directory for SQLite database +RUN mkdir -p /app/data +EXPOSE 8081 +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/docker-compose.yml b/docker-compose.yml index 6e90d4fc..ab476f78 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,64 +7,13 @@ services: restart: unless-stopped ports: - "8080:8081" - environment: - - DB_USER=${POSTGRES_USER} - - DB_PASSWORD=${POSTGRES_PASSWORD} - - DB_NAME=${POSTGRES_DB} - - DB_HOST=db - - DB_PORT=5432 - depends_on: - db: - condition: service_healthy + volumes: + - sqlite_data:/app/data # Volume für SQLite Datenbank networks: - meldestelle-net - # PostgreSQL Datenbank (Service-Name 'db') - db: - image: postgres:16-alpine # Spezifische Version - container_name: meldestelle-db - restart: unless-stopped - environment: - # Liest Werte aus .env - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_DB} - # PGDATA nicht nötig, Standard verwenden - volumes: - # Benanntes Volume für Daten auf Standardpfad - - postgres_data:/var/lib/postgresql/data - networks: - - meldestelle-net # <--- Muss zum Netzwerk-Namen passen - healthcheck: # Wichtig für depends_on - test: [ "CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}" ] # Doppelte $$ beachten! - interval: 10s - timeout: 5s - retries: 5 - # ports: # Nur bei Bedarf freigeben, z.B. für lokalen Zugriff - # - "127.0.0.1:54321:5432" # Host-Port 54321 → Container-Port 5432 - - # Optional: PgAdmin Service -# pgadmin: -# image: dpage/pgadmin4:latest # Oder spezifische Version -# container_name: meldestelle-pgadmin -# restart: unless-stopped -# environment: -# # Werte aus .env lesen (oder Defaults nutzen) -# PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-admin@example.com} -# PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:-DeinSicheresPgAdminPasswort!} # UNBEDINGT IN .env SETZEN! -# PGADMIN_CONFIG_SERVER_MODE: 'False' -# volumes: -# - pgadmin_data:/var/lib/pgadmin # Benanntes Volume -# ports: -# # Port 5050 auf dem Host (nur localhost) → Port 80 im Container -# - "${PGADMIN_PORT:-127.0.0.1:5050}:80" -# networks: -# - meldestelle-net # <--- Muss zum Netzwerk-Namen passen -# depends_on: # PgAdmin braucht die DB -# - db networks: meldestelle-net: driver: bridge volumes: - postgres_data: # <--- Konsistenter Name -# pgadmin_data: # <--- Konsistenter Name + sqlite_data: # Volume für SQLite Datenbank From 935070e2118e2bc381d2564c33eb24c5abed48e9 Mon Sep 17 00:00:00 2001 From: stefan Date: Thu, 5 Jun 2025 14:15:36 +0200 Subject: [PATCH 06/13] fix meldestelle.db --- meldestelle.db | 0 server/src/test/kotlin/at/mocode/TestDatabase.kt | 5 ----- 2 files changed, 5 deletions(-) delete mode 100644 meldestelle.db diff --git a/meldestelle.db b/meldestelle.db deleted file mode 100644 index e69de29b..00000000 diff --git a/server/src/test/kotlin/at/mocode/TestDatabase.kt b/server/src/test/kotlin/at/mocode/TestDatabase.kt index 21b0eb01..37747e74 100644 --- a/server/src/test/kotlin/at/mocode/TestDatabase.kt +++ b/server/src/test/kotlin/at/mocode/TestDatabase.kt @@ -4,20 +4,15 @@ import at.mocode.model.Bewerb import at.mocode.model.Turnier import at.mocode.tables.BewerbeTable import at.mocode.tables.TurniereTable -import `import org`.jetbrains.exposed.sql.selectAll import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.html.* import io.ktor.server.routing.* import kotlinx.html.* import org.jetbrains.exposed.sql.Database -import org.jetbrains.exposed.sql.Op import org.jetbrains.exposed.sql.SchemaUtils -import org.jetbrains.exposed.sql.SqlExpressionBuilder import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.selectAll -import org.jetbrains.exposed.sql.select -import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction import org.slf4j.LoggerFactory From 0482ef8479a0a4b52f9049317f70b4e85c8fd9d9 Mon Sep 17 00:00:00 2001 From: stefan Date: Thu, 5 Jun 2025 14:38:32 +0200 Subject: [PATCH 07/13] fix SQLite --- docker-compose.yml | 2 ++ server/src/main/kotlin/at/mocode/plugins/Database.kt | 9 ++++++--- server/src/main/kotlin/at/mocode/routes/AdminRoutes.kt | 3 ++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index ab476f78..8231b823 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,8 @@ services: - "8080:8081" volumes: - sqlite_data:/app/data # Volume für SQLite Datenbank + environment: + - USE_SQLITE=true networks: - meldestelle-net diff --git a/server/src/main/kotlin/at/mocode/plugins/Database.kt b/server/src/main/kotlin/at/mocode/plugins/Database.kt index fb64169d..afe0792f 100644 --- a/server/src/main/kotlin/at/mocode/plugins/Database.kt +++ b/server/src/main/kotlin/at/mocode/plugins/Database.kt @@ -37,12 +37,15 @@ fun configureDatabase() { throw e // Rethrow error, test should fail } } else { - // Check if we're running in IDEA (no Docker environment variables set) - // We only check one variable, that's usually enough + // Check if we should use SQLite (either in IDEA or in Docker with SQLite) + // First check for explicit SQLite flag + val useSqlite = System.getenv("USE_SQLITE")?.toBoolean() ?: false + + // Then check if we're in IDEA (no Docker environment variables set) val dbHostFromEnv = System.getenv("DB_HOST") val isIdeaEnvironment = (dbHostFromEnv == null) - if (isIdeaEnvironment) { + if (useSqlite || isIdeaEnvironment) { // Ensure the data directory exists val dataDir = File("data") if (!dataDir.exists()) { diff --git a/server/src/main/kotlin/at/mocode/routes/AdminRoutes.kt b/server/src/main/kotlin/at/mocode/routes/AdminRoutes.kt index 2f2f7097..1e30e9e6 100644 --- a/server/src/main/kotlin/at/mocode/routes/AdminRoutes.kt +++ b/server/src/main/kotlin/at/mocode/routes/AdminRoutes.kt @@ -1,6 +1,7 @@ package at.mocode.routes import at.mocode.model.Bewerb +import at.mocode.config.DependencyInjection import at.mocode.repository.TurnierRepository import at.mocode.views.AdminView import io.ktor.http.* @@ -15,7 +16,7 @@ import org.slf4j.LoggerFactory */ fun Route.adminRoutes() { val log = LoggerFactory.getLogger("AdminRoutes") - val turnierRepository = TurnierRepository() + val turnierRepository = DependencyInjection.turnierRepository val adminView = AdminView() // Route to display the tournament management page From 3b752bd90effba5d2286e119b55f5df106bd441a Mon Sep 17 00:00:00 2001 From: stefan Date: Thu, 5 Jun 2025 15:43:31 +0200 Subject: [PATCH 08/13] fix SQLite --- data/meldestelle.db | Bin 32768 -> 32768 bytes docker-compose.yml | 4 +- .../at/mocode/repository/TurnierRepository.kt | 248 +++++++++++------- .../kotlin/at/mocode/routes/AdminRoutes.kt | 46 ++-- .../kotlin/at/mocode/routes/HomeRoutes.kt | 5 +- .../kotlin/at/mocode/views/LayoutTemplate.kt | 10 +- 6 files changed, 185 insertions(+), 128 deletions(-) diff --git a/data/meldestelle.db b/data/meldestelle.db index 13b965f3c9732337b9d23be82e654e62cfeb4f8c..922ef380639c8c504b58898d5f631aac6aff4ac2 100644 GIT binary patch delta 158 zcmZo@U}|V!njp={HBrWyk!xeZ5`G>Qeh&uz)BKb8qxd~G3kq293kk5WFp5i7holyl z7#Zpr>KU0%j*?#mRQQ5{{}2BQ{&)OeHw!A<;AdlHW@M~pnfy*)43DI}7_&5w`eb$o lQzjmDpan0vdAViT88`*`85o_yb*nd<_?#AIlwFjd0002!DRTe- delta 108 zcmZo@U}|V!njp={IZ?)$k#l3h5`Hdb{wN0i)BKb8qc#gF`0!7Tl3xWBd&j{4hyNY_ u*Uf?jH~1$8a7=!uFNRgvUW{3a$7C|QgXv~LfgjwPO?*xZGs-MVPyhfh`Xed; diff --git a/docker-compose.yml b/docker-compose.yml index 8231b823..5f85fcd5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: ports: - "8080:8081" volumes: - - sqlite_data:/app/data # Volume für SQLite Datenbank + - ./data:/app/data # Direktes Mapping des data Verzeichnisses environment: - USE_SQLITE=true networks: @@ -17,5 +17,3 @@ services: networks: meldestelle-net: driver: bridge -volumes: - sqlite_data: # Volume für SQLite Datenbank diff --git a/server/src/main/kotlin/at/mocode/repository/TurnierRepository.kt b/server/src/main/kotlin/at/mocode/repository/TurnierRepository.kt index f7ff4e46..4367dab1 100644 --- a/server/src/main/kotlin/at/mocode/repository/TurnierRepository.kt +++ b/server/src/main/kotlin/at/mocode/repository/TurnierRepository.kt @@ -121,97 +121,130 @@ class TurnierRepository { fun getAllTurniere(): List = transaction { log.info("Fetching all tournaments from database...") - // Get all tournaments - val turniere = TurniereTable.selectAll().map { row -> - Turnier( - name = row[TurniereTable.name], - datum = row[TurniereTable.datum], - number = row[TurniereTable.number] - ) - } - - // For each tournament, get its competitions - turniere.forEach { turnier -> - val bewerbeList = BewerbeTable.selectAll().where { BewerbeTable.turnierNumber eq turnier.number }.map { row -> - Bewerb( - nummer = row[BewerbeTable.nummer], - titel = row[BewerbeTable.titel], - klasse = row[BewerbeTable.klasse], - task = row[BewerbeTable.task] + try { + // Get all tournaments + val turniere = TurniereTable.selectAll().map { row -> + Turnier( + name = row[TurniereTable.name], + datum = row[TurniereTable.datum], + number = row[TurniereTable.number] ) } - turnier.bewerbe = bewerbeList - } - turniere + log.info("Found ${turniere.size} tournaments in database") + + // For each tournament, get its competitions + turniere.forEach { turnier -> + try { + val bewerbeList = BewerbeTable.selectAll().where { BewerbeTable.turnierNumber eq turnier.number }.map { row -> + Bewerb( + nummer = row[BewerbeTable.nummer], + titel = row[BewerbeTable.titel], + klasse = row[BewerbeTable.klasse], + task = row[BewerbeTable.task] + ) + } + turnier.bewerbe = bewerbeList + log.info("Found ${bewerbeList.size} competitions for tournament ${turnier.number}") + } catch (e: Exception) { + log.error("Error fetching competitions for tournament ${turnier.number}", e) + turnier.bewerbe = emptyList() // Set empty list to avoid null pointer exceptions + } + } + + turniere + } catch (e: Exception) { + log.error("Error fetching tournaments from database", e) + emptyList() // Return empty list instead of throwing exception + } } /** * Inserts dummy tournaments with competitions if the table is empty. */ fun insertDummyTurnierIfEmpty() = transaction { - if (TurniereTable.selectAll().count() == 0L) { - log.info("Turnier table is empty, inserting dummy tournaments...") + try { + // First check if the table is empty + val count = TurniereTable.selectAll().count() + log.info("Current tournament count in database: $count") - // Insert first tournament - val turnierNumber1 = 25319 - TurniereTable.insert { - it[TurniereTable.number] = turnierNumber1 - it[TurniereTable.name] = "CSN-C Edelhof April 2025" - it[TurniereTable.datum] = "14.04.2025 - 15.04.2025" + if (count == 0L) { + log.info("Turnier table is empty, inserting dummy tournaments...") + + try { + // Insert first tournament + val turnierNumber1 = 25319 + TurniereTable.insert { + it[TurniereTable.number] = turnierNumber1 + it[TurniereTable.name] = "CSN-C Edelhof April 2025" + it[TurniereTable.datum] = "14.04.2025 - 15.04.2025" + } + log.info("Inserted first tournament with number $turnierNumber1") + + // Insert competitions for first tournament + BewerbeTable.insert { + it[BewerbeTable.nummer] = 1 + it[BewerbeTable.titel] = "Stilspringprüfung" + it[BewerbeTable.klasse] = "60 cm" + it[BewerbeTable.task] = null + it[BewerbeTable.turnierNumber] = turnierNumber1 + } + + BewerbeTable.insert { + it[BewerbeTable.nummer] = 2 + it[BewerbeTable.titel] = "Dressurprüfung" + it[BewerbeTable.klasse] = "Kl. A" + it[BewerbeTable.task] = "DRA 1" + it[BewerbeTable.turnierNumber] = turnierNumber1 + } + log.info("Inserted competitions for first tournament") + + // Insert second tournament (as specified in the issue description) + val turnierNumber2 = 25320 + TurniereTable.insert { + it[TurniereTable.number] = turnierNumber2 + it[TurniereTable.name] = "CDN-C CDNP-C NEU Neumarkt/M., OÖ" + it[TurniereTable.datum] = "8. JUNI 2025" + } + log.info("Inserted second tournament with number $turnierNumber2") + + // Insert competitions for second tournament + BewerbeTable.insert { + it[BewerbeTable.nummer] = 1 + it[BewerbeTable.titel] = "Dressurprüfung" + it[BewerbeTable.klasse] = "Kl. A" + it[BewerbeTable.task] = "DRA 2" + it[BewerbeTable.turnierNumber] = turnierNumber2 + } + + BewerbeTable.insert { + it[BewerbeTable.nummer] = 2 + it[BewerbeTable.titel] = "Dressurprüfung" + it[BewerbeTable.klasse] = "Kl. L" + it[BewerbeTable.task] = "DRL 1" + it[BewerbeTable.turnierNumber] = turnierNumber2 + } + + BewerbeTable.insert { + it[BewerbeTable.nummer] = 3 + it[BewerbeTable.titel] = "Dressurprüfung" + it[BewerbeTable.klasse] = "Kl. L" + it[BewerbeTable.task] = "DRL 2" + it[BewerbeTable.turnierNumber] = turnierNumber2 + } + log.info("Inserted competitions for second tournament") + + log.info("Dummy tournaments and competitions inserted successfully.") + } catch (e: Exception) { + log.error("Error inserting dummy tournaments", e) + // Don't rethrow, allow the application to continue + } + } else { + log.info("Turnier table is not empty, skipping dummy data insertion") } - - // Insert competitions for first tournament - BewerbeTable.insert { - it[BewerbeTable.nummer] = 1 - it[BewerbeTable.titel] = "Stilspringprüfung" - it[BewerbeTable.klasse] = "60 cm" - it[BewerbeTable.task] = null - it[BewerbeTable.turnierNumber] = turnierNumber1 - } - - BewerbeTable.insert { - it[BewerbeTable.nummer] = 2 - it[BewerbeTable.titel] = "Dressurprüfung" - it[BewerbeTable.klasse] = "Kl. A" - it[BewerbeTable.task] = "DRA 1" - it[BewerbeTable.turnierNumber] = turnierNumber1 - } - - // Insert second tournament (as specified in the issue description) - val turnierNumber2 = 25320 - TurniereTable.insert { - it[TurniereTable.number] = turnierNumber2 - it[TurniereTable.name] = "CDN-C CDNP-C NEU Neumarkt/M., OÖ" - it[TurniereTable.datum] = "8. JUNI 2025" - } - - // Insert competitions for second tournament - BewerbeTable.insert { - it[BewerbeTable.nummer] = 1 - it[BewerbeTable.titel] = "Dressurprüfung" - it[BewerbeTable.klasse] = "Kl. A" - it[BewerbeTable.task] = "DRA 2" - it[BewerbeTable.turnierNumber] = turnierNumber2 - } - - BewerbeTable.insert { - it[BewerbeTable.nummer] = 2 - it[BewerbeTable.titel] = "Dressurprüfung" - it[BewerbeTable.klasse] = "Kl. L" - it[BewerbeTable.task] = "DRL 1" - it[BewerbeTable.turnierNumber] = turnierNumber2 - } - - BewerbeTable.insert { - it[BewerbeTable.nummer] = 3 - it[BewerbeTable.titel] = "Dressurprüfung" - it[BewerbeTable.klasse] = "Kl. L" - it[BewerbeTable.task] = "DRL 2" - it[BewerbeTable.turnierNumber] = turnierNumber2 - } - - log.info("Dummy tournaments and competitions inserted successfully.") + } catch (e: Exception) { + log.error("Error checking if tournament table is empty", e) + // Don't rethrow, allow the application to continue } } @@ -221,26 +254,47 @@ class TurnierRepository { * @return The tournament or null if not found */ fun getTurnierByNumber(number: Int): Turnier? = transaction { - // Get the tournament - val turnierRow = TurniereTable.selectAll().where { TurniereTable.number eq number }.singleOrNull() ?: return@transaction null + log.info("Fetching tournament with number $number") - val turnier = Turnier( - name = turnierRow[TurniereTable.name], - datum = turnierRow[TurniereTable.datum], - number = turnierRow[TurniereTable.number] - ) + try { + // Get the tournament + val turnierRow = TurniereTable.selectAll().where { TurniereTable.number eq number }.singleOrNull() - // Get competitions for this tournament - val bewerbeList = BewerbeTable.selectAll().where { BewerbeTable.turnierNumber eq number }.map { row -> - Bewerb( - nummer = row[BewerbeTable.nummer], - titel = row[BewerbeTable.titel], - klasse = row[BewerbeTable.klasse], - task = row[BewerbeTable.task] + if (turnierRow == null) { + log.warn("Tournament with number $number not found") + return@transaction null + } + + log.info("Found tournament with number $number: ${turnierRow[TurniereTable.name]}") + + val turnier = Turnier( + name = turnierRow[TurniereTable.name], + datum = turnierRow[TurniereTable.datum], + number = turnierRow[TurniereTable.number] ) - } - turnier.bewerbe = bewerbeList - turnier + try { + // Get competitions for this tournament + val bewerbeList = BewerbeTable.selectAll().where { BewerbeTable.turnierNumber eq number }.map { row -> + Bewerb( + nummer = row[BewerbeTable.nummer], + titel = row[BewerbeTable.titel], + klasse = row[BewerbeTable.klasse], + task = row[BewerbeTable.task] + ) + } + + log.info("Found ${bewerbeList.size} competitions for tournament $number") + turnier.bewerbe = bewerbeList + } catch (e: Exception) { + log.error("Error fetching competitions for tournament $number", e) + turnier.bewerbe = emptyList() // Set empty list to avoid null pointer exceptions + } + + turnier + } catch (e: Exception) { + log.error("Error fetching tournament with number $number", e) + null + } } } diff --git a/server/src/main/kotlin/at/mocode/routes/AdminRoutes.kt b/server/src/main/kotlin/at/mocode/routes/AdminRoutes.kt index 1e30e9e6..9e15d2b7 100644 --- a/server/src/main/kotlin/at/mocode/routes/AdminRoutes.kt +++ b/server/src/main/kotlin/at/mocode/routes/AdminRoutes.kt @@ -73,19 +73,24 @@ fun Route.adminRoutes() { val bewerbKlasse = formParameters.getAll("bewerb-klasse[]") ?: emptyList() val bewerbTask = formParameters.getAll("bewerb-task[]") ?: emptyList() + log.info("Received competitions for creation: ${bewerbNummern.size} numbers, ${bewerbTitel.size} titles, ${bewerbKlasse.size} classes, ${bewerbTask.size} tasks") + // Create list of competitions val bewerbe = mutableListOf() for (i in bewerbNummern.indices) { - if (i < bewerbTitel.size && i < bewerbKlasse.size && i < bewerbTask.size) { - bewerbe.add( - Bewerb( - nummer = bewerbNummern[i], - titel = bewerbTitel[i], - klasse = bewerbKlasse[i], - task = if (bewerbTask[i].isNotBlank()) bewerbTask[i] else null - ) + val titel = if (i < bewerbTitel.size) bewerbTitel[i] else "Unbenannter Bewerb" + val klasse = if (i < bewerbKlasse.size) bewerbKlasse[i] else "Keine Klasse" + val task = if (i < bewerbTask.size && bewerbTask[i].isNotBlank()) bewerbTask[i] else null + + bewerbe.add( + Bewerb( + nummer = bewerbNummern[i], + titel = titel, + klasse = klasse, + task = task ) - } + ) + log.info("Added competition for creation: ${bewerbNummern[i]}, $titel, $klasse, $task") } // Create the tournament @@ -126,19 +131,24 @@ fun Route.adminRoutes() { val bewerbKlasse = formParameters.getAll("bewerb-klasse[]") ?: emptyList() val bewerbTask = formParameters.getAll("bewerb-task[]") ?: emptyList() + log.info("Received competitions for update: ${bewerbNummern.size} numbers, ${bewerbTitel.size} titles, ${bewerbKlasse.size} classes, ${bewerbTask.size} tasks") + // Create list of competitions val bewerbe = mutableListOf() for (i in bewerbNummern.indices) { - if (i < bewerbTitel.size && i < bewerbKlasse.size && i < bewerbTask.size) { - bewerbe.add( - Bewerb( - nummer = bewerbNummern[i], - titel = bewerbTitel[i], - klasse = bewerbKlasse[i], - task = if (bewerbTask[i].isNotBlank()) bewerbTask[i] else null - ) + val titel = if (i < bewerbTitel.size) bewerbTitel[i] else "Unbenannter Bewerb" + val klasse = if (i < bewerbKlasse.size) bewerbKlasse[i] else "Keine Klasse" + val task = if (i < bewerbTask.size && bewerbTask[i].isNotBlank()) bewerbTask[i] else null + + bewerbe.add( + Bewerb( + nummer = bewerbNummern[i], + titel = titel, + klasse = klasse, + task = task ) - } + ) + log.info("Added competition for update: ${bewerbNummern[i]}, $titel, $klasse, $task") } // Update the tournament diff --git a/server/src/main/kotlin/at/mocode/routes/HomeRoutes.kt b/server/src/main/kotlin/at/mocode/routes/HomeRoutes.kt index 8a31dc97..e09c66b6 100644 --- a/server/src/main/kotlin/at/mocode/routes/HomeRoutes.kt +++ b/server/src/main/kotlin/at/mocode/routes/HomeRoutes.kt @@ -16,10 +16,7 @@ fun Route.homeRoutes() { get("/") { log.info("Handling request to home page") - // Insert dummy tournament if needed - turnierRepository.insertDummyTurnierIfEmpty() - - // Get all tournaments + // Get all tournaments from the database val turniere = turnierRepository.getAllTurniere() // Render the home page diff --git a/server/src/main/kotlin/at/mocode/views/LayoutTemplate.kt b/server/src/main/kotlin/at/mocode/views/LayoutTemplate.kt index 0451350a..eebfb1db 100644 --- a/server/src/main/kotlin/at/mocode/views/LayoutTemplate.kt +++ b/server/src/main/kotlin/at/mocode/views/LayoutTemplate.kt @@ -178,10 +178,10 @@ class LayoutTemplate { textarea, select { width: 100%; - padding: 1.2rem; - border: none; + padding: 0.8rem; + border: 1px solid var(--border-color); border-radius: 6px; - font-size: 2rem; + font-size: 1rem; transition: all 0.3s; margin-bottom: 1.2rem; box-shadow: 0 1px 3px rgba(0,0,0,0.05); @@ -220,7 +220,6 @@ class LayoutTemplate { letter-spacing: 0.5px; font-weight: 500; box-shadow: 0 2px 5px rgba(0,0,0,0.2); - width: 100%; text-align: center; } @@ -359,8 +358,7 @@ class LayoutTemplate { } .button, button { - width: 100%; - text-align: center; + display: block; margin-bottom: 0.5rem; } } From 77c5809e8a9b54e0115f27c3b67d20dbca126ce4 Mon Sep 17 00:00:00 2001 From: stefan Date: Thu, 5 Jun 2025 16:02:42 +0200 Subject: [PATCH 09/13] fix SQLite --- .github/workflows/deploy-proxmox.yml | 9 ++++++++- README.md | 21 +++++++++++++++++++++ data/meldestelle.db | Bin 32768 -> 32768 bytes docker-compose.yml | 7 +++++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-proxmox.yml b/.github/workflows/deploy-proxmox.yml index b7deac45..23d5b122 100644 --- a/.github/workflows/deploy-proxmox.yml +++ b/.github/workflows/deploy-proxmox.yml @@ -43,6 +43,13 @@ jobs: POSTGRES_USER: ${{ secrets.POSTGRES_USER }} POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} POSTGRES_DB: ${{ secrets.POSTGRES_DB }} + # Email configuration secrets + SMTP_HOST: ${{ secrets.SMTP_HOST }} + SMTP_PORT: ${{ secrets.SMTP_PORT }} + SMTP_USER: ${{ secrets.SMTP_USER }} + SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }} + RECIPIENT_EMAIL: ${{ secrets.RECIPIENT_EMAIL }} + SMTP_SENDER_EMAIL: ${{ secrets.SMTP_SENDER_EMAIL }} # Füge hier optional weitere Secrets hinzu, die deine Compose-Datei braucht # z.B. PGADMIN_DEFAULT_PASSWORD: ${{ secrets.PGADMIN_PASSWORD }} # Führt 'docker compose up' aus. @@ -53,4 +60,4 @@ jobs: # --build: Baut Images neu, falls nötig (besonders wichtig für den 'server'-Service) # -d: Startet Container im Hintergrund # --force-recreate: Erzwingt Neuerstellung, kann helfen, alte Zustände zu vermeiden - run: docker compose up --build -d --force-recreate \ No newline at end of file + run: docker compose up --build -d --force-recreate diff --git a/README.md b/README.md index bb6dd497..f50b5edd 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,27 @@ This is a Kotlin Multiplatform project targeting Web, Desktop, Server. * `/shared` is for the code that will be shared between all targets in the project. The most important subfolder is `commonMain`. If preferred, you can add code to the platform-specific folders here too. +## Email Configuration + +The application uses email to send notifications for form submissions. The email configuration can be set up in several ways: + +1. **Environment Variables**: The application reads email configuration from environment variables. +2. **.env File**: If environment variables are not set, the application looks for a `.env` file. +3. **Default Values**: If neither environment variables nor a `.env` file is found, default values are used. + +### GitHub Actions Secrets + +For deployment with GitHub Actions, the email configuration is stored in GitHub repository secrets. The following secrets need to be set up in your GitHub repository: + +- `SMTP_HOST`: The SMTP server host (e.g., smtp.gmail.com) +- `SMTP_PORT`: The SMTP server port (e.g., 587) +- `SMTP_USER`: The SMTP username (usually your email address) +- `SMTP_PASSWORD`: The SMTP password or app password +- `RECIPIENT_EMAIL`: The email address that will receive form submissions +- `SMTP_SENDER_EMAIL`: The email address that will appear as the sender (usually the same as SMTP_USER) + +These secrets are automatically passed to the Docker container during deployment via the GitHub Actions workflow. + Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html), [Compose Multiplatform](https://github.com/JetBrains/compose-multiplatform/#compose-multiplatform), diff --git a/data/meldestelle.db b/data/meldestelle.db index 922ef380639c8c504b58898d5f631aac6aff4ac2..50b0e648fd75104fa0510afcd39a53b59bb90251 100644 GIT binary patch delta 411 zcmZvYF-yZx6ov0g@@ivSk5N?GD#k^TLbXA`%~TzVAc81@i?;Gm!M;kHTE~EYKrQ|N z!9O6`1Q#6~1YPZ>pil=34jlvslflj5aPGN$aJbiX71vcxk_@MV^9)y87XThe#RlOW zXV^f_Gt3#(bHm3P5hdzR!~Ll^)AV?1Ra6`^lS!M4cb8I%vT03P)15w~qDA;Y3vIML zFrXnR+W|Q-9j^^g{gNW;Fj`7OVx@a%Ei2t|HJk3 rQmKohLHNcOKK#yx|H^xeg}6ox+zM%dYgR}NT(v?<;EKhWMmnrtHi>N% delta 304 zcmZo@U}|V!njp={HBrWyk!xeZLVkV*1_l;>4+j3z{FC^j_&qiYDp>GO_KqE?;Je^N0%T+ z1w%PTc81MQ{NM81G6S{qa^|Jx Date: Thu, 5 Jun 2025 17:34:03 +0200 Subject: [PATCH 10/13] fix SQLite --- .../src/main/kotlin/at/mocode/Application.kt | 17 +- .../main/kotlin/at/mocode/views/AdminView.kt | 154 ----- .../main/kotlin/at/mocode/views/HomeView.kt | 51 -- .../kotlin/at/mocode/views/LayoutTemplate.kt | 547 +++++------------- .../kotlin/at/mocode/views/NennungView.kt | 189 +----- .../src/main/resources/static/css/admin.css | 407 +++++++++++++ server/src/main/resources/static/css/base.css | 156 +++++ .../src/main/resources/static/css/buttons.css | 213 +++++++ .../src/main/resources/static/css/effects.css | 140 +++++ .../src/main/resources/static/css/footer.css | 191 ++++++ .../src/main/resources/static/css/forms.css | 365 ++++++++++++ server/src/main/resources/static/css/main.css | 21 + .../main/resources/static/css/navigation.css | 286 +++++++++ .../src/main/resources/static/css/tables.css | 289 +++++++++ .../main/resources/static/css/tournament.css | 481 +++++++++++++++ .../main/resources/static/css/variables.css | 45 ++ 16 files changed, 2759 insertions(+), 793 deletions(-) create mode 100644 server/src/main/resources/static/css/admin.css create mode 100644 server/src/main/resources/static/css/base.css create mode 100644 server/src/main/resources/static/css/buttons.css create mode 100644 server/src/main/resources/static/css/effects.css create mode 100644 server/src/main/resources/static/css/footer.css create mode 100644 server/src/main/resources/static/css/forms.css create mode 100644 server/src/main/resources/static/css/main.css create mode 100644 server/src/main/resources/static/css/navigation.css create mode 100644 server/src/main/resources/static/css/tables.css create mode 100644 server/src/main/resources/static/css/tournament.css create mode 100644 server/src/main/resources/static/css/variables.css diff --git a/server/src/main/kotlin/at/mocode/Application.kt b/server/src/main/kotlin/at/mocode/Application.kt index ec7a4c13..c7499b42 100644 --- a/server/src/main/kotlin/at/mocode/Application.kt +++ b/server/src/main/kotlin/at/mocode/Application.kt @@ -5,8 +5,9 @@ import at.mocode.routes.configureAdminRoutes import at.mocode.routes.configureHomeRoutes import at.mocode.routes.configureNennungRoutes import io.ktor.server.application.* +import io.ktor.server.http.content.* import io.ktor.server.netty.* - +import io.ktor.server.routing.* fun main(args: Array) { @@ -20,8 +21,22 @@ fun Application.module() { // Configure database first configureDatabase() + // Configure static resources + configureStaticResources() + // Configure routes configureHomeRoutes() configureNennungRoutes() configureAdminRoutes() } + +/** + * Configure static resources. + */ +fun Application.configureStaticResources() { + routing { + staticResources(remotePath = "/css", basePackage = "static/css") + staticResources(remotePath = "/js", basePackage = "static/js") + staticResources(remotePath = "/images", basePackage = "static/images") + } +} diff --git a/server/src/main/kotlin/at/mocode/views/AdminView.kt b/server/src/main/kotlin/at/mocode/views/AdminView.kt index 6dfccb67..6b79c2e0 100644 --- a/server/src/main/kotlin/at/mocode/views/AdminView.kt +++ b/server/src/main/kotlin/at/mocode/views/AdminView.kt @@ -154,92 +154,6 @@ class AdminView { } } - // Additional styles specific to the admin page - style { - +""" - .admin-section { - background-color: var(--container-bg); - border-radius: 8px; - padding: 1.5rem; - margin-bottom: 2rem; - box-shadow: 0 2px 10px rgba(0,0,0,0.05); - } - - .admin-section h2 { - border-bottom: 1px solid var(--border-color); - padding-bottom: 0.75rem; - margin-bottom: 1.5rem; - color: var(--primary-color); - } - - .admin-form { - max-width: 100%; - } - - .form-row { - display: flex; - flex-wrap: wrap; - margin: 0 -10px; - } - - .form-group-third { - flex: 0 0 calc(33.333% - 20px); - margin: 0 10px; - } - - .bewerbe-section { - border-top: 1px solid var(--border-color); - padding-top: 1.5rem; - } - - .bewerb-container { - background-color: var(--light-bg); - border-radius: 8px; - padding: 1.5rem; - margin-bottom: 1rem; - border: 1px solid var(--border-color); - } - - .bewerb-container h4 { - margin-top: 0; - color: var(--secondary-color); - border-bottom: 1px solid var(--border-color); - padding-bottom: 0.5rem; - margin-bottom: 1rem; - } - - .table-responsive { - overflow-x: auto; - } - - .competition-list { - margin: 0; - padding-left: 1.2rem; - } - - .competition-list li { - margin-bottom: 0.25rem; - } - - .empty-state { - text-align: center; - padding: 2rem; - color: var(--light-text); - } - - .empty-state i { - font-size: 3rem; - margin-bottom: 1rem; - display: block; - } - - @media (max-width: 768px) { - .form-group-third { - flex: 0 0 calc(100% - 20px); - } - } - """ - } // JavaScript for dynamic form handling script(type = "text/javascript") { @@ -442,74 +356,6 @@ class AdminView { } } - // Additional styles specific to the edit page - style { - +""" - .admin-section { - background-color: var(--container-bg); - border-radius: 8px; - padding: 1.5rem; - margin-bottom: 2rem; - box-shadow: 0 2px 10px rgba(0,0,0,0.05); - } - - .admin-form { - max-width: 100%; - } - - .form-row { - display: flex; - flex-wrap: wrap; - margin: 0 -10px; - } - - .form-group-third { - flex: 0 0 calc(33.333% - 20px); - margin: 0 10px; - } - - .bewerbe-section { - border-top: 1px solid var(--border-color); - padding-top: 1.5rem; - } - - .bewerb-container { - background-color: var(--light-bg); - border-radius: 8px; - padding: 1.5rem; - margin-bottom: 1rem; - border: 1px solid var(--border-color); - } - - .bewerb-container h4 { - margin-top: 0; - color: var(--secondary-color); - border-bottom: 1px solid var(--border-color); - padding-bottom: 0.5rem; - margin-bottom: 1rem; - } - - .readonly-field { - background-color: var(--light-bg); - cursor: not-allowed; - } - - .ml-2 { - margin-left: 0.5rem; - } - - @media (max-width: 768px) { - .form-group-third { - flex: 0 0 calc(100% - 20px); - } - - .ml-2 { - margin-left: 0; - margin-top: 0.5rem; - } - } - """ - } // JavaScript for dynamic form handling script(type = "text/javascript") { diff --git a/server/src/main/kotlin/at/mocode/views/HomeView.kt b/server/src/main/kotlin/at/mocode/views/HomeView.kt index 03d2d91c..7d9a39aa 100644 --- a/server/src/main/kotlin/at/mocode/views/HomeView.kt +++ b/server/src/main/kotlin/at/mocode/views/HomeView.kt @@ -77,57 +77,6 @@ class HomeView { } } - style { - +""" - .tournament-list { - display: flex; - flex-direction: column; - gap: 16px; - max-width: 800px; - margin: 0 auto; - } - - .tournament-item { - border: 1px solid var(--border-color); - border-radius: 12px; - padding: 24px; - transition: box-shadow 0.3s; - background-color: var(--container-bg); - box-shadow: 0 2px 8px rgba(0,0,0,0.05); - } - - .tournament-item:hover { - box-shadow: 0 5px 15px rgba(0,0,0,0.08); - } - - .tournament-header h3 { - margin-top: 0; - color: var(--primary-color); - font-size: 1.4rem; - } - - .tournament-header p { - color: var(--light-text); - margin-bottom: 0.5rem; - font-size: 0.95rem; - } - - .tournament-competitions h4 { - font-size: 1.1rem; - margin-bottom: 0.5rem; - color: var(--secondary-color); - } - - .tournament-competitions ul { - padding-left: 1.2rem; - } - - .tournament-actions { - margin-top: 1rem; - text-align: right; - } - """ - } } } } diff --git a/server/src/main/kotlin/at/mocode/views/LayoutTemplate.kt b/server/src/main/kotlin/at/mocode/views/LayoutTemplate.kt index eebfb1db..f5b18dfe 100644 --- a/server/src/main/kotlin/at/mocode/views/LayoutTemplate.kt +++ b/server/src/main/kotlin/at/mocode/views/LayoutTemplate.kt @@ -25,367 +25,73 @@ class LayoutTemplate { meta(name = "viewport", content = "width=device-width, initial-scale=1.0") title { +title } link(rel = "stylesheet", href = "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css") - link(rel = "stylesheet", href = "https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap") - link(rel = "stylesheet", href = "https://fonts.googleapis.com/icon?family=Material+Icons") - link(rel = "stylesheet", href = "https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css") - style { - +""" - /* Base styles */ - :root { - --primary-color: #5d8aa8; - --primary-hover: #4a7a98; - --secondary-color: #7d9eb1; - --secondary-hover: #6a8ca1; - --text-color: #333; - --light-text: #666; - --lighter-text: #999; - --border-color: #e0e0e0; - --light-bg: #f5f7fa; - --container-bg: #fff; - --success-color: #66bb6a; - --warning-color: #ffa726; - --error-color: #ef5350; - } - - * { - box-sizing: border-box; - margin: 0; - padding: 0; - } - - body { - font-family: 'Roboto', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - line-height: 1.6; - color: var(--text-color); - background-color: var(--light-bg); - padding: 0; - margin: 0; - } - - .container { - width: 100%; - max-width: 1200px; - margin: 0 auto; - padding: 0 20px; - } - - /* Navigation */ - nav.nav-extended { - background-color: var(--primary-color); - margin-bottom: 20px; - } - - nav .brand-logo { - font-size: 1.6rem; - font-weight: 500; - padding-left: 10px; - } - - nav .brand-logo i { - margin-right: 8px; - } - - nav ul li a { - font-weight: 500; - transition: background-color 0.3s; - } - - nav ul li a:hover { - background-color: rgba(255,255,255,0.1); - } - - .sidenav { - width: 280px; - } - - .sidenav .user-view { - padding: 20px 16px 12px; - } - - .sidenav .user-view .name { - font-size: 1.4rem; - font-weight: 500; - margin-top: 8px; - color: var(--primary-color); - } - - .sidenav li > a { - display: flex; - align-items: center; - font-weight: 500; - } - - .sidenav li > a > i { - margin-right: 16px; - color: var(--primary-color); - } - - /* Main content */ - main { - padding: 2rem 0; - } - - .content-card { - background-color: var(--container-bg); - border-radius: 8px; - box-shadow: 0 2px 10px rgba(0,0,0,0.05); - padding: 2rem; - margin-bottom: 2rem; - } - - /* Typography */ - h1, h2, h3, h4, h5, h6 { - margin-bottom: 1rem; - line-height: 1.2; - color: var(--text-color); - } - - h1 { - font-size: 2.2rem; - text-align: center; - margin-bottom: 1.5rem; - } - - h2 { - font-size: 1.8rem; - margin-top: 1.5rem; - } - - h3 { - font-size: 1.5rem; - margin-top: 1.2rem; - } - - p { - margin-bottom: 1rem; - } - - /* Forms */ - .form-group { - margin-bottom: 1.5rem; - } - - label { - display: block; - margin-bottom: 0.5rem; - font-weight: 600; - } - - input[type="text"], - input[type="email"], - input[type="tel"], - input[type="number"], - textarea, - select { - width: 100%; - padding: 0.8rem; - border: 1px solid var(--border-color); - border-radius: 6px; - font-size: 1rem; - transition: all 0.3s; - margin-bottom: 1.2rem; - box-shadow: 0 1px 3px rgba(0,0,0,0.05); - background-color: white; - } - - input[type="text"]:focus, - input[type="email"]:focus, - input[type="tel"]:focus, - input[type="number"]:focus, - textarea:focus, - select:focus { - border-color: var(--primary-color); - outline: none; - box-shadow: 0 2px 8px rgba(93,138,168,0.2); - } - - .required:after { - content: " *"; - color: var(--error-color); - } - - /* Buttons */ - .button, button { - display: inline-block; - background-color: var(--primary-color); - color: white; - padding: 0.9rem 1.8rem; - border: none; - border-radius: 4px; - font-size: 1.1rem; - cursor: pointer; - text-decoration: none; - transition: all 0.3s; - text-transform: uppercase; - letter-spacing: 0.5px; - font-weight: 500; - box-shadow: 0 2px 5px rgba(0,0,0,0.2); - text-align: center; - } - - .button:hover, button:hover { - background-color: var(--primary-hover); - box-shadow: 0 4px 8px rgba(0,0,0,0.3); - transform: translateY(-2px); - } - - .button-secondary { - background-color: var(--secondary-color); - } - - .button-secondary:hover { - background-color: var(--secondary-hover); - } - - /* Tables */ - table { - width: 100%; - border-collapse: collapse; - margin-bottom: 1.5rem; - } - - th, td { - padding: 0.75rem; - text-align: left; - border-bottom: 1px solid var(--border-color); - } - - th { - background-color: var(--light-bg); - font-weight: 600; - } - - tr:hover { - background-color: rgba(0,0,0,0.02); - } - - /* Lists */ - ul, ol { - margin-bottom: 1rem; - padding-left: 1.5rem; - } - - li { - margin-bottom: 0.5rem; - } - - /* Utilities */ - .text-center { - text-align: center; - } - - .mt-1 { margin-top: 0.5rem; } - .mt-2 { margin-top: 1rem; } - .mt-3 { margin-top: 1.5rem; } - .mt-4 { margin-top: 2rem; } - - .mb-1 { margin-bottom: 0.5rem; } - .mb-2 { margin-bottom: 1rem; } - .mb-3 { margin-bottom: 1.5rem; } - .mb-4 { margin-bottom: 2rem; } - - /* Footer */ - footer { - background-color: var(--text-color); - color: white; - padding: 2rem 0; - margin-top: 2rem; - } - - footer a { - color: white; - text-decoration: none; - } - - footer a:hover { - text-decoration: underline; - } - - /* Responsive design */ - @media (max-width: 768px) { - .menu-toggle { - display: block; - } - - nav ul { - display: none; - position: absolute; - top: 60px; - left: 0; - right: 0; - flex-direction: column; - background-color: var(--primary-color); - padding: 1rem; - box-shadow: 0 2px 5px rgba(0,0,0,0.1); - } - - nav ul.show { - display: flex; - } - - nav ul li { - margin: 0.5rem 0; - } - - .content-card { - padding: 1.5rem; - } - - h1 { - font-size: 1.8rem; - } - - h2 { - font-size: 1.5rem; - } - - h3 { - font-size: 1.3rem; - } - } - - @media (max-width: 480px) { - .container { - padding: 0 15px; - } - - .content-card { - padding: 1rem; - } - - h1 { - font-size: 1.6rem; - } - - .button, button { - display: block; - margin-bottom: 0.5rem; - } - } - """ - } - script(src = "https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js") {} + link(rel = "stylesheet", href = "https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Roboto:wght@300;400;500;700&display=swap") + link(rel = "stylesheet", href = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css") + link(rel = "stylesheet", href = "https://cdn.jsdelivr.net/npm/aos@2.3.4/dist/aos.css") + link(rel = "stylesheet", href = "/css/main.css") + script(src = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js") {} + script(src = "https://cdn.jsdelivr.net/npm/aos@2.3.4/dist/aos.js") {} script { unsafe { +""" document.addEventListener('DOMContentLoaded', function() { - // Mobile menu toggle - const menuToggle = document.querySelector('.menu-toggle'); - const navMenu = document.querySelector('nav ul'); + // Initialize Bootstrap tooltips + const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]'); + const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl)); - if (menuToggle && navMenu) { - menuToggle.addEventListener('click', function() { - navMenu.classList.toggle('show'); + // Initialize Bootstrap popovers + const popoverTriggerList = document.querySelectorAll('[data-bs-toggle="popover"]'); + const popoverList = [...popoverTriggerList].map(popoverTriggerEl => new bootstrap.Popover(popoverTriggerEl)); + + // Add Bootstrap validation classes to forms + const forms = document.querySelectorAll('.needs-validation'); + Array.from(forms).forEach(form => { + form.addEventListener('submit', event => { + if (!form.checkValidity()) { + event.preventDefault(); + event.stopPropagation(); + } + form.classList.add('was-validated'); + }, false); + }); + + // Initialize AOS (Animate On Scroll) + AOS.init({ + duration: 800, + easing: 'ease-in-out', + once: true + }); + + // Navbar scroll effect + const navbar = document.querySelector('.navbar'); + if (navbar) { + window.addEventListener('scroll', function() { + if (window.scrollY > 50) { + navbar.classList.add('navbar-scrolled'); + } else { + navbar.classList.remove('navbar-scrolled'); + } }); } - // Initialize Materialize components - M.AutoInit(); + // Add ripple effect to buttons + const buttons = document.querySelectorAll('.button, .btn'); + buttons.forEach(button => { + button.addEventListener('click', function(e) { + const x = e.clientX - e.target.getBoundingClientRect().left; + const y = e.clientY - e.target.getBoundingClientRect().top; - // Enhance form elements - const inputs = document.querySelectorAll('input, textarea, select'); - inputs.forEach(input => { - input.classList.add('browser-default'); + const ripple = document.createElement('span'); + ripple.classList.add('ripple-effect'); + ripple.style.left = x + 'px'; + ripple.style.top = y + 'px'; + + this.appendChild(ripple); + + setTimeout(() => { + ripple.remove(); + }, 600); + }); }); }); """ @@ -394,28 +100,35 @@ class LayoutTemplate { } body { if (showNavbar) { - nav(classes = "nav-extended z-depth-1") { - div("nav-wrapper") { - div("container") { - a(href = "/", classes = "brand-logo") { - i("material-icons left") { +"sports_handball" } - +"Meldestelle Portal" - } - a(href = "#", classes = "sidenav-trigger") { - attributes["data-target"] = "mobile-nav" - i("material-icons") { +"menu" } - } - ul(classes = "right hide-on-med-and-down") { - li { - a(href = "/") { - i("material-icons left") { +"home" } + nav(classes = "navbar navbar-expand-lg navbar-dark fixed-top") { + div("container") { + a(href = "/", classes = "navbar-brand") { + i("fas fa-horse-head") {} + +"Meldestelle Portal" + } + button(classes = "navbar-toggler") { + type = ButtonType.button + attributes["data-bs-toggle"] = "collapse" + attributes["data-bs-target"] = "#navbarContent" + attributes["aria-controls"] = "navbarContent" + attributes["aria-expanded"] = "false" + attributes["aria-label"] = "Toggle navigation" + span(classes = "navbar-toggler-icon") {} + } + + div(classes = "collapse navbar-collapse") { + id = "navbarContent" + ul(classes = "navbar-nav ms-auto mb-2 mb-lg-0") { + li(classes = "nav-item") { + a(href = "/", classes = "nav-link active") { + i("fas fa-home") {} +"Home" } } if (showAdminLink) { - li { - a(href = "/admin/tournaments") { - i("material-icons left") { +"event" } + li(classes = "nav-item") { + a(href = "/admin/tournaments", classes = "nav-link") { + i("fas fa-calendar-alt") {} +"Turnierverwaltung" } } @@ -424,46 +137,74 @@ class LayoutTemplate { } } } + } + // Add padding to account for fixed navbar + div(classes = "navbar-spacer") {} - // Mobile sidenav - ul(classes = "sidenav") { - attributes["id"] = "mobile-nav" - li { - div("user-view") { - div("background blue-grey lighten-4") { - style = "height: 80px;" - } - span("name") { +"Meldestelle Portal" } - } - } - li { - a(href = "/") { - i("material-icons") { +"home" } - +"Home" - } - } - if (showAdminLink) { - li { - a(href = "/admin/tournaments") { - i("material-icons") { +"event" } - +"Turnierverwaltung" - } + main(classes = "py-5") { + div("container") { + div(classes = "card shadow") { + attributes["data-aos"] = "fade-up" + attributes["data-aos-delay"] = "100" + div("card-body") { + content() } } } } - main { + footer(classes = "footer mt-5") { + attributes["data-aos"] = "fade-up" + attributes["data-aos-delay"] = "200" div("container") { - div("content-card") { - content() + div("footer-content") { + div("row gy-4") { + div("col-lg-4 col-md-6") { + div("footer-info") { + h3(classes = "gradient-text") { +"Meldestelle Portal" } + p { + +"Ihre zentrale Plattform für Turnierorganisation und Anmeldungen." + } + div("social-links mt-3") { + a(href = "#", classes = "facebook") { i("fab fa-facebook-f") {} } + a(href = "#", classes = "twitter") { i("fab fa-twitter") {} } + a(href = "#", classes = "instagram") { i("fab fa-instagram") {} } + a(href = "#", classes = "linkedin") { i("fab fa-linkedin-in") {} } + } + } + } + div("col-lg-4 col-md-6") { + div("footer-links") { + h4 { +"Nützliche Links" } + ul { + li { a(href = "/") { +"Home" } } + li { a(href = "#") { +"Über uns" } } + li { a(href = "#") { +"Turniere" } } + li { a(href = "#") { +"Kontakt" } } + } + } + } + div("col-lg-4 col-md-6") { + div("footer-contact") { + h4 { +"Kontakt" } + p { + i("fas fa-envelope me-2") {} + +"info@meldestelle-portal.at" + } + p { + i("fas fa-phone me-2") {} + +"+43 123 456 789" + } + } + } + } } - } - } - footer { - div("container") { - div("text-center") { - p { +"© ${java.time.Year.now().value} Meldestelle Portal. Alle Rechte vorbehalten." } - p { + div("footer-legal text-center") { + div("copyright") { + +"© ${java.time.Year.now().value} " + strong { +"Meldestelle Portal" } + +". Alle Rechte vorbehalten." + } + div("credits") { +"Entwickelt von " a(href = "#") { +"mocode" } } diff --git a/server/src/main/kotlin/at/mocode/views/NennungView.kt b/server/src/main/kotlin/at/mocode/views/NennungView.kt index 76bb5b51..e526907c 100644 --- a/server/src/main/kotlin/at/mocode/views/NennungView.kt +++ b/server/src/main/kotlin/at/mocode/views/NennungView.kt @@ -144,132 +144,6 @@ class NennungView { } } - // Additional styles specific to the registration form - style { - +""" - .tournament-info { - margin-bottom: 2rem; - } - - .tournament-info h2 { - color: var(--primary-color); - } - - .form-section { - background-color: white; - border-radius: 8px; - padding: 2rem; - margin-bottom: 2rem; - box-shadow: 0 2px 10px rgba(0,0,0,0.08); - border-left: 4px solid var(--primary-color); - } - - .form-section h3 { - border-bottom: 1px solid var(--border-color); - padding-bottom: 0.8rem; - margin-bottom: 1.5rem; - text-align: left; - color: var(--primary-color); - font-size: 1.4rem; - } - - .form-row { - display: flex; - flex-direction: column; - width: 100%; - margin: 0; - } - - @media (min-width: 768px) { - .form-row { - flex-direction: row; - gap: 20px; - } - - .form-group-half { - flex: 1; - } - } - - .form-group { - width: 100%; - margin: 0 0 1.5rem 0; - } - - .form-group-half { - flex: 0 0 100%; - margin: 0 0 1.5rem 0; - max-width: 100%; - } - - .form-hint { - font-size: 0.9rem; - color: var(--light-text); - margin-top: 0.5rem; - text-align: center; - } - - .competitions-list { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 15px; - width: 100%; - margin: 0; - } - - .competition-item { - padding: 18px; - border-radius: 8px; - transition: all 0.3s; - width: 100%; - text-align: left; - border: 1px solid var(--border-color); - background-color: white; - box-shadow: 0 1px 3px rgba(0,0,0,0.1); - } - - .competition-item:hover { - background-color: rgba(0,0,0,0.02); - box-shadow: 0 3px 8px rgba(0,0,0,0.12); - } - - .competition-item label { - display: flex; - align-items: center; - justify-content: flex-start; - cursor: pointer; - } - - .competition-item input[type="checkbox"] { - margin-right: 15px; - transform: scale(1.2); - } - - .competition-details { - text-align: left; - } - - /* Participant information styling */ - .participant-details { - width: 100%; - } - - .participant-details label { - display: block; - margin-bottom: 0.5rem; - font-weight: 500; - } - - .participant-details input { - width: 100%; - } - - @media (max-width: 768px) { - /* Mobile styles already handled by the responsive layout */ - } - """ - } } } } @@ -318,7 +192,11 @@ class NennungView { } p(classes = "confirmation-message") { - +"Sie erhalten in Kürze eine Bestätigung per E-Mail." + +"Start und Ergebnislisten auf" + } + + p(classes = "confirmation-message") { + +"www.ihremeldestelle.at" } div(classes = "confirmation-actions") { @@ -328,63 +206,6 @@ class NennungView { } } - // Additional styles specific to the confirmation page - style { - +""" - .confirmation-box { - background-color: var(--light-bg); - border-radius: 8px; - padding: 2rem; - text-align: center; - max-width: 600px; - margin: 0 auto; - box-shadow: 0 4px 15px rgba(0,0,0,0.05); - } - - .confirmation-icon { - font-size: 4rem; - color: var(--success-color); - margin-bottom: 1rem; - } - - .confirmation-details { - margin: 1.5rem 0; - padding: 1rem; - background-color: var(--container-bg); - border-radius: 8px; - text-align: left; - } - - .detail-item { - display: flex; - margin-bottom: 0.5rem; - padding: 0.5rem 0; - border-bottom: 1px solid var(--border-color); - } - - .detail-item:last-child { - border-bottom: none; - } - - .detail-label { - font-weight: bold; - width: 100px; - } - - .detail-value { - flex: 1; - } - - .confirmation-message { - margin: 1.5rem 0; - color: var(--light-text); - } - - .confirmation-actions { - margin-top: 1.5rem; - } - """ - } } } } diff --git a/server/src/main/resources/static/css/admin.css b/server/src/main/resources/static/css/admin.css new file mode 100644 index 00000000..175e00a5 --- /dev/null +++ b/server/src/main/resources/static/css/admin.css @@ -0,0 +1,407 @@ +/* Modern admin interface styles with enhanced UX */ + +/* Admin dashboard layout */ +.admin-dashboard { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.admin-dashboard-card { + background-color: white; + border-radius: var(--border-radius); + box-shadow: var(--box-shadow); + padding: 1.5rem; + transition: all var(--transition-fast); + border: 1px solid var(--border-color); + position: relative; + overflow: hidden; +} + +.admin-dashboard-card:hover { + transform: translateY(-5px); + box-shadow: var(--box-shadow-lg); +} + +.admin-dashboard-card:before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 4px; + height: 100%; + background-color: var(--primary-color); +} + +.admin-dashboard-card h3 { + color: var(--primary-color); + font-size: 1.25rem; + margin-bottom: 1rem; + font-weight: 700; +} + +.admin-dashboard-card .stat { + font-size: 2.5rem; + font-weight: 700; + color: var(--text-color); + margin-bottom: 0.5rem; +} + +.admin-dashboard-card .stat-label { + color: var(--light-text); + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Admin section styling */ +.admin-section { + background-color: white; + border-radius: var(--border-radius); + box-shadow: var(--box-shadow); + margin-bottom: 2rem; + overflow: hidden; + border: 1px solid var(--border-color); + transition: all var(--transition-fast); +} + +.admin-section:hover { + box-shadow: var(--box-shadow-lg); +} + +.admin-section .card-header { + background: linear-gradient(to right, rgba(67, 97, 238, 0.05), rgba(114, 9, 183, 0.05)); + border-bottom: 1px solid var(--border-color); + padding: 1.25rem 1.5rem; + display: flex; + align-items: center; + justify-content: space-between; +} + +.admin-section .card-body { + padding: 1.5rem; +} + +.admin-section h2 { + color: var(--primary-color); + font-size: 1.5rem; + margin-bottom: 0; + font-weight: 700; + display: flex; + align-items: center; +} + +.admin-section h2 i { + margin-right: 0.75rem; + font-size: 1.25rem; + background-color: rgba(67, 97, 238, 0.1); + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: transform var(--transition-fast); +} + +.admin-section:hover h2 i { + transform: rotate(10deg); +} + +/* Admin form styling */ +.admin-form { + width: 100%; +} + +.admin-form .form-row { + display: flex; + flex-wrap: wrap; + margin: 0 -0.75rem; +} + +.admin-form .form-group { + padding: 0 0.75rem; + margin-bottom: 1.5rem; +} + +.admin-form .form-group-third { + flex: 0 0 33.333333%; + max-width: 33.333333%; +} + +.admin-form label { + font-weight: 600; + margin-bottom: 0.5rem; + display: block; + color: var(--text-color); +} + +.admin-form input, +.admin-form select, +.admin-form textarea { + width: 100%; + padding: 0.75rem 1rem; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + transition: all var(--transition-fast); +} + +.admin-form input:focus, +.admin-form select:focus, +.admin-form textarea:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 0.25rem rgba(67, 97, 238, 0.25); + outline: none; +} + +/* Competition section styling */ +.bewerbe-section { + border-top: 1px solid var(--border-color); + padding-top: 2rem; + margin-top: 2rem; + position: relative; +} + +.bewerbe-section h3 { + color: var(--secondary-color); + font-size: 1.5rem; + margin-bottom: 1.5rem; + font-weight: 700; + display: flex; + align-items: center; +} + +.bewerbe-section h3 i { + margin-right: 0.75rem; + font-size: 1.25rem; + color: var(--accent-color); +} + +/* Competition container styling */ +.bewerb-container { + background-color: var(--neutral-50); + border-radius: var(--border-radius); + padding: 1.5rem; + margin-bottom: 1.5rem; + border: 1px solid var(--border-color); + position: relative; + transition: all var(--transition-fast); + box-shadow: var(--box-shadow-sm); +} + +.bewerb-container:hover { + background-color: white; + transform: translateY(-3px); + box-shadow: var(--box-shadow); + border-color: var(--primary-color); +} + +.bewerb-container h4 { + color: var(--secondary-color); + font-size: 1.25rem; + border-bottom: 1px solid var(--border-color); + padding-bottom: 0.75rem; + margin-bottom: 1.25rem; + font-weight: 600; + display: flex; + align-items: center; +} + +.bewerb-container h4 i { + margin-right: 0.5rem; + color: var(--accent-color); + transition: transform var(--transition-fast); +} + +.bewerb-container:hover h4 i { + transform: rotate(10deg); +} + +/* Empty state styling */ +.empty-state { + text-align: center; + padding: 3rem 2rem; + background-color: var(--neutral-50); + border-radius: var(--border-radius); + border: 1px dashed var(--border-color); + margin: 2rem 0; +} + +.empty-state i { + font-size: 4rem; + margin-bottom: 1.5rem; + display: block; + color: var(--secondary-color); + opacity: 0.7; +} + +.empty-state p { + font-size: 1.25rem; + color: var(--text-color); + margin-bottom: 1.5rem; + font-weight: 500; +} + +.empty-state .button { + margin-top: 1rem; +} + +/* Admin table styling */ +.admin-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + margin-bottom: 1.5rem; +} + +.admin-table th { + background-color: var(--neutral-100); + font-weight: 700; + text-transform: uppercase; + font-size: 0.85rem; + letter-spacing: 0.05em; + color: var(--primary-color); + padding: 1rem; + border-bottom: 2px solid var(--primary-color); +} + +.admin-table td { + padding: 1rem; + border-bottom: 1px solid var(--border-color); + vertical-align: middle; +} + +.admin-table tr:hover td { + background-color: rgba(67, 97, 238, 0.03); +} + +.admin-table tr:last-child td { + border-bottom: none; +} + +/* Admin action buttons */ +.admin-actions { + display: flex; + gap: 0.75rem; + margin-top: 1.5rem; + justify-content: flex-end; +} + +.admin-actions .button { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.admin-actions .button i { + margin-right: 0.5rem; + font-size: 1em; +} + +/* Admin tabs */ +.admin-tabs { + display: flex; + border-bottom: 1px solid var(--border-color); + margin-bottom: 2rem; +} + +.admin-tab { + padding: 1rem 1.5rem; + font-weight: 600; + color: var(--light-text); + cursor: pointer; + position: relative; + transition: all var(--transition-fast); +} + +.admin-tab:hover { + color: var(--primary-color); +} + +.admin-tab.active { + color: var(--primary-color); +} + +.admin-tab.active:after { + content: ''; + position: absolute; + bottom: -1px; + left: 0; + width: 100%; + height: 3px; + background-color: var(--primary-color); +} + +/* Admin filters */ +.admin-filters { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin-bottom: 1.5rem; + padding: 1rem; + background-color: var(--neutral-50); + border-radius: var(--border-radius); +} + +.admin-filter { + flex: 1; + min-width: 200px; +} + +.admin-filter label { + font-weight: 600; + margin-bottom: 0.5rem; + display: block; + font-size: 0.875rem; +} + +/* Responsive adjustments */ +@media (max-width: 992px) { + .admin-dashboard { + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + } + + .admin-form .form-group-third { + flex: 0 0 50%; + max-width: 50%; + } +} + +@media (max-width: 768px) { + .admin-section .card-header { + flex-direction: column; + align-items: flex-start; + } + + .admin-actions { + flex-direction: column; + width: 100%; + margin-top: 1rem; + } + + .admin-form .form-group-third { + flex: 0 0 100%; + max-width: 100%; + } + + .admin-tabs { + overflow-x: auto; + white-space: nowrap; + -webkit-overflow-scrolling: touch; + } +} + +@media (max-width: 576px) { + .admin-dashboard { + grid-template-columns: 1fr; + } + + .bewerb-container { + padding: 1.25rem; + } + + .admin-section .card-body { + padding: 1.25rem; + } +} diff --git a/server/src/main/resources/static/css/base.css b/server/src/main/resources/static/css/base.css new file mode 100644 index 00000000..561b2b99 --- /dev/null +++ b/server/src/main/resources/static/css/base.css @@ -0,0 +1,156 @@ +/* Base styles - Modern and accessible */ +body { + font-family: 'Inter', 'Roboto', 'Segoe UI', system-ui, -apple-system, sans-serif; + color: var(--text-color); + background-color: var(--neutral-50); + line-height: 1.7; + font-size: 16px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +/* Modern typography system */ +h1, h2, h3, h4, h5, h6 { + margin-bottom: var(--spacer-3); + font-weight: 700; + line-height: 1.3; + color: var(--text-color); + letter-spacing: -0.02em; +} + +h1 { + font-size: 2.5rem; + margin-bottom: var(--spacer-4); +} + +h2 { + font-size: 2rem; + margin-top: var(--spacer-5); + margin-bottom: var(--spacer-3); +} + +h3 { + font-size: 1.5rem; + margin-top: var(--spacer-4); +} + +h4 { + font-size: 1.25rem; +} + +p { + margin-bottom: var(--spacer-3); + max-width: 70ch; /* Optimal reading width */ +} + +a { + color: var(--primary-color); + text-decoration: none; + transition: color var(--transition-fast); +} + +a:hover { + color: var(--primary-hover); + text-decoration: underline; +} + +/* Custom page title */ +.page-title { + text-align: center; + margin-bottom: var(--spacer-5); + font-weight: 700; + color: var(--primary-color); + position: relative; + padding-bottom: var(--spacer-3); +} + +.page-title::after { + content: ''; + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 60px; + height: 4px; + background-color: var(--accent-color); + border-radius: 2px; +} + +/* Modern card styles */ +.content-card { + background-color: var(--container-bg); + border-radius: var(--border-radius); + box-shadow: var(--box-shadow); + transition: transform var(--transition-fast), box-shadow var(--transition-fast); + overflow: hidden; + border: 1px solid var(--border-color); +} + +.content-card:hover { + transform: translateY(-3px); + box-shadow: var(--box-shadow-lg); +} + +/* Section styling */ +.section { + padding: var(--spacer-5) 0; +} + +.section-title { + text-align: center; + margin-bottom: var(--spacer-5); +} + +/* Custom utility classes */ +.text-primary { color: var(--primary-color) !important; } +.text-secondary { color: var(--secondary-color) !important; } +.text-accent { color: var(--accent-color) !important; } +.text-muted { color: var(--light-text) !important; } + +.bg-primary { background-color: var(--primary-color) !important; } +.bg-secondary { background-color: var(--secondary-color) !important; } +.bg-accent { background-color: var(--accent-color) !important; } +.bg-light { background-color: var(--neutral-100) !important; } + +.rounded { border-radius: var(--border-radius) !important; } +.rounded-lg { border-radius: var(--border-radius-lg) !important; } +.rounded-circle { border-radius: 50% !important; } + +.shadow-sm { box-shadow: var(--box-shadow-sm) !important; } +.shadow { box-shadow: var(--box-shadow) !important; } +.shadow-lg { box-shadow: var(--box-shadow-lg) !important; } + +/* Custom responsive adjustments */ +@media (max-width: 992px) { + h1 { font-size: 2.2rem; } + h2 { font-size: 1.8rem; } + h3 { font-size: 1.4rem; } +} + +@media (max-width: 768px) { + body { font-size: 15px; } + h1 { font-size: 2rem; } + h2 { font-size: 1.6rem; } + h3 { font-size: 1.3rem; } + + .page-title { + font-size: 1.8rem; + margin-bottom: var(--spacer-4); + } +} + +@media (max-width: 480px) { + h1 { font-size: 1.8rem; } + h2 { font-size: 1.5rem; } + h3 { font-size: 1.2rem; } + + .page-title { + font-size: 1.6rem; + margin-bottom: var(--spacer-3); + } + + .section { + padding: var(--spacer-4) 0; + } +} diff --git a/server/src/main/resources/static/css/buttons.css b/server/src/main/resources/static/css/buttons.css new file mode 100644 index 00000000..36d759a6 --- /dev/null +++ b/server/src/main/resources/static/css/buttons.css @@ -0,0 +1,213 @@ +/* Modern button styles with enhanced UX */ + +/* Base button styles - applies to both .btn and .button */ +.btn, .button { + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 600; + text-align: center; + vertical-align: middle; + cursor: pointer; + user-select: none; + padding: 0.625rem 1.25rem; + font-size: 1rem; + border-radius: var(--border-radius); + transition: all var(--transition-fast); + text-decoration: none; + letter-spacing: 0.01em; + position: relative; + overflow: hidden; + border: none; + line-height: 1.5; +} + +/* Primary button */ +.btn-primary, .button { + background-color: var(--primary-color); + color: white; + box-shadow: var(--box-shadow-sm); +} + +/* Secondary button */ +.btn-secondary, .button-secondary { + background-color: var(--secondary-color); + color: white; + box-shadow: var(--box-shadow-sm); +} + +/* Accent button */ +.btn-accent, .button-accent { + background-color: var(--accent-color); + color: white; + box-shadow: var(--box-shadow-sm); +} + +/* Outline buttons */ +.btn-outline-primary { + background-color: transparent; + color: var(--primary-color); + border: 2px solid var(--primary-color); +} + +.btn-outline-secondary { + background-color: transparent; + color: var(--secondary-color); + border: 2px solid var(--secondary-color); +} + +.btn-outline-accent { + background-color: transparent; + color: var(--accent-color); + border: 2px solid var(--accent-color); +} + +/* Ghost buttons */ +.btn-ghost, .button-ghost { + background-color: transparent; + color: var(--text-color); + box-shadow: none; +} + +/* Enhanced hover and focus effects */ +.btn:hover, .button:hover { + transform: translateY(-3px); + box-shadow: var(--box-shadow); +} + +.btn:active, .button:active { + transform: translateY(-1px); +} + +.btn:focus, .button:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(67, 97, 238, 0.3); +} + +/* Ripple effect for buttons */ +.btn::after, .button::after { + content: ''; + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + pointer-events: none; + background-image: radial-gradient(circle, #fff 10%, transparent 10.01%); + background-repeat: no-repeat; + background-position: 50%; + transform: scale(10, 10); + opacity: 0; + transition: transform 0.5s, opacity 0.8s; +} + +.btn:active::after, .button:active::after { + transform: scale(0, 0); + opacity: 0.3; + transition: 0s; +} + +/* Button sizes */ +.btn-sm, .button-sm { + padding: 0.375rem 0.75rem; + font-size: 0.875rem; +} + +.btn-lg, .button-lg { + padding: 0.875rem 1.75rem; + font-size: 1.125rem; + font-weight: 600; + letter-spacing: 0.02em; +} + +/* Button with icons */ +.btn i, .button i { + margin-right: 0.625rem; + font-size: 1.1em; +} + +.btn-icon, .button-icon { + width: 2.5rem; + height: 2.5rem; + padding: 0; + border-radius: 50%; +} + +.btn-icon i, .button-icon i { + margin-right: 0; +} + +/* Button group */ +.btn-group { + display: inline-flex; +} + +.btn-group .btn, .btn-group .button { + border-radius: 0; +} + +.btn-group .btn:first-child, .btn-group .button:first-child { + border-top-left-radius: var(--border-radius); + border-bottom-left-radius: var(--border-radius); +} + +.btn-group .btn:last-child, .btn-group .button:last-child { + border-top-right-radius: var(--border-radius); + border-bottom-right-radius: var(--border-radius); +} + +/* Disabled state */ +.btn:disabled, .button:disabled, +.btn.disabled, .button.disabled { + opacity: 0.65; + pointer-events: none; + box-shadow: none; +} + +/* Loading state */ +.btn-loading, .button-loading { + position: relative; + color: transparent !important; +} + +.btn-loading::before, .button-loading::before { + content: ''; + position: absolute; + width: 1rem; + height: 1rem; + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top-color: white; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Responsive button adjustments */ +@media (max-width: 768px) { + .btn, .button { + padding: 0.5rem 1rem; + } + + .btn-lg, .button-lg { + padding: 0.75rem 1.5rem; + } +} + +@media (max-width: 480px) { + .d-grid .btn, .d-grid .button { + margin-bottom: 0.5rem; + width: 100%; + } + + .btn-group { + flex-direction: column; + } + + .btn-group .btn, .btn-group .button { + border-radius: var(--border-radius); + margin-bottom: 0.5rem; + } +} diff --git a/server/src/main/resources/static/css/effects.css b/server/src/main/resources/static/css/effects.css new file mode 100644 index 00000000..eaf3e25b --- /dev/null +++ b/server/src/main/resources/static/css/effects.css @@ -0,0 +1,140 @@ +/* Special effects and animations */ + +/* Navbar spacer to account for fixed navbar */ +.navbar-spacer { + height: 70px; /* Adjust based on navbar height */ +} + +/* Ripple effect for buttons */ +.ripple-effect { + position: absolute; + border-radius: 50%; + background-color: rgba(255, 255, 255, 0.7); + width: 100px; + height: 100px; + margin-top: -50px; + margin-left: -50px; + animation: ripple 0.6s linear; + pointer-events: none; +} + +@keyframes ripple { + 0% { + transform: scale(0); + opacity: 0.5; + } + 100% { + transform: scale(4); + opacity: 0; + } +} + +/* Hover lift effect */ +.hover-lift { + transition: transform var(--transition-fast), box-shadow var(--transition-fast); +} + +.hover-lift:hover { + transform: translateY(-5px); + box-shadow: var(--box-shadow-lg); +} + +/* Gradient text */ +.gradient-text { + background: linear-gradient(90deg, var(--primary-color), var(--secondary-color)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + display: inline-block; +} + +/* Gradient border */ +.gradient-border { + position: relative; + border-radius: var(--border-radius); + padding: 4px; + background: linear-gradient(90deg, var(--primary-color), var(--secondary-color)); +} + +.gradient-border-content { + background-color: white; + border-radius: calc(var(--border-radius) - 2px); + padding: 1.5rem; + height: 100%; +} + +/* Shimmer effect for loading states */ +.shimmer { + background: linear-gradient(90deg, + var(--neutral-100) 0%, + var(--neutral-200) 50%, + var(--neutral-100) 100%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; +} + +@keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +/* Fade in animation */ +.fade-in { + animation: fadeIn 0.5s ease-in-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Pulse animation */ +.pulse { + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.05); + } + 100% { + transform: scale(1); + } +} + +/* Floating animation */ +.floating { + animation: floating 3s ease-in-out infinite; +} + +@keyframes floating { + 0% { + transform: translateY(0px); + } + 50% { + transform: translateY(-10px); + } + 100% { + transform: translateY(0px); + } +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .navbar-spacer { + height: 60px; + } +} diff --git a/server/src/main/resources/static/css/footer.css b/server/src/main/resources/static/css/footer.css new file mode 100644 index 00000000..ae97138e --- /dev/null +++ b/server/src/main/resources/static/css/footer.css @@ -0,0 +1,191 @@ +/* Modern footer styles */ + +.footer { + background: linear-gradient(to right, var(--primary-color), var(--secondary-color)); + color: white; + padding: 4rem 0 2rem; + position: relative; + overflow: hidden; +} + +.footer::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: url('data:image/svg+xml;utf8,'); + background-repeat: no-repeat; + background-position: bottom; + background-size: cover; + opacity: 0.1; + z-index: 0; +} + +.footer-content { + position: relative; + z-index: 1; + margin-bottom: 2rem; +} + +/* Footer info section */ +.footer-info h3 { + font-size: 1.8rem; + margin-bottom: 1rem; + font-weight: 700; +} + +.footer-info p { + font-size: 0.95rem; + line-height: 1.7; + margin-bottom: 1.5rem; + max-width: 300px; +} + +/* Social links */ +.social-links { + display: flex; + gap: 0.75rem; +} + +.social-links a { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background-color: rgba(255, 255, 255, 0.1); + border-radius: 50%; + color: white; + transition: all var(--transition-fast); +} + +.social-links a:hover { + background-color: white; + color: var(--primary-color); + transform: translateY(-3px); +} + +/* Footer links */ +.footer-links h4 { + font-size: 1.2rem; + margin-bottom: 1.25rem; + font-weight: 600; + position: relative; + padding-bottom: 0.75rem; +} + +.footer-links h4::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 30px; + height: 2px; + background-color: white; +} + +.footer-links ul { + list-style: none; + padding: 0; + margin: 0; +} + +.footer-links li { + margin-bottom: 0.75rem; + position: relative; + padding-left: 1.25rem; +} + +.footer-links li::before { + content: '›'; + position: absolute; + left: 0; + top: 0; + font-size: 1.2rem; + color: rgba(255, 255, 255, 0.7); +} + +.footer-links a { + color: rgba(255, 255, 255, 0.8); + text-decoration: none; + transition: all var(--transition-fast); +} + +.footer-links a:hover { + color: white; + padding-left: 5px; +} + +/* Footer contact */ +.footer-contact h4 { + font-size: 1.2rem; + margin-bottom: 1.25rem; + font-weight: 600; + position: relative; + padding-bottom: 0.75rem; +} + +.footer-contact h4::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 30px; + height: 2px; + background-color: white; +} + +.footer-contact p { + margin-bottom: 0.75rem; + display: flex; + align-items: center; +} + +.footer-contact i { + width: 20px; + text-align: center; + margin-right: 0.75rem; +} + +/* Footer legal section */ +.footer-legal { + border-top: 1px solid rgba(255, 255, 255, 0.1); + padding-top: 1.5rem; + margin-top: 1.5rem; + color: rgba(255, 255, 255, 0.8); + font-size: 0.9rem; +} + +.footer-legal .copyright { + margin-bottom: 0.5rem; +} + +.footer-legal a { + color: white; + text-decoration: none; + font-weight: 600; + transition: all var(--transition-fast); +} + +.footer-legal a:hover { + text-decoration: underline; +} + +/* Responsive adjustments */ +@media (max-width: 992px) { + .footer { + padding: 3rem 0 1.5rem; + } +} + +@media (max-width: 768px) { + .footer-info, .footer-links, .footer-contact { + margin-bottom: 2rem; + } + + .footer-info p { + max-width: 100%; + } +} diff --git a/server/src/main/resources/static/css/forms.css b/server/src/main/resources/static/css/forms.css new file mode 100644 index 00000000..6e7c9f38 --- /dev/null +++ b/server/src/main/resources/static/css/forms.css @@ -0,0 +1,365 @@ +/* Modern form styles with enhanced UX and accessibility */ + +/* Form layout and spacing */ +.form-group { + margin-bottom: 1.5rem; + position: relative; +} + +.form-row { + display: flex; + flex-wrap: wrap; + margin-right: -0.75rem; + margin-left: -0.75rem; +} + +.form-col { + flex: 1 0 0; + padding-right: 0.75rem; + padding-left: 0.75rem; +} + +/* Modern label styling */ +.form-label { + display: block; + margin-bottom: 0.5rem; + font-weight: 600; + color: var(--text-color); + font-size: 0.9375rem; + transition: color var(--transition-fast); +} + +/* Floating labels */ +.form-floating { + position: relative; +} + +.form-floating > .form-control, +.form-floating > .form-select { + height: calc(3.5rem + 2px); + padding: 1.625rem 0.75rem 0.625rem; +} + +.form-floating > label { + position: absolute; + top: 0; + left: 0; + height: 100%; + padding: 1rem 0.75rem; + pointer-events: none; + border: 1px solid transparent; + transform-origin: 0 0; + transition: opacity 0.1s ease-in-out, transform 0.1s ease-in-out; +} + +.form-floating > .form-control:focus ~ label, +.form-floating > .form-control:not(:placeholder-shown) ~ label, +.form-floating > .form-select ~ label { + opacity: 0.65; + transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem); +} + +/* Input styling */ +.form-control { + display: block; + width: 100%; + padding: 0.75rem 1rem; + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + color: var(--text-color); + background-color: #fff; + background-clip: padding-box; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} + +.form-control:focus { + border-color: var(--primary-color); + outline: 0; + box-shadow: 0 0 0 0.25rem rgba(67, 97, 238, 0.25); +} + +.form-control::placeholder { + color: var(--lighter-text); + opacity: 1; +} + +/* Select styling */ +.form-select { + display: block; + width: 100%; + padding: 0.75rem 2.25rem 0.75rem 1rem; + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + color: var(--text-color); + background-color: #fff; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right 1rem center; + background-size: 16px 12px; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + appearance: none; + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} + +.form-select:focus { + border-color: var(--primary-color); + outline: 0; + box-shadow: 0 0 0 0.25rem rgba(67, 97, 238, 0.25); +} + +/* Checkbox and radio styling */ +.form-check { + display: block; + min-height: 1.5rem; + padding-left: 1.75em; + margin-bottom: 0.5rem; +} + +.form-check-input { + width: 1.25em; + height: 1.25em; + margin-top: 0.125em; + margin-left: -1.75em; + vertical-align: top; + background-color: #fff; + background-repeat: no-repeat; + background-position: center; + background-size: contain; + border: 1px solid var(--border-color); + appearance: none; + transition: background-color var(--transition-fast), border-color var(--transition-fast); +} + +.form-check-input[type="checkbox"] { + border-radius: 0.25em; +} + +.form-check-input[type="radio"] { + border-radius: 50%; +} + +.form-check-input:checked { + background-color: var(--primary-color); + border-color: var(--primary-color); +} + +.form-check-input:checked[type="checkbox"] { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/%3e%3c/svg%3e"); +} + +.form-check-input:checked[type="radio"] { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e"); +} + +.form-check-input:focus { + border-color: var(--primary-color); + outline: 0; + box-shadow: 0 0 0 0.25rem rgba(67, 97, 238, 0.25); +} + +/* Switch styling */ +.form-switch { + padding-left: 2.5em; +} + +.form-switch .form-check-input { + width: 2em; + margin-left: -2.5em; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e"); + background-position: left center; + border-radius: 2em; + transition: background-position 0.15s ease-in-out; +} + +.form-switch .form-check-input:checked { + background-position: right center; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e"); +} + +/* Required field indicator */ +.required:after { + content: " *"; + color: var(--error-color); + font-weight: bold; +} + +/* Form validation states */ +.was-validated .form-control:valid, .form-control.is-valid { + border-color: var(--success-color); + padding-right: calc(1.5em + 0.75rem); + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2338b000' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right calc(0.375em + 0.1875rem) center; + background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} + +.was-validated .form-control:invalid, .form-control.is-invalid { + border-color: var(--error-color); + padding-right: calc(1.5em + 0.75rem); + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23d90429'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23d90429' stroke='none'/%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right calc(0.375em + 0.1875rem) center; + background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} + +.invalid-feedback { + display: none; + width: 100%; + margin-top: 0.25rem; + font-size: 0.875em; + color: var(--error-color); +} + +.was-validated :invalid ~ .invalid-feedback, +.is-invalid ~ .invalid-feedback { + display: block; +} + +/* Custom form hint text */ +.form-text { + margin-top: 0.25rem; + font-size: 0.875rem; + color: var(--light-text); +} + +/* Custom form section styling */ +.form-section { + background-color: white; + border-radius: var(--border-radius); + padding: 2rem; + margin-bottom: 2rem; + box-shadow: var(--box-shadow); + border-left: 4px solid var(--primary-color); + transition: transform var(--transition-fast), box-shadow var(--transition-fast); +} + +.form-section:hover { + transform: translateY(-3px); + box-shadow: var(--box-shadow-lg); +} + +.form-section h3 { + border-bottom: 1px solid var(--border-color); + padding-bottom: 0.75rem; + margin-bottom: 1.5rem; + color: var(--primary-color); + font-size: 1.25rem; + font-weight: 600; +} + +/* Custom form action buttons container */ +.form-actions { + margin-top: 2.5rem; + display: flex; + gap: 1rem; + justify-content: flex-end; +} + +/* Input sizes */ +.form-control-lg { + min-height: calc(1.5em + 1rem + 2px); + padding: 0.5rem 1rem; + font-size: 1.25rem; + border-radius: var(--border-radius); +} + +.form-control-sm { + min-height: calc(1.5em + 0.5rem + 2px); + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + border-radius: var(--border-radius-sm); +} + +/* Readonly and disabled states */ +.form-control:disabled, .form-control[readonly] { + background-color: var(--neutral-100); + opacity: 1; +} + +/* File input */ +.form-control[type="file"] { + overflow: hidden; +} + +.form-control[type="file"]:not(:disabled):not([readonly]) { + cursor: pointer; +} + +/* Range input */ +.form-range { + width: 100%; + height: 1.5rem; + padding: 0; + background-color: transparent; + appearance: none; +} + +.form-range:focus { + outline: 0; +} + +.form-range:focus::-webkit-slider-thumb { + box-shadow: 0 0 0 1px #fff, 0 0 0 0.25rem rgba(67, 97, 238, 0.25); +} + +.form-range::-webkit-slider-thumb { + width: 1rem; + height: 1rem; + margin-top: -0.25rem; + background-color: var(--primary-color); + border: 0; + border-radius: 1rem; + transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + appearance: none; +} + +.form-range::-webkit-slider-thumb:active { + background-color: var(--primary-hover); +} + +.form-range::-webkit-slider-runnable-track { + width: 100%; + height: 0.5rem; + color: transparent; + cursor: pointer; + background-color: var(--neutral-200); + border-color: transparent; + border-radius: 1rem; +} + +/* Responsive adjustments */ +@media (max-width: 992px) { + .form-row { + flex-direction: column; + } + + .form-col { + padding-right: 0; + padding-left: 0; + } +} + +@media (max-width: 768px) { + .form-section { + padding: 1.5rem; + } + + .form-actions { + flex-direction: column; + } +} + +@media (max-width: 480px) { + .form-section { + padding: 1.25rem; + } + + .form-control, .form-select { + font-size: 16px; /* Prevents iOS zoom on focus */ + } +} diff --git a/server/src/main/resources/static/css/main.css b/server/src/main/resources/static/css/main.css new file mode 100644 index 00000000..357248bb --- /dev/null +++ b/server/src/main/resources/static/css/main.css @@ -0,0 +1,21 @@ +/* Main CSS file that imports all other CSS files */ + +/* Import variables first */ +@import url('variables.css'); + +/* Base styles */ +@import url('base.css'); + +/* Component styles */ +@import url('navigation.css'); +@import url('forms.css'); +@import url('buttons.css'); +@import url('tables.css'); +@import url('footer.css'); + +/* Special effects and animations */ +@import url('effects.css'); + +/* Page-specific styles */ +@import url('tournament.css'); +@import url('admin.css'); diff --git a/server/src/main/resources/static/css/navigation.css b/server/src/main/resources/static/css/navigation.css new file mode 100644 index 00000000..c28a5d7c --- /dev/null +++ b/server/src/main/resources/static/css/navigation.css @@ -0,0 +1,286 @@ +/* Modern navigation styles with enhanced UX */ + +/* Main navbar styling */ +.navbar { + padding: 1rem 0; + box-shadow: var(--box-shadow); + transition: all var(--transition-fast); + position: sticky; + top: 0; + z-index: 1030; +} + +.navbar-scrolled { + padding: 0.5rem 0; + box-shadow: var(--box-shadow-lg); + backdrop-filter: blur(10px); + background-color: rgba(255, 255, 255, 0.95); +} + +/* Enhanced navbar brand */ +.navbar-brand { + font-weight: 700; + font-size: 1.5rem; + color: white; + display: flex; + align-items: center; + letter-spacing: -0.01em; + position: relative; + padding: 0.5rem 0; +} + +.navbar-brand:hover { + color: rgba(255, 255, 255, 0.9); +} + +.navbar-brand i { + margin-right: 0.75rem; + font-size: 1.25em; + transition: transform var(--transition-fast); +} + +.navbar-brand:hover i { + transform: scale(1.1) rotate(-5deg); +} + +/* Navbar toggler animation */ +.navbar-toggler { + border: none; + padding: 0.5rem; + transition: transform var(--transition-fast); +} + +.navbar-toggler:focus { + box-shadow: none; + outline: none; +} + +.navbar-toggler:hover { + transform: scale(1.05); +} + +.navbar-toggler-icon { + transition: transform var(--transition-fast); +} + +.navbar-toggler:not(.collapsed) .navbar-toggler-icon { + transform: rotate(90deg); +} + +/* Nav links styling */ +.nav-link { + font-weight: 600; + padding: 0.5rem 1rem; + color: rgba(255, 255, 255, 0.85); + position: relative; + transition: color var(--transition-fast); +} + +.nav-link:hover, .nav-link:focus { + color: white; +} + +/* Animated underline effect */ +.nav-link::after { + content: ''; + position: absolute; + width: 0; + height: 2px; + bottom: 0; + left: 50%; + background-color: white; + transition: all var(--transition); + transform: translateX(-50%); + opacity: 0; +} + +.nav-link:hover::after, +.nav-link:focus::after, +.nav-link.active::after { + width: 80%; + opacity: 1; +} + +.nav-link.active { + color: white; + font-weight: 700; +} + +.nav-link i { + margin-right: 0.5rem; + font-size: 1.1em; + transition: transform var(--transition-fast); +} + +.nav-link:hover i { + transform: translateY(-2px); +} + +/* Dropdown menus */ +.dropdown-menu { + border: none; + border-radius: var(--border-radius); + box-shadow: var(--box-shadow); + padding: 0.5rem; + margin-top: 0.5rem; + animation: fadeIn 0.2s ease-out; +} + +.dropdown-item { + padding: 0.75rem 1rem; + border-radius: var(--border-radius-sm); + transition: all var(--transition-fast); +} + +.dropdown-item:hover, .dropdown-item:focus { + background-color: var(--neutral-100); + color: var(--primary-color); +} + +.dropdown-item.active { + background-color: var(--primary-color); + color: white; +} + +.dropdown-item i { + margin-right: 0.5rem; + color: var(--primary-color); +} + +.dropdown-item.active i { + color: white; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Navbar color variants */ +.navbar-light { + background-color: white; +} + +.navbar-light .navbar-brand, +.navbar-light .nav-link { + color: var(--text-color); +} + +.navbar-light .nav-link:hover, +.navbar-light .nav-link:focus, +.navbar-light .nav-link.active { + color: var(--primary-color); +} + +.navbar-light .nav-link::after { + background-color: var(--primary-color); +} + +.navbar-dark { + background-color: var(--primary-color); +} + +.navbar-dark .navbar-toggler-icon { + filter: brightness(0) invert(1); +} + +/* Custom card styling */ +.card { + border: none; + border-radius: var(--border-radius); + box-shadow: var(--box-shadow); + transition: transform var(--transition-fast), box-shadow var(--transition-fast); + overflow: hidden; +} + +.card:hover { + transform: translateY(-5px); + box-shadow: var(--box-shadow-lg); +} + +.card-body { + padding: 2rem; +} + +.card-title { + color: var(--primary-color); + margin-bottom: 1rem; + font-weight: 700; +} + +.card-subtitle { + color: var(--secondary-color); + margin-bottom: 1rem; + font-weight: 600; +} + +.card-img-top { + transition: transform var(--transition); +} + +.card:hover .card-img-top { + transform: scale(1.05); +} + +/* Responsive adjustments */ +@media (max-width: 992px) { + .navbar-collapse { + background-color: var(--primary-color); + border-radius: var(--border-radius); + padding: 1rem; + margin-top: 0.5rem; + box-shadow: var(--box-shadow); + } + + .navbar-light .navbar-collapse { + background-color: white; + } + + .navbar-nav { + padding: 0.5rem 0; + } + + .nav-link { + padding: 0.75rem 1rem; + } + + .nav-link::after { + bottom: auto; + top: 0; + height: 100%; + width: 3px; + left: 0; + transform: none; + } + + .nav-link:hover::after, + .nav-link:focus::after, + .nav-link.active::after { + width: 3px; + height: 80%; + } +} + +@media (max-width: 768px) { + .navbar-brand { + font-size: 1.25rem; + } + + .card-body { + padding: 1.5rem; + } +} + +@media (max-width: 480px) { + .navbar { + padding: 0.75rem 0; + } + + .navbar-brand i { + margin-right: 0.5rem; + } + + .card-body { + padding: 1.25rem; + } +} diff --git a/server/src/main/resources/static/css/tables.css b/server/src/main/resources/static/css/tables.css new file mode 100644 index 00000000..d40e3410 --- /dev/null +++ b/server/src/main/resources/static/css/tables.css @@ -0,0 +1,289 @@ +/* Modern table styles with enhanced UX */ + +/* Base table styling */ +.table { + width: 100%; + margin-bottom: 1.5rem; + color: var(--text-color); + vertical-align: middle; + border-color: var(--border-color); + --bs-table-striped-bg: var(--neutral-50); + --bs-table-striped-color: var(--text-color); + --bs-table-active-bg: var(--neutral-100); + --bs-table-active-color: var(--text-color); + --bs-table-hover-bg: var(--neutral-100); + --bs-table-hover-color: var(--text-color); + border-collapse: separate; + border-spacing: 0; +} + +/* Enhanced table header */ +.table thead { + position: relative; +} + +.table thead th { + background-color: var(--neutral-100); + font-weight: 700; + border-top: none; + border-bottom: 2px solid var(--primary-color); + padding: 1rem; + text-transform: uppercase; + font-size: 0.875rem; + letter-spacing: 0.05em; + color: var(--primary-color); + vertical-align: bottom; + position: sticky; + top: 0; + z-index: 10; +} + +/* Table body styling */ +.table tbody td { + padding: 1rem; + vertical-align: middle; + border-bottom: 1px solid var(--border-color); + transition: background-color var(--transition-fast); +} + +/* Custom table hover effect */ +.table-hover tbody tr { + transition: all var(--transition-fast); +} + +.table-hover tbody tr:hover { + background-color: rgba(67, 97, 238, 0.05); + transform: translateY(-2px); + box-shadow: var(--box-shadow-sm); +} + +/* Striped table */ +.table-striped > tbody > tr:nth-of-type(odd) > * { + background-color: var(--neutral-50); +} + +/* Bordered table */ +.table-bordered { + border: 1px solid var(--border-color); +} + +.table-bordered th, +.table-bordered td { + border: 1px solid var(--border-color); +} + +/* Custom modern table */ +.table-modern { + border-radius: var(--border-radius); + overflow: hidden; + box-shadow: var(--box-shadow); + border: 1px solid var(--border-color); +} + +.table-modern thead th { + background-color: var(--primary-color); + color: white; + border-bottom: none; +} + +.table-modern thead th:first-child { + border-top-left-radius: var(--border-radius); +} + +.table-modern thead th:last-child { + border-top-right-radius: var(--border-radius); +} + +.table-modern tbody tr:last-child td:first-child { + border-bottom-left-radius: var(--border-radius); +} + +.table-modern tbody tr:last-child td:last-child { + border-bottom-right-radius: var(--border-radius); +} + +/* Table with cards for mobile */ +.table-responsive-card { + overflow-x: auto; +} + +@media (max-width: 768px) { + .table-responsive-card table, + .table-responsive-card thead, + .table-responsive-card tbody, + .table-responsive-card th, + .table-responsive-card td, + .table-responsive-card tr { + display: block; + } + + .table-responsive-card thead tr { + position: absolute; + top: -9999px; + left: -9999px; + } + + .table-responsive-card tr { + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + margin-bottom: 1rem; + box-shadow: var(--box-shadow-sm); + background-color: white; + } + + .table-responsive-card td { + border: none; + border-bottom: 1px solid var(--border-color); + position: relative; + padding-left: 50%; + text-align: right; + } + + .table-responsive-card td:last-child { + border-bottom: none; + } + + .table-responsive-card td:before { + position: absolute; + top: 1rem; + left: 1rem; + width: 45%; + padding-right: 10px; + white-space: nowrap; + text-align: left; + font-weight: 600; + content: attr(data-label); + } +} + +/* Table with horizontal scroll for mobile */ +.table-responsive { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +/* Table caption */ +.table caption { + padding: 0.5rem; + caption-side: bottom; + text-align: center; + color: var(--light-text); + font-style: italic; +} + +/* Table with actions */ +.table .actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; +} + +.table .actions .btn { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; +} + +/* Table with status indicators */ +.table .status-indicator { + width: 0.75rem; + height: 0.75rem; + border-radius: 50%; + display: inline-block; + margin-right: 0.5rem; +} + +.table .status-active { + background-color: var(--success-color); +} + +.table .status-pending { + background-color: var(--warning-color); +} + +.table .status-inactive { + background-color: var(--error-color); +} + +/* Modern list styles */ +.list-modern { + list-style: none; + padding-left: 0; + margin-bottom: 1.5rem; +} + +.list-modern li { + padding: 1rem; + margin-bottom: 0.5rem; + background-color: white; + border-radius: var(--border-radius); + box-shadow: var(--box-shadow-sm); + transition: all var(--transition-fast); + border-left: 3px solid var(--primary-color); +} + +.list-modern li:hover { + transform: translateX(5px); + box-shadow: var(--box-shadow); +} + +/* Competition list specific styles */ +.competition-list { + margin: 0; + padding-left: 0; + list-style-type: none; +} + +.competition-list li { + margin-bottom: 0.75rem; + position: relative; + padding: 0.75rem 0.75rem 0.75rem 1.5rem; + border-radius: var(--border-radius-sm); + transition: all var(--transition-fast); + background-color: var(--neutral-50); +} + +.competition-list li:hover { + background-color: white; + box-shadow: var(--box-shadow-sm); + transform: translateX(3px); +} + +.competition-list li:before { + content: ""; + position: absolute; + left: 0.5rem; + top: 50%; + transform: translateY(-50%); + width: 0.5rem; + height: 0.5rem; + background-color: var(--primary-color); + border-radius: 50%; +} + +/* Feature list */ +.feature-list { + list-style: none; + padding-left: 0; +} + +.feature-list li { + padding-left: 2rem; + position: relative; + margin-bottom: 1rem; +} + +.feature-list li:before { + content: "✓"; + position: absolute; + left: 0; + top: 0; + width: 1.5rem; + height: 1.5rem; + background-color: var(--primary-color); + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.875rem; +} diff --git a/server/src/main/resources/static/css/tournament.css b/server/src/main/resources/static/css/tournament.css new file mode 100644 index 00000000..c458b351 --- /dev/null +++ b/server/src/main/resources/static/css/tournament.css @@ -0,0 +1,481 @@ +/* Modern tournament styles with enhanced UX */ + +/* Tournament list container */ +.tournament-list { + max-width: 900px; + margin: 0 auto; + padding: 1rem 0; +} + +/* Tournament item card */ +.tournament-item { + border-radius: var(--border-radius-lg); + transition: all var(--transition); + margin-bottom: 2rem; + overflow: hidden; + background-color: white; + border: 1px solid var(--border-color); + box-shadow: var(--box-shadow); + position: relative; + padding: 1.5rem; +} + +.tournament-item:hover { + transform: translateY(-8px); + box-shadow: var(--box-shadow-lg); +} + +.tournament-item:before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 5px; + background: linear-gradient(90deg, var(--primary-color), var(--secondary-color)); + opacity: 0; + transition: opacity var(--transition); +} + +.tournament-item:hover:before { + opacity: 1; +} + +/* Tournament header */ +.tournament-header { + display: flex; + flex-direction: column; + margin-bottom: 1.5rem; +} + +.tournament-header h3 { + color: var(--primary-color); + font-size: 1.6rem; + font-weight: 700; + margin-bottom: 1rem; + position: relative; + display: inline-block; +} + +.tournament-header h3:after { + content: ''; + position: absolute; + bottom: -0.5rem; + left: 0; + width: 50px; + height: 3px; + background-color: var(--accent-color); + border-radius: 3px; + transition: width var(--transition); +} + +.tournament-item:hover .tournament-header h3:after { + width: 100px; +} + +.tournament-meta { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin-top: 0.5rem; +} + +.tournament-header p { + color: var(--light-text); + font-size: 1rem; + margin: 0; + display: flex; + align-items: center; +} + +.tournament-header p i { + color: var(--primary-color); + width: 1.5rem; + height: 1.5rem; + text-align: center; + margin-right: 0.5rem; + background-color: rgba(67, 97, 238, 0.1); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.8rem; + transition: transform var(--transition-fast); +} + +.tournament-item:hover .tournament-header p i { + transform: scale(1.2); +} + +/* Tournament competitions section */ +.tournament-competitions { + background-color: var(--neutral-50); + border-radius: var(--border-radius); + padding: 1.25rem; + margin-bottom: 1.5rem; + transition: all var(--transition-fast); +} + +.tournament-item:hover .tournament-competitions { + background-color: var(--neutral-100); +} + +.tournament-competitions h4 { + font-size: 1.1rem; + color: var(--secondary-color); + margin-top: 0; + margin-bottom: 1rem; + font-weight: 600; + display: flex; + align-items: center; +} + +.tournament-competitions h4:before { + content: '\f091'; /* Trophy icon */ + font-family: 'Font Awesome 5 Free', serif; + font-weight: 900; + margin-right: 0.5rem; + font-size: 0.9em; + color: var(--accent-color); +} + +.tournament-competitions ul { + list-style: none; + padding-left: 0; + margin-bottom: 0; +} + +.tournament-competitions li { + padding: 0.5rem 0; + border-bottom: 1px dashed var(--border-color); + transition: transform var(--transition-fast); + padding-left: 1.5rem; + position: relative; +} + +.tournament-competitions li:last-child { + border-bottom: none; +} + +.tournament-competitions li:before { + content: ''; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 8px; + height: 8px; + background-color: var(--primary-color); + border-radius: 50%; +} + +.tournament-competitions li:hover { + transform: translateX(5px); +} + +/* Tournament actions */ +.tournament-actions { + margin-top: 1.5rem; + text-align: right; +} + +.tournament-actions .button { + position: relative; + overflow: hidden; + z-index: 1; +} + +.tournament-actions .button:before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(90deg, var(--primary-color), var(--secondary-color)); + z-index: -1; + transition: opacity var(--transition); + opacity: 0; +} + +.tournament-actions .button:hover:before { + opacity: 1; +} + +/* Tournament info for registration page */ +.tournament-info { + margin-bottom: 3rem; + text-align: center; + position: relative; + padding-bottom: 2rem; +} + +.tournament-info:after { + content: ''; + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 100px; + height: 3px; + background: linear-gradient(90deg, var(--primary-color), var(--secondary-color)); + border-radius: 3px; +} + +.tournament-info h2 { + color: var(--primary-color); + margin-bottom: 0.75rem; + font-weight: 700; + font-size: 2.2rem; +} + +.tournament-info p { + color: var(--light-text); + font-size: 1.1rem; + margin-bottom: 0.5rem; +} + +/* Registration form styles */ +.registration-form { + max-width: 800px; + margin: 0 auto; +} + +.competitions-list { + width: 100%; + margin: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1.5rem; +} + +/* Competition item */ +.competition-item { + padding: 1.25rem; + border-radius: var(--border-radius); + transition: all var(--transition-fast); + border: 1px solid var(--border-color); + background-color: white; + box-shadow: var(--box-shadow-sm); + height: 100%; + display: flex; + flex-direction: column; +} + +.competition-item:hover { + background-color: rgba(67, 97, 238, 0.03); + box-shadow: var(--box-shadow); + transform: translateY(-5px); + border-color: var(--primary-color); +} + +/* Custom form check styling */ +.competition-item .form-check { + display: flex; + align-items: flex-start; + padding-left: 0; + margin-bottom: 0; + height: 100%; +} + +.competition-item .form-check-input { + margin-right: 1rem; + margin-top: 0.25rem; + float: none; + flex-shrink: 0; + width: 1.25rem; + height: 1.25rem; + cursor: pointer; +} + +.competition-details { + flex-grow: 1; +} + +.competition-details strong { + color: var(--primary-color); + font-weight: 700; + font-size: 1.1rem; + display: block; + margin-bottom: 0.5rem; +} + +/* Participant details section */ +.participant-details { + width: 100%; +} + +.participant-details .form-label { + font-weight: 600; + margin-bottom: 0.5rem; + color: var(--text-color); +} + +/* Custom form spacing */ +.registration-form .mb-3:last-child { + margin-bottom: 0 !important; +} + +/* Confirmation page styles */ +.confirmation-box { + max-width: 700px; + margin: 0 auto; + text-align: center; + background-color: white; + border-radius: var(--border-radius-lg); + padding: 3rem 2rem; + box-shadow: var(--box-shadow); + position: relative; + overflow: hidden; +} + +.confirmation-box:before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 8px; + background: linear-gradient(90deg, var(--success-color), var(--primary-color)); +} + +/* Success icon styling */ +.confirmation-icon { + font-size: 4rem; + color: var(--success-color); + margin-bottom: 2rem; + position: relative; + display: inline-block; +} + +.confirmation-icon i { + background-color: rgba(56, 176, 0, 0.1); + border-radius: 50%; + padding: 1.5rem; + display: inline-block; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0% { + box-shadow: 0 0 0 0 rgba(56, 176, 0, 0.4); + } + 70% { + box-shadow: 0 0 0 15px rgba(56, 176, 0, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(56, 176, 0, 0); + } +} + +.confirmation-box h2 { + color: var(--text-color); + font-size: 2rem; + margin-bottom: 1.5rem; + font-weight: 700; +} + +/* Confirmation details card */ +.confirmation-details { + margin: 2rem 0; + background-color: var(--neutral-50); + border-radius: var(--border-radius); + box-shadow: var(--box-shadow-sm); + overflow: hidden; + border: 1px solid var(--border-color); +} + +/* Detail items as list group */ +.detail-item { + display: flex; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border-color); + transition: background-color var(--transition-fast); +} + +.detail-item:hover { + background-color: white; +} + +.detail-item:last-child { + border-bottom: none; +} + +.detail-label { + font-weight: 700; + width: 120px; + color: var(--secondary-color); + text-transform: uppercase; + font-size: 0.85rem; + letter-spacing: 0.05em; +} + +.detail-value { + flex: 1; + font-weight: 500; +} + +/* Confirmation messages */ +.confirmation-message { + margin: 1.5rem 0; + color: var(--text-color); + font-size: 1.2rem; + font-weight: 500; +} + +/* Action buttons container */ +.confirmation-actions { + margin-top: 2.5rem; + display: flex; + justify-content: center; + gap: 1.5rem; +} + +/* Responsive adjustments */ +@media (max-width: 992px) { + .competitions-list { + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + } +} + +@media (max-width: 768px) { + .tournament-header { + flex-direction: column; + } + + .tournament-meta { + flex-direction: column; + gap: 0.5rem; + } + + .competitions-list { + grid-template-columns: 1fr; + } + + .confirmation-box { + padding: 2rem 1.5rem; + } +} + +@media (max-width: 576px) { + .tournament-item { + padding: 1.25rem; + } + + .tournament-header h3 { + font-size: 1.4rem; + } + + .confirmation-actions { + flex-direction: column; + } + + .detail-item { + flex-direction: column; + } + + .detail-label { + width: 100%; + margin-bottom: 0.5rem; + } +} diff --git a/server/src/main/resources/static/css/variables.css b/server/src/main/resources/static/css/variables.css new file mode 100644 index 00000000..676a2d50 --- /dev/null +++ b/server/src/main/resources/static/css/variables.css @@ -0,0 +1,45 @@ +:root { + /* Modern color palette */ + --primary-color: #4361ee; + --primary-hover: #3a56d4; + --secondary-color: #7209b7; + --secondary-hover: #6008a0; + --accent-color: #f72585; + --text-color: #2b2d42; + --light-text: #6c757d; + --lighter-text: #adb5bd; + --border-color: #e9ecef; + --light-bg: #f8f9fa; + --container-bg: #fff; + --success-color: #38b000; + --warning-color: #ffbe0b; + --error-color: #d90429; + --info-color: #4cc9f0; + + /* Neutral shades for backgrounds */ + --neutral-50: #fafafa; + --neutral-100: #f5f5f5; + --neutral-200: #e5e5e5; + --neutral-300: #d4d4d4; + + /* Design tokens */ + --border-radius-sm: 0.25rem; + --border-radius: 0.5rem; + --border-radius-lg: 1rem; + --border-radius-xl: 1.5rem; + --box-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + --box-shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1); + --transition-fast: 150ms ease; + --transition: 300ms ease; + --transition-slow: 500ms ease; + + /* Spacing */ + --spacer: 1rem; + --spacer-1: 0.25rem; + --spacer-2: 0.5rem; + --spacer-3: 1rem; + --spacer-4: 1.5rem; + --spacer-5: 3rem; + --spacer-6: 4.5rem; +} From 05108d389c7a079ee347b847797dae776bdab375 Mon Sep 17 00:00:00 2001 From: stefan Date: Thu, 5 Jun 2025 18:20:43 +0200 Subject: [PATCH 11/13] Styling --- data/meldestelle.db | Bin 32768 -> 32768 bytes .../kotlin/at/mocode/email/EmailService.kt | 6 +- .../src/main/kotlin/at/mocode/model/Bewerb.kt | 2 +- .../main/kotlin/at/mocode/plugins/Database.kt | 10 +- .../at/mocode/repository/TurnierRepository.kt | 4 +- .../kotlin/at/mocode/routes/AdminRoutes.kt | 7 +- .../kotlin/at/mocode/routes/NennungRoutes.kt | 8 +- .../main/kotlin/at/mocode/tables/Tables.kt | 6 +- .../kotlin/at/mocode/views/LayoutTemplate.kt | 97 +++++++++--------- .../kotlin/at/mocode/views/NennungView.kt | 67 ++++++++---- .../src/main/resources/static/css/footer.css | 4 +- .../src/main/resources/static/css/forms.css | 20 ++++ .../main/resources/static/css/tournament.css | 60 +++++++++-- .../main/resources/static/css/variables.css | 2 +- 14 files changed, 190 insertions(+), 103 deletions(-) diff --git a/data/meldestelle.db b/data/meldestelle.db index 50b0e648fd75104fa0510afcd39a53b59bb90251..dff39983dd9bede97fb4bb9d82918872e61ebfc4 100644 GIT binary patch delta 138 zcmZo@U}|V!njp<6Fj2;tQD9?2VZ1XN0|NsuXI^SvUTI!>ybv?9CL@fKT9%quQq0E9 z$Slp6lv>z{Y=( jf&VA}NB);UWjFaRZWdJ7%g@2a#=^kJ&t^FJX1zK92kIr` delta 118 zcmZo@U}|V!njp={KT*b+k$+=CVZ0+N0|NsuXI^SvUTI!>ybv?9CL@fKT9%quQq0QD z$Slp6lvmb(tdP diff --git a/server/src/main/kotlin/at/mocode/email/EmailService.kt b/server/src/main/kotlin/at/mocode/email/EmailService.kt index 7641ecc4..6aa3c0ab 100644 --- a/server/src/main/kotlin/at/mocode/email/EmailService.kt +++ b/server/src/main/kotlin/at/mocode/email/EmailService.kt @@ -56,7 +56,7 @@ class EmailService private constructor( } /** - * Creates email session with the configured properties. + * Creates an email session with the configured properties. * * @param debug Whether to enable debug mode for the mail session * @return The configured mail session @@ -93,7 +93,7 @@ class EmailService private constructor( /** * Sends an email notification with the form submission data. - * Includes retry mechanism for transient failures. + * Includes a retry mechanism for transient failures. * * @param nennung The form submission data * @return true if the email was sent successfully, false otherwise @@ -123,7 +123,7 @@ class EmailService private constructor( subject = nennung.turnier?.let { "Neue Nennung für ${it.name}: ${nennung.riderName} mit ${nennung.horseName}" } ?: "Neue Nennung: ${nennung.riderName} mit ${nennung.horseName}" - // Create multipart message with both plain text and HTML versions + // Create a multipart message with both plain text and HTML versions val multipart = MimeMultipart("alternative") // Plain text part diff --git a/server/src/main/kotlin/at/mocode/model/Bewerb.kt b/server/src/main/kotlin/at/mocode/model/Bewerb.kt index 3d38288a..418a6384 100644 --- a/server/src/main/kotlin/at/mocode/model/Bewerb.kt +++ b/server/src/main/kotlin/at/mocode/model/Bewerb.kt @@ -8,7 +8,7 @@ import kotlinx.serialization.Serializable */ @Serializable data class Bewerb( - /** Competition number, e.g. 1, 2, etc. */ + /** Competition number, e.g., 1, 2, etc. */ val nummer: Int, /** Title of the competition, e.g. "Stilspringprüfung" or "Dressurprüfung" */ diff --git a/server/src/main/kotlin/at/mocode/plugins/Database.kt b/server/src/main/kotlin/at/mocode/plugins/Database.kt index afe0792f..1c56b2ce 100644 --- a/server/src/main/kotlin/at/mocode/plugins/Database.kt +++ b/server/src/main/kotlin/at/mocode/plugins/Database.kt @@ -38,7 +38,7 @@ fun configureDatabase() { } } else { // Check if we should use SQLite (either in IDEA or in Docker with SQLite) - // First check for explicit SQLite flag + // First check for an explicit SQLite flag val useSqlite = System.getenv("USE_SQLITE")?.toBoolean() ?: false // Then check if we're in IDEA (no Docker environment variables set) @@ -59,7 +59,7 @@ fun configureDatabase() { connectionSuccessful = true } catch (e: Exception) { log.error("Failed to connect to SQLite (dev)!", e) - // Maybe don't throw here so the app starts in IDE anyway? Currently it throws. + // Maybe don't throw here so the app starts in IDE anyway? Currently, it throws. throw e } } else { @@ -76,7 +76,7 @@ fun configureDatabase() { val maxPoolSize = System.getenv("DB_POOL_SIZE")?.toIntOrNull() ?: 10 val jdbcURL = "jdbc:postgresql://$dbHost:$dbPort/$dbName" - log.info("Attempting to connect to PostgreSQL at URL: {}", jdbcURL) + log.info("Attempting to connect to PostgresQL at URL: {}", jdbcURL) val hikariConfig = HikariConfig().apply { this.driverClassName = driverClassName @@ -88,7 +88,7 @@ fun configureDatabase() { } val dataSource = HikariDataSource(hikariConfig) Database.connect(dataSource) - log.info("PostgreSQL connection pool initialized successfully!") + log.info("PostgresQL connection pool initialized successfully!") connectionSuccessful = true } catch (e: Exception) { log.error("Failed to initialize PostgreSQL connection pool!", e) @@ -109,7 +109,7 @@ fun configureDatabase() { } catch (e: Exception) { log.error("Failed to initialize database schema!", e) // Here you could decide if a schema error is critical - // throw e // Commented out: App might start anyway, even if schema is missing/wrong + // throw e // Commented out: App might start anyway, even if the schema is missing/wrong } } } diff --git a/server/src/main/kotlin/at/mocode/repository/TurnierRepository.kt b/server/src/main/kotlin/at/mocode/repository/TurnierRepository.kt index 4367dab1..6874572c 100644 --- a/server/src/main/kotlin/at/mocode/repository/TurnierRepository.kt +++ b/server/src/main/kotlin/at/mocode/repository/TurnierRepository.kt @@ -27,7 +27,7 @@ class TurnierRepository { fun createTurnier(number: Int, name: String, datum: String, bewerbe: List): Turnier? = transaction { try { // Check if a tournament with this number already exists - val existingTurnier = TurniereTable.select { TurniereTable.number eq number }.singleOrNull() + val existingTurnier = TurniereTable.selectAll().where { TurniereTable.number eq number }.singleOrNull() if (existingTurnier != null) { log.error("Tournament with number $number already exists") return@transaction null @@ -75,7 +75,7 @@ class TurnierRepository { fun updateTurnier(number: Int, name: String, datum: String, bewerbe: List): Turnier? = transaction { try { // Check if the tournament exists - val existingTurnier = TurniereTable.select { TurniereTable.number eq number }.singleOrNull() + val existingTurnier = TurniereTable.selectAll().where { TurniereTable.number eq number }.singleOrNull() if (existingTurnier == null) { log.error("Tournament with number $number not found") return@transaction null diff --git a/server/src/main/kotlin/at/mocode/routes/AdminRoutes.kt b/server/src/main/kotlin/at/mocode/routes/AdminRoutes.kt index 9e15d2b7..3908828a 100644 --- a/server/src/main/kotlin/at/mocode/routes/AdminRoutes.kt +++ b/server/src/main/kotlin/at/mocode/routes/AdminRoutes.kt @@ -1,8 +1,7 @@ package at.mocode.routes -import at.mocode.model.Bewerb import at.mocode.config.DependencyInjection -import at.mocode.repository.TurnierRepository +import at.mocode.model.Bewerb import at.mocode.views.AdminView import io.ktor.http.* import io.ktor.server.application.* @@ -75,7 +74,7 @@ fun Route.adminRoutes() { log.info("Received competitions for creation: ${bewerbNummern.size} numbers, ${bewerbTitel.size} titles, ${bewerbKlasse.size} classes, ${bewerbTask.size} tasks") - // Create list of competitions + // Create a list of competitions val bewerbe = mutableListOf() for (i in bewerbNummern.indices) { val titel = if (i < bewerbTitel.size) bewerbTitel[i] else "Unbenannter Bewerb" @@ -133,7 +132,7 @@ fun Route.adminRoutes() { log.info("Received competitions for update: ${bewerbNummern.size} numbers, ${bewerbTitel.size} titles, ${bewerbKlasse.size} classes, ${bewerbTask.size} tasks") - // Create list of competitions + // Create a list of competitions val bewerbe = mutableListOf() for (i in bewerbNummern.indices) { val titel = if (i < bewerbTitel.size) bewerbTitel[i] else "Unbenannter Bewerb" diff --git a/server/src/main/kotlin/at/mocode/routes/NennungRoutes.kt b/server/src/main/kotlin/at/mocode/routes/NennungRoutes.kt index ef44f274..a92dff93 100644 --- a/server/src/main/kotlin/at/mocode/routes/NennungRoutes.kt +++ b/server/src/main/kotlin/at/mocode/routes/NennungRoutes.kt @@ -77,6 +77,12 @@ fun Route.nennungRoutes() { return@post } + // Validate that at least one competition is selected + if (selectedEvents.isEmpty()) { + call.respond(HttpStatusCode.BadRequest, "Bitte wählen Sie mindestens einen Bewerb aus") + return@post + } + // Create Nennung object using repository val nennung = nennungRepository.createNennung( riderName = riderName, @@ -105,7 +111,7 @@ fun Route.nennungRoutes() { } // Render confirmation page - nennungView.renderConfirmationPage(call, turnier, riderName, horseName) + nennungView.renderConfirmationPage(call, turnier, riderName, horseName, selectedEvents) } } diff --git a/server/src/main/kotlin/at/mocode/tables/Tables.kt b/server/src/main/kotlin/at/mocode/tables/Tables.kt index db3e2d2d..780f0a33 100644 --- a/server/src/main/kotlin/at/mocode/tables/Tables.kt +++ b/server/src/main/kotlin/at/mocode/tables/Tables.kt @@ -8,7 +8,7 @@ import org.jetbrains.exposed.sql.Column */ object TurniereTable : Table("turniere") { /** - * Unique number for the tournament, used as primary key. + * Unique number for the tournament, used as a primary key. */ val number: Column = integer("number").uniqueIndex() @@ -36,7 +36,7 @@ object BewerbeTable : Table("bewerbe") { val id: Column = integer("id").autoIncrement() /** - * Number of the competition. + * Amount of the competition. */ val nummer: Column = integer("nummer") @@ -131,7 +131,7 @@ object NennungEventsTable : Table("nennung_events") { val nennungId: Column = integer("nennung_id") /** - * Number of the selected competition. + * Amount of the selected competition. */ val eventNumber: Column = varchar("event_number", 100) diff --git a/server/src/main/kotlin/at/mocode/views/LayoutTemplate.kt b/server/src/main/kotlin/at/mocode/views/LayoutTemplate.kt index f5b18dfe..0a275c9d 100644 --- a/server/src/main/kotlin/at/mocode/views/LayoutTemplate.kt +++ b/server/src/main/kotlin/at/mocode/views/LayoutTemplate.kt @@ -18,6 +18,7 @@ class LayoutTemplate { title: String, showNavbar: Boolean = true, showAdminLink: Boolean = true, + showFooter: Boolean = true, content: FlowContent.() -> Unit ) { head { @@ -152,61 +153,63 @@ class LayoutTemplate { } } } - footer(classes = "footer mt-5") { - attributes["data-aos"] = "fade-up" - attributes["data-aos-delay"] = "200" - div("container") { - div("footer-content") { - div("row gy-4") { - div("col-lg-4 col-md-6") { - div("footer-info") { - h3(classes = "gradient-text") { +"Meldestelle Portal" } - p { - +"Ihre zentrale Plattform für Turnierorganisation und Anmeldungen." - } - div("social-links mt-3") { - a(href = "#", classes = "facebook") { i("fab fa-facebook-f") {} } - a(href = "#", classes = "twitter") { i("fab fa-twitter") {} } - a(href = "#", classes = "instagram") { i("fab fa-instagram") {} } - a(href = "#", classes = "linkedin") { i("fab fa-linkedin-in") {} } + if (showFooter) { + footer(classes = "footer mt-5") { + attributes["data-aos"] = "fade-up" + attributes["data-aos-delay"] = "200" + div("container") { + div("footer-content") { + div("row gy-4") { + div("col-lg-4 col-md-6") { + div("footer-info") { + h3(classes = "gradient-text") { +"Meldestelle Portal" } + p { + +"Ihre zentrale Plattform für Turnierorganisation und Anmeldungen." + } + div("social-links mt-3") { + a(href = "#", classes = "facebook") { i("fab fa-facebook-f") {} } + a(href = "#", classes = "twitter") { i("fab fa-twitter") {} } + a(href = "#", classes = "instagram") { i("fab fa-instagram") {} } + a(href = "#", classes = "linkedin") { i("fab fa-linkedin-in") {} } + } } } - } - div("col-lg-4 col-md-6") { - div("footer-links") { - h4 { +"Nützliche Links" } - ul { - li { a(href = "/") { +"Home" } } - li { a(href = "#") { +"Über uns" } } - li { a(href = "#") { +"Turniere" } } - li { a(href = "#") { +"Kontakt" } } + div("col-lg-4 col-md-6") { + div("footer-links") { + h4 { +"Nützliche Links" } + ul { + li { a(href = "/") { +"Home" } } + li { a(href = "#") { +"Über uns" } } + li { a(href = "#") { +"Turniere" } } + li { a(href = "#") { +"Kontakt" } } + } } } - } - div("col-lg-4 col-md-6") { - div("footer-contact") { - h4 { +"Kontakt" } - p { - i("fas fa-envelope me-2") {} - +"info@meldestelle-portal.at" - } - p { - i("fas fa-phone me-2") {} - +"+43 123 456 789" + div("col-lg-4 col-md-6") { + div("footer-contact") { + h4 { +"Kontakt" } + p { + i("fas fa-envelope me-2") {} + +"info@meldestelle-portal.at" + } + p { + i("fas fa-phone me-2") {} + +"+43 123 456 789" + } } } } } - } - div("footer-legal text-center") { - div("copyright") { - +"© ${java.time.Year.now().value} " - strong { +"Meldestelle Portal" } - +". Alle Rechte vorbehalten." - } - div("credits") { - +"Entwickelt von " - a(href = "#") { +"mocode" } + div("footer-legal text-center") { + div("copyright") { + +"© ${java.time.Year.now().value} " + strong { +"Meldestelle Portal" } + +". Alle Rechte vorbehalten." + } + div("credits") { + +"Entwickelt von " + a(href = "#") { +"mocode" } + } } } } diff --git a/server/src/main/kotlin/at/mocode/views/NennungView.kt b/server/src/main/kotlin/at/mocode/views/NennungView.kt index e526907c..b8f29903 100644 --- a/server/src/main/kotlin/at/mocode/views/NennungView.kt +++ b/server/src/main/kotlin/at/mocode/views/NennungView.kt @@ -23,7 +23,8 @@ class NennungView { applyLayout( title = "Online-Nennen - ${turnier.name}", showNavbar = false, - showAdminLink = false + showAdminLink = false, + showFooter = false ) { h1 { +"Online-Nennen" } @@ -44,7 +45,7 @@ class NennungView { div(classes = "competition-item") { div(classes = "participant-details") { label(classes = "required") { +"Reiter-Name" } - input(type = InputType.text, name = "riderName") { + input(type = InputType.text, name = "riderName", classes = "form-control") { attributes["required"] = "required" attributes["placeholder"] = "Vor- und Nachname" } @@ -55,7 +56,7 @@ class NennungView { div(classes = "competition-item") { div(classes = "participant-details") { label(classes = "required") { +"Kopf-Nr./Pferd" } - input(type = InputType.text, name = "horseName") { + input(type = InputType.text, name = "horseName", classes = "form-control") { attributes["required"] = "required" attributes["placeholder"] = "Name des Pferdes" } @@ -65,12 +66,11 @@ class NennungView { // Contact information div(classes = "competition-item") { div(classes = "participant-details") { - div(classes = "form-row") { - div(classes = "form-group form-group-half") { - label { +"E-Mail" } - input(type = InputType.email, name = "email") { - attributes["placeholder"] = "ihre@email.com" - } + div(classes = "form-group") { + label { +"E-Mail" } + input(type = InputType.email, name = "email") { + attributes["placeholder"] = "ihre@email.com" + attributes["class"] = "form-control" } } } @@ -79,12 +79,11 @@ class NennungView { // Contact information div(classes = "competition-item") { div(classes = "participant-details") { - div(classes = "form-row") { - div(classes = "form-group form-group-half") { - label { +"Telefon-Nr." } - input(type = InputType.tel, name = "phone") { - attributes["placeholder"] = "Ihre Telefonnummer" - } + div(classes = "form-group") { + label { +"Telefon-Nr." } + input(type = InputType.tel, name = "phone") { + attributes["placeholder"] = "Ihre Telefonnummer" + attributes["class"] = "form-control" } } } @@ -104,8 +103,8 @@ class NennungView { div(classes = "competitions-list") { turnier.bewerbe.forEach { bewerb -> div(classes = "competition-item") { - label { - input(type = InputType.checkBox, name = "selectedEvents") { + label(classes = "form-check") { + input(type = InputType.checkBox, name = "selectedEvents", classes = "form-check-input") { attributes["value"] = bewerb.nummer.toString() } span(classes = "competition-details") { @@ -128,7 +127,7 @@ class NennungView { div(classes = "form-group") { label { +"Wünsche/Bemerkungen" } - textArea { + textArea(classes = "form-control") { attributes["rows"] = "4" attributes["name"] = "comments" attributes["placeholder"] = "Ihre Wünsche oder Bemerkungen zur Nennung..." @@ -137,7 +136,8 @@ class NennungView { } // Submit button - div(classes = "form-actions text-center mt-4") { + div(classes = "form-actions mt-4") { + attributes["style"] = "justify-content: center;" button(type = ButtonType.submit, classes = "button") { +"Jetzt Nennen" } @@ -155,16 +155,17 @@ class NennungView { * @param turnier The tournament the registration was for * @param riderName The name of the rider * @param horseName The name of the horse + * @param selectedEvents The list of selected competition IDs */ - suspend fun renderConfirmationPage(call: ApplicationCall, turnier: Turnier, riderName: String, horseName: String) { + suspend fun renderConfirmationPage(call: ApplicationCall, turnier: Turnier, riderName: String, horseName: String, selectedEvents: List) { call.respondHtml(HttpStatusCode.OK) { layoutTemplate.apply { applyLayout( title = "Nennung bestätigt - ${turnier.name}", showNavbar = false, - showAdminLink = false + showAdminLink = false, + showFooter = false ) { - h1 { +"Nennung bestätigt" } div(classes = "confirmation-box") { div(classes = "confirmation-icon") { @@ -189,6 +190,28 @@ class NennungView { span(classes = "detail-label") { +"Pferd:" } span(classes = "detail-value") { +horseName } } + + // Display selected competitions + if (selectedEvents.isNotEmpty()) { + div(classes = "detail-item mt-3") { + span(classes = "detail-label") { +"Ausgewählte Bewerbe:" } + div(classes = "selected-competitions") { + ul(classes = "competition-list") { + selectedEvents.forEach { eventId -> + val bewerb = turnier.bewerbe.find { it.nummer.toString() == eventId } + if (bewerb != null) { + li { + +"${bewerb.nummer}. ${bewerb.titel} - ${bewerb.klasse}" + if (bewerb.task != null) { + +" - ${bewerb.task}" + } + } + } + } + } + } + } + } } p(classes = "confirmation-message") { diff --git a/server/src/main/resources/static/css/footer.css b/server/src/main/resources/static/css/footer.css index ae97138e..979f9cea 100644 --- a/server/src/main/resources/static/css/footer.css +++ b/server/src/main/resources/static/css/footer.css @@ -15,9 +15,7 @@ left: 0; width: 100%; height: 100%; - background: url('data:image/svg+xml;utf8,'); - background-repeat: no-repeat; - background-position: bottom; + background: url('data:image/svg+xml;utf8,') no-repeat bottom; background-size: cover; opacity: 0.1; z-index: 0; diff --git a/server/src/main/resources/static/css/forms.css b/server/src/main/resources/static/css/forms.css index 6e7c9f38..a0934dc1 100644 --- a/server/src/main/resources/static/css/forms.css +++ b/server/src/main/resources/static/css/forms.css @@ -250,6 +250,8 @@ color: var(--primary-color); font-size: 1.25rem; font-weight: 600; + font-family: 'Inter', 'Roboto', 'Segoe UI', system-ui, -apple-system, sans-serif; + line-height: 1.3; } /* Custom form action buttons container */ @@ -354,6 +356,24 @@ } } +/* Selected competitions list in confirmation page */ +.selected-competitions ul.competition-list { + list-style: none; + padding-left: 0; + margin-top: 0.5rem; +} + +.selected-competitions ul.competition-list li { + padding: 0.5rem 0; + border-bottom: 1px dashed var(--border-color); + margin-bottom: 0.5rem; +} + +.selected-competitions ul.competition-list li:last-child { + border-bottom: none; + margin-bottom: 0; +} + @media (max-width: 480px) { .form-section { padding: 1.25rem; diff --git a/server/src/main/resources/static/css/tournament.css b/server/src/main/resources/static/css/tournament.css index c458b351..009d524c 100644 --- a/server/src/main/resources/static/css/tournament.css +++ b/server/src/main/resources/static/css/tournament.css @@ -146,10 +146,9 @@ } .tournament-competitions li { - padding: 0.5rem 0; border-bottom: 1px dashed var(--border-color); transition: transform var(--transition-fast); - padding-left: 1.5rem; + padding: 0.5rem 0 0.5rem 1.5rem; position: relative; } @@ -244,14 +243,14 @@ .competitions-list { width: 100%; margin: 0; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); - gap: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.75rem; } /* Competition item */ .competition-item { - padding: 1.25rem; + padding: 0.75rem; border-radius: var(--border-radius); transition: all var(--transition-fast); border: 1px solid var(--border-color); @@ -259,7 +258,8 @@ box-shadow: var(--box-shadow-sm); height: 100%; display: flex; - flex-direction: column; + flex-direction: row; + align-items: center; } .competition-item:hover { @@ -272,43 +272,81 @@ /* Custom form check styling */ .competition-item .form-check { display: flex; - align-items: flex-start; + align-items: center; padding-left: 0; margin-bottom: 0; height: 100%; + width: 100%; } .competition-item .form-check-input { margin-right: 1rem; - margin-top: 0.25rem; float: none; flex-shrink: 0; width: 1.25rem; height: 1.25rem; cursor: pointer; + border: 2px solid var(--primary-color); + border-radius: 0.25rem; + transition: all var(--transition-fast); + position: relative; + background-color: white; +} + +.competition-item .form-check-input:checked { + background-color: var(--primary-color); + border-color: var(--primary-color); + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/%3e%3c/svg%3e"); + background-position: center; + background-repeat: no-repeat; + background-size: 75%; +} + +.competition-item .form-check-input:focus { + outline: 0; + box-shadow: 0 0 0 0.25rem rgba(67, 97, 238, 0.25); + border-color: var(--primary-hover); +} + +.competition-item .form-check-input:hover { + border-color: var(--primary-hover); } .competition-details { flex-grow: 1; + font-family: 'Inter', 'Roboto', 'Segoe UI', system-ui, -apple-system, sans-serif; + color: var(--text-color); + line-height: 1.5; + font-size: 1rem; } .competition-details strong { color: var(--primary-color); font-weight: 700; font-size: 1.1rem; - display: block; - margin-bottom: 0.5rem; + display: inline; + margin-right: 0.25rem; } /* Participant details section */ .participant-details { width: 100%; + font-family: 'Inter', 'Roboto', 'Segoe UI', system-ui, -apple-system, sans-serif; } .participant-details .form-label { font-weight: 600; margin-bottom: 0.5rem; color: var(--text-color); + font-size: 1rem; + line-height: 1.5; +} + +.participant-details input, +.participant-details textarea { + font-family: 'Inter', 'Roboto', 'Segoe UI', system-ui, -apple-system, sans-serif; + color: var(--text-color); + font-size: 1rem; } /* Custom form spacing */ diff --git a/server/src/main/resources/static/css/variables.css b/server/src/main/resources/static/css/variables.css index 676a2d50..e451942e 100644 --- a/server/src/main/resources/static/css/variables.css +++ b/server/src/main/resources/static/css/variables.css @@ -5,7 +5,7 @@ --secondary-color: #7209b7; --secondary-hover: #6008a0; --accent-color: #f72585; - --text-color: #2b2d42; + --text-color: #555555; --light-text: #6c757d; --lighter-text: #adb5bd; --border-color: #e9ecef; From a35798c03346647333e8254434cd93871072df84 Mon Sep 17 00:00:00 2001 From: stefan Date: Thu, 5 Jun 2025 22:55:39 +0200 Subject: [PATCH 12/13] fix roter valid-error-effekt --- data/meldestelle.db | Bin 32768 -> 32768 bytes .../kotlin/at/mocode/views/NennungView.kt | 154 ++++++++++++++++-- server/src/main/resources/static/css/base.css | 2 +- .../src/main/resources/static/css/buttons.css | 14 +- .../src/main/resources/static/css/forms.css | 30 ++-- .../main/resources/static/css/navigation.css | 22 +-- .../src/main/resources/static/css/tables.css | 10 +- .../main/resources/static/css/tournament.css | 38 +++-- .../main/resources/static/css/variables.css | 38 ++--- 9 files changed, 221 insertions(+), 87 deletions(-) diff --git a/data/meldestelle.db b/data/meldestelle.db index dff39983dd9bede97fb4bb9d82918872e61ebfc4..42d34c7fad7e68cc7c1a2927f1d8c56e4d55abf4 100644 GIT binary patch delta 378 zcmZo@U}|V!njp<6I#I@%QFLQMVZ1Ll0|NsuXI^SvUTI!>ybv?9CL@fKT9%quQq0ZG z$Slp6lvp`z z7PaK%<@8Og2uUq2DK1G(OU%>D&DTrLcSz4o%*+8Ya@Dwi$`rMEd6|I9d`p3figFY4 z@*s*Gz>4(}OOjrwa6;9AjKir;g#)I_39mACm@<5-CQIkPl;Y-J$-w`U|0DlPpl5IL zU*tc@e~^DC|3?0mn*|lR`8C+MSr{1kxeeL4Ks1*j8z+e7G-Trd(Hw?s>>!%maPq}^ FeE=!=X}SOa delta 122 zcmZo@U}|V!njp<6Fj2;tQD9?2VZ1XN0|NsuXI^SvUTI!>ybv?9CL@fKT9%quQq0E9 z$Slp6lv div(classes = "competition-item") { label(classes = "form-check") { - input(type = InputType.checkBox, name = "selectedEvents", classes = "form-check-input") { + input( + type = InputType.checkBox, + name = "selectedEvents", + classes = "form-check-input" + ) { attributes["value"] = bewerb.nummer.toString() } span(classes = "competition-details") { @@ -157,7 +277,13 @@ class NennungView { * @param horseName The name of the horse * @param selectedEvents The list of selected competition IDs */ - suspend fun renderConfirmationPage(call: ApplicationCall, turnier: Turnier, riderName: String, horseName: String, selectedEvents: List) { + suspend fun renderConfirmationPage( + call: ApplicationCall, + turnier: Turnier, + riderName: String, + horseName: String, + selectedEvents: List + ) { call.respondHtml(HttpStatusCode.OK) { layoutTemplate.apply { applyLayout( @@ -175,11 +301,11 @@ class NennungView { h2 { +"Vielen Dank für Ihre Nennung!" } div(classes = "confirmation-details") { - p { - +"Ihre Nennung für " - strong { +turnier.name } - +" wurde erfolgreich übermittelt." - } + p(classes = "confirmation-message") { +"Ihre Nennung für " } + + p(classes = "confirmation-message") { strong { +turnier.name } } + + p(classes = "confirmation-message") { +" wurde erfolgreich übermittelt." } div(classes = "detail-item") { span(classes = "detail-label") { +"Reiter:" } @@ -200,8 +326,8 @@ class NennungView { selectedEvents.forEach { eventId -> val bewerb = turnier.bewerbe.find { it.nummer.toString() == eventId } if (bewerb != null) { - li { - +"${bewerb.nummer}. ${bewerb.titel} - ${bewerb.klasse}" + span(classes = "detail-value") { + +"${bewerb.nummer} . ${bewerb.titel} - ${bewerb.klasse}" if (bewerb.task != null) { +" - ${bewerb.task}" } diff --git a/server/src/main/resources/static/css/base.css b/server/src/main/resources/static/css/base.css index 561b2b99..f5e5dd35 100644 --- a/server/src/main/resources/static/css/base.css +++ b/server/src/main/resources/static/css/base.css @@ -1,6 +1,6 @@ /* Base styles - Modern and accessible */ body { - font-family: 'Inter', 'Roboto', 'Segoe UI', system-ui, -apple-system, sans-serif; + font-family: 'Helvetica', 'Arial', sans-serif; color: var(--text-color); background-color: var(--neutral-50); line-height: 1.7; diff --git a/server/src/main/resources/static/css/buttons.css b/server/src/main/resources/static/css/buttons.css index 36d759a6..6f0691ff 100644 --- a/server/src/main/resources/static/css/buttons.css +++ b/server/src/main/resources/static/css/buttons.css @@ -25,21 +25,21 @@ /* Primary button */ .btn-primary, .button { background-color: var(--primary-color); - color: white; + color: var(--container-bg); box-shadow: var(--box-shadow-sm); } /* Secondary button */ .btn-secondary, .button-secondary { background-color: var(--secondary-color); - color: white; + color: var(--container-bg); box-shadow: var(--box-shadow-sm); } /* Accent button */ .btn-accent, .button-accent { background-color: var(--accent-color); - color: white; + color: var(--container-bg); box-shadow: var(--box-shadow-sm); } @@ -81,7 +81,7 @@ .btn:focus, .button:focus { outline: none; - box-shadow: 0 0 0 3px rgba(67, 97, 238, 0.3); + box-shadow: 0 0 0 3px rgba(43, 103, 119, 0.3); /* Light version of primary color #2b6777 */ } /* Ripple effect for buttons */ @@ -93,7 +93,7 @@ top: 0; left: 0; pointer-events: none; - background-image: radial-gradient(circle, #fff 10%, transparent 10.01%); + background-image: radial-gradient(circle, var(--container-bg) 10%, transparent 10.01%); background-repeat: no-repeat; background-position: 50%; transform: scale(10, 10); @@ -175,9 +175,9 @@ position: absolute; width: 1rem; height: 1rem; - border: 2px solid rgba(255, 255, 255, 0.3); + border: 2px solid rgba(255, 255, 255, 0.3); /* Semi-transparent border */ border-radius: 50%; - border-top-color: white; + border-top-color: var(--container-bg); animation: spin 0.8s linear infinite; } diff --git a/server/src/main/resources/static/css/forms.css b/server/src/main/resources/static/css/forms.css index a0934dc1..d39dac36 100644 --- a/server/src/main/resources/static/css/forms.css +++ b/server/src/main/resources/static/css/forms.css @@ -68,7 +68,7 @@ font-weight: 400; line-height: 1.5; color: var(--text-color); - background-color: #fff; + background-color: var(--container-bg); background-clip: padding-box; border: 1px solid var(--border-color); border-radius: var(--border-radius); @@ -78,7 +78,7 @@ .form-control:focus { border-color: var(--primary-color); outline: 0; - box-shadow: 0 0 0 0.25rem rgba(67, 97, 238, 0.25); + box-shadow: 0 0 0 0.25rem rgba(43, 103, 119, 0.25); /* Light version of primary color #2b6777 */ } .form-control::placeholder { @@ -95,8 +95,8 @@ font-weight: 400; line-height: 1.5; color: var(--text-color); - background-color: #fff; - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"); + background-color: var(--container-bg); + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%232b6777' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"); background-repeat: no-repeat; background-position: right 1rem center; background-size: 16px 12px; @@ -109,7 +109,7 @@ .form-select:focus { border-color: var(--primary-color); outline: 0; - box-shadow: 0 0 0 0.25rem rgba(67, 97, 238, 0.25); + box-shadow: 0 0 0 0.25rem rgba(43, 103, 119, 0.25); /* Light version of primary color #2b6777 */ } /* Checkbox and radio styling */ @@ -126,7 +126,7 @@ margin-top: 0.125em; margin-left: -1.75em; vertical-align: top; - background-color: #fff; + background-color: var(--container-bg); background-repeat: no-repeat; background-position: center; background-size: contain; @@ -149,17 +149,17 @@ } .form-check-input:checked[type="checkbox"] { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/%3e%3c/svg%3e"); + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23ffffff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/%3e%3c/svg%3e"); } .form-check-input:checked[type="radio"] { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e"); + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23ffffff'/%3e%3c/svg%3e"); } .form-check-input:focus { border-color: var(--primary-color); outline: 0; - box-shadow: 0 0 0 0.25rem rgba(67, 97, 238, 0.25); + box-shadow: 0 0 0 0.25rem rgba(43, 103, 119, 0.25); /* Light version of primary color #2b6777 */ } /* Switch styling */ @@ -170,7 +170,7 @@ .form-switch .form-check-input { width: 2em; margin-left: -2.5em; - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e"); + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%2843, 103, 119, 0.25%29'/%3e%3c/svg%3e"); /* Using primary color with transparency */ background-position: left center; border-radius: 2em; transition: background-position 0.15s ease-in-out; @@ -178,7 +178,7 @@ .form-switch .form-check-input:checked { background-position: right center; - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e"); + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23ffffff'/%3e%3c/svg%3e"); } /* Required field indicator */ @@ -192,7 +192,7 @@ .was-validated .form-control:valid, .form-control.is-valid { border-color: var(--success-color); padding-right: calc(1.5em + 0.75rem); - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2338b000' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e"); + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2352ab98' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e"); /* Using success color #52ab98 */ background-repeat: no-repeat; background-position: right calc(0.375em + 0.1875rem) center; background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); @@ -201,7 +201,7 @@ .was-validated .form-control:invalid, .form-control.is-invalid { border-color: var(--error-color); padding-right: calc(1.5em + 0.75rem); - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23d90429'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23d90429' stroke='none'/%3e%3c/svg%3e"); + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%232b6777'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%232b6777' stroke='none'/%3e%3c/svg%3e"); /* Using error color #2b6777 */ background-repeat: no-repeat; background-position: right calc(0.375em + 0.1875rem) center; background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); @@ -250,7 +250,7 @@ color: var(--primary-color); font-size: 1.25rem; font-weight: 600; - font-family: 'Inter', 'Roboto', 'Segoe UI', system-ui, -apple-system, sans-serif; + font-family: 'Helvetica', 'Arial', sans-serif; line-height: 1.3; } @@ -306,7 +306,7 @@ } .form-range:focus::-webkit-slider-thumb { - box-shadow: 0 0 0 1px #fff, 0 0 0 0.25rem rgba(67, 97, 238, 0.25); + box-shadow: 0 0 0 1px var(--container-bg), 0 0 0 0.25rem rgba(43, 103, 119, 0.25); /* Light version of primary color #2b6777 */ } .form-range::-webkit-slider-thumb { diff --git a/server/src/main/resources/static/css/navigation.css b/server/src/main/resources/static/css/navigation.css index c28a5d7c..d69b46ef 100644 --- a/server/src/main/resources/static/css/navigation.css +++ b/server/src/main/resources/static/css/navigation.css @@ -14,14 +14,14 @@ padding: 0.5rem 0; box-shadow: var(--box-shadow-lg); backdrop-filter: blur(10px); - background-color: rgba(255, 255, 255, 0.95); + background-color: rgba(255, 255, 255, 0.95); /* Using rgba for transparency */ } /* Enhanced navbar brand */ .navbar-brand { font-weight: 700; font-size: 1.5rem; - color: white; + color: var(--container-bg); display: flex; align-items: center; letter-spacing: -0.01em; @@ -30,7 +30,7 @@ } .navbar-brand:hover { - color: rgba(255, 255, 255, 0.9); + color: rgba(255, 255, 255, 0.9); /* Using rgba for hover effect */ } .navbar-brand i { @@ -71,13 +71,13 @@ .nav-link { font-weight: 600; padding: 0.5rem 1rem; - color: rgba(255, 255, 255, 0.85); + color: rgba(255, 255, 255, 0.85); /* Using rgba for transparency */ position: relative; transition: color var(--transition-fast); } .nav-link:hover, .nav-link:focus { - color: white; + color: var(--container-bg); } /* Animated underline effect */ @@ -88,7 +88,7 @@ height: 2px; bottom: 0; left: 50%; - background-color: white; + background-color: var(--container-bg); transition: all var(--transition); transform: translateX(-50%); opacity: 0; @@ -102,7 +102,7 @@ } .nav-link.active { - color: white; + color: var(--container-bg); font-weight: 700; } @@ -139,7 +139,7 @@ .dropdown-item.active { background-color: var(--primary-color); - color: white; + color: var(--container-bg); } .dropdown-item i { @@ -148,7 +148,7 @@ } .dropdown-item.active i { - color: white; + color: var(--container-bg); } @keyframes fadeIn { @@ -158,7 +158,7 @@ /* Navbar color variants */ .navbar-light { - background-color: white; + background-color: var(--container-bg); } .navbar-light .navbar-brand, @@ -233,7 +233,7 @@ } .navbar-light .navbar-collapse { - background-color: white; + background-color: var(--container-bg); } .navbar-nav { diff --git a/server/src/main/resources/static/css/tables.css b/server/src/main/resources/static/css/tables.css index d40e3410..f5a93d1a 100644 --- a/server/src/main/resources/static/css/tables.css +++ b/server/src/main/resources/static/css/tables.css @@ -52,7 +52,7 @@ } .table-hover tbody tr:hover { - background-color: rgba(67, 97, 238, 0.05); + background-color: rgba(43, 103, 119, 0.05); /* Light version of primary color #2b6777 */ transform: translateY(-2px); box-shadow: var(--box-shadow-sm); } @@ -82,7 +82,7 @@ .table-modern thead th { background-color: var(--primary-color); - color: white; + color: var(--container-bg); border-bottom: none; } @@ -128,7 +128,7 @@ border-radius: var(--border-radius); margin-bottom: 1rem; box-shadow: var(--box-shadow-sm); - background-color: white; + background-color: var(--container-bg); } .table-responsive-card td { @@ -214,7 +214,7 @@ .list-modern li { padding: 1rem; margin-bottom: 0.5rem; - background-color: white; + background-color: var(--container-bg); border-radius: var(--border-radius); box-shadow: var(--box-shadow-sm); transition: all var(--transition-fast); @@ -243,7 +243,7 @@ } .competition-list li:hover { - background-color: white; + background-color: var(--container-bg); box-shadow: var(--box-shadow-sm); transform: translateX(3px); } diff --git a/server/src/main/resources/static/css/tournament.css b/server/src/main/resources/static/css/tournament.css index 009d524c..c69583f3 100644 --- a/server/src/main/resources/static/css/tournament.css +++ b/server/src/main/resources/static/css/tournament.css @@ -13,7 +13,7 @@ transition: all var(--transition); margin-bottom: 2rem; overflow: hidden; - background-color: white; + background-color: var(--container-bg); border: 1px solid var(--border-color); box-shadow: var(--box-shadow); position: relative; @@ -94,7 +94,7 @@ height: 1.5rem; text-align: center; margin-right: 0.5rem; - background-color: rgba(67, 97, 238, 0.1); + background-color: rgba(43, 103, 119, 0.1); /* Light version of primary color #2b6777 */ border-radius: 50%; display: flex; align-items: center; @@ -254,7 +254,7 @@ border-radius: var(--border-radius); transition: all var(--transition-fast); border: 1px solid var(--border-color); - background-color: white; + background-color: var(--container-bg); box-shadow: var(--box-shadow-sm); height: 100%; display: flex; @@ -263,12 +263,20 @@ } .competition-item:hover { - background-color: rgba(67, 97, 238, 0.03); + background-color: rgba(43, 103, 119, 0.03); /* Light version of primary color #2b6777 */ box-shadow: var(--box-shadow); transform: translateY(-5px); border-color: var(--primary-color); } +/* Disable hover effect for specific fields */ +.competition-item.no-hover:hover { + transform: none; + box-shadow: var(--box-shadow-sm); + border-color: var(--border-color); + background-color: var(--container-bg); +} + /* Custom form check styling */ .competition-item .form-check { display: flex; @@ -290,7 +298,7 @@ border-radius: 0.25rem; transition: all var(--transition-fast); position: relative; - background-color: white; + background-color: var(--container-bg); } .competition-item .form-check-input:checked { @@ -304,7 +312,7 @@ .competition-item .form-check-input:focus { outline: 0; - box-shadow: 0 0 0 0.25rem rgba(67, 97, 238, 0.25); + box-shadow: 0 0 0 0.25rem rgba(43, 103, 119, 0.25); /* Light version of primary color #2b6777 */ border-color: var(--primary-hover); } @@ -314,7 +322,7 @@ .competition-details { flex-grow: 1; - font-family: 'Inter', 'Roboto', 'Segoe UI', system-ui, -apple-system, sans-serif; + font-family: 'Helvetica', 'Arial', sans-serif; color: var(--text-color); line-height: 1.5; font-size: 1rem; @@ -331,7 +339,7 @@ /* Participant details section */ .participant-details { width: 100%; - font-family: 'Inter', 'Roboto', 'Segoe UI', system-ui, -apple-system, sans-serif; + font-family: 'Helvetica', 'Arial', sans-serif; } .participant-details .form-label { @@ -344,7 +352,7 @@ .participant-details input, .participant-details textarea { - font-family: 'Inter', 'Roboto', 'Segoe UI', system-ui, -apple-system, sans-serif; + font-family: 'Helvetica', 'Arial', sans-serif; color: var(--text-color); font-size: 1rem; } @@ -359,7 +367,7 @@ max-width: 700px; margin: 0 auto; text-align: center; - background-color: white; + background-color: var(--container-bg); border-radius: var(--border-radius-lg); padding: 3rem 2rem; box-shadow: var(--box-shadow); @@ -387,7 +395,7 @@ } .confirmation-icon i { - background-color: rgba(56, 176, 0, 0.1); + background-color: rgba(82, 171, 152, 0.1); /* Light version of success color #52ab98 */ border-radius: 50%; padding: 1.5rem; display: inline-block; @@ -396,13 +404,13 @@ @keyframes pulse { 0% { - box-shadow: 0 0 0 0 rgba(56, 176, 0, 0.4); + box-shadow: 0 0 0 0 rgba(82, 171, 152, 0.4); /* Success color #52ab98 with opacity */ } 70% { - box-shadow: 0 0 0 15px rgba(56, 176, 0, 0); + box-shadow: 0 0 0 15px rgba(82, 171, 152, 0); /* Success color #52ab98 with zero opacity */ } 100% { - box-shadow: 0 0 0 0 rgba(56, 176, 0, 0); + box-shadow: 0 0 0 0 rgba(82, 171, 152, 0); /* Success color #52ab98 with zero opacity */ } } @@ -432,7 +440,7 @@ } .detail-item:hover { - background-color: white; + background-color: var(--container-bg); } .detail-item:last-child { diff --git a/server/src/main/resources/static/css/variables.css b/server/src/main/resources/static/css/variables.css index e451942e..ca5f7742 100644 --- a/server/src/main/resources/static/css/variables.css +++ b/server/src/main/resources/static/css/variables.css @@ -1,26 +1,26 @@ :root { /* Modern color palette */ - --primary-color: #4361ee; - --primary-hover: #3a56d4; - --secondary-color: #7209b7; - --secondary-hover: #6008a0; - --accent-color: #f72585; - --text-color: #555555; - --light-text: #6c757d; - --lighter-text: #adb5bd; - --border-color: #e9ecef; - --light-bg: #f8f9fa; - --container-bg: #fff; - --success-color: #38b000; - --warning-color: #ffbe0b; - --error-color: #d90429; - --info-color: #4cc9f0; + --primary-color: #2b6777; + --primary-hover: #255a68; + --secondary-color: #c8d8e4; + --secondary-hover: #b9c9d5; + --accent-color: #52ab98; + --text-color: #2b6777; + --light-text: #52ab98; + --lighter-text: #c8d8e4; + --border-color: #c8d8e4; + --light-bg: #f2f2f2; + --container-bg: #ffffff; + --success-color: #52ab98; + --warning-color: #c8d8e4; + --error-color: #2b6777; + --info-color: #c8d8e4; /* Neutral shades for backgrounds */ - --neutral-50: #fafafa; - --neutral-100: #f5f5f5; - --neutral-200: #e5e5e5; - --neutral-300: #d4d4d4; + --neutral-50: #ffffff; + --neutral-100: #f2f2f2; + --neutral-200: #e6e6e6; + --neutral-300: #c8d8e4; /* Design tokens */ --border-radius-sm: 0.25rem; From 72b55513775811b975d988e13527f368fd7f1d53 Mon Sep 17 00:00:00 2001 From: stefan Date: Thu, 5 Jun 2025 23:43:56 +0200 Subject: [PATCH 13/13] new design --- .../main/kotlin/at/mocode/views/NennungView.kt | 4 +++- .../images/Reitverein-Neumarkt_Logo.png | Bin 0 -> 22067 bytes .../main/resources/static/css/tournament.css | 13 +++++++------ 3 files changed, 10 insertions(+), 7 deletions(-) create mode 100644 server/src/main/resources/images/Reitverein-Neumarkt_Logo.png diff --git a/server/src/main/kotlin/at/mocode/views/NennungView.kt b/server/src/main/kotlin/at/mocode/views/NennungView.kt index 10a3b951..cb3b2294 100644 --- a/server/src/main/kotlin/at/mocode/views/NennungView.kt +++ b/server/src/main/kotlin/at/mocode/views/NennungView.kt @@ -29,12 +29,14 @@ class NennungView { h1 { +"Online-Nennen" } // Tournament description - div(classes = "tournament-info text-center mb-4") { + + div(classes = "tournament-info mb-3") { h2 { +turnier.name } p { +turnier.datum } p { +"Turnier-Nr.: ${turnier.number}" } } + form( action = "/nennung/${turnier.number}/submit", method = FormMethod.post, diff --git a/server/src/main/resources/images/Reitverein-Neumarkt_Logo.png b/server/src/main/resources/images/Reitverein-Neumarkt_Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..e19ed1c988694648204b72d476de458e4a68ad2e GIT binary patch literal 22067 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 literal 0 HcmV?d00001 diff --git a/server/src/main/resources/static/css/tournament.css b/server/src/main/resources/static/css/tournament.css index c69583f3..94047933 100644 --- a/server/src/main/resources/static/css/tournament.css +++ b/server/src/main/resources/static/css/tournament.css @@ -2,7 +2,7 @@ /* Tournament list container */ .tournament-list { - max-width: 900px; + max-width: 1080px; margin: 0 auto; padding: 1rem 0; } @@ -203,10 +203,11 @@ /* Tournament info for registration page */ .tournament-info { - margin-bottom: 3rem; + max-width: 840px; + margin: 0 auto; text-align: center; position: relative; - padding-bottom: 2rem; + padding-bottom: 1.5rem; } .tournament-info:after { @@ -229,14 +230,14 @@ } .tournament-info p { - color: var(--light-text); + color: var(--text-color); font-size: 1.1rem; margin-bottom: 0.5rem; } /* Registration form styles */ .registration-form { - max-width: 800px; + max-width: 960px; margin: 0 auto; } @@ -364,7 +365,7 @@ /* Confirmation page styles */ .confirmation-box { - max-width: 700px; + max-width: 840px; margin: 0 auto; text-align: center; background-color: var(--container-bg);