Эх сурвалжийг харах

First somewhat working version

Peter Oertel 5 сар өмнө
commit
f56478c96b
38 өөрчлөгдсөн 2156 нэмэгдсэн , 0 устгасан
  1. 27 0
      .gitignore
  2. BIN
      images/banner.png
  3. BIN
      images/logo.png
  4. BIN
      images/logo_square.png
  5. 56 0
      stellarplugin/build.gradle.kts
  6. 0 0
      stellarplugin/gradle.properties
  7. BIN
      stellarplugin/gradle/wrapper/gradle-wrapper.jar
  8. 7 0
      stellarplugin/gradle/wrapper/gradle-wrapper.properties
  9. 249 0
      stellarplugin/gradlew
  10. 92 0
      stellarplugin/gradlew.bat
  11. 1 0
      stellarplugin/settings.gradle.kts
  12. 80 0
      stellarplugin/src/main/kotlin/io/stellarfrontier/stellarplugin/SocketClient.kt
  13. 39 0
      stellarplugin/src/main/kotlin/io/stellarfrontier/stellarplugin/StellarPlugin.kt
  14. 63 0
      stellarplugin/src/main/kotlin/io/stellarfrontier/stellarplugin/commands/LinkCommand.kt
  15. 64 0
      stellarplugin/src/main/kotlin/io/stellarfrontier/stellarplugin/listeners/GameListener.kt
  16. 4 0
      stellarplugin/src/main/resources/plugin.yml
  17. 57 0
      stellarwebapp/app.py
  18. 80 0
      stellarwebapp/events.py
  19. 7 0
      stellarwebapp/extensions.py
  20. 103 0
      stellarwebapp/models.py
  21. BIN
      stellarwebapp/requirements.txt
  22. 0 0
      stellarwebapp/routes/__init__.py
  23. 52 0
      stellarwebapp/routes/admin.py
  24. 45 0
      stellarwebapp/routes/api.py
  25. 75 0
      stellarwebapp/routes/auth.py
  26. 154 0
      stellarwebapp/routes/main.py
  27. 118 0
      stellarwebapp/templates/admin/dashboard.html
  28. 77 0
      stellarwebapp/templates/admin/user_detail.html
  29. 54 0
      stellarwebapp/templates/admin/user_search.html
  30. 29 0
      stellarwebapp/templates/apply.html
  31. 229 0
      stellarwebapp/templates/base.html
  32. 23 0
      stellarwebapp/templates/index.html
  33. 13 0
      stellarwebapp/templates/rules.html
  34. 164 0
      stellarwebapp/templates/ticket_detail.html
  35. 58 0
      stellarwebapp/templates/tickets.html
  36. 50 0
      stellarwebapp/templates/user/profile.html
  37. 33 0
      stellarwebapp/update_db.py
  38. 53 0
      verify_system.py

+ 27 - 0
.gitignore

@@ -0,0 +1,27 @@
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+venv/
+.venv/
+env/
+.env
+
+# Gradle
+.gradle/
+build/
+
+# IDEs and Editors
+.idea/
+*.iml
+.vscode/
+
+# OS files
+.DS_Store
+Thumbs.db
+
+# Logs
+*.log
+
+# MC Test Server
+testserver/

BIN
images/banner.png


BIN
images/logo.png


BIN
images/logo_square.png


+ 56 - 0
stellarplugin/build.gradle.kts

@@ -0,0 +1,56 @@
+plugins {
+    kotlin("jvm") version "2.3.0"
+    id("com.gradleup.shadow") version "8.3.0"
+    id("xyz.jpenilla.run-paper") version "2.3.1"
+}
+
+group = "io.stellarfrontier"
+version = "1.0-SNAPSHOT"
+
+repositories {
+    mavenCentral()
+    maven("https://hub.spigotmc.org/nexus/content/repositories/snapshots/") {
+        name = "spigotmc-repo"
+    }
+}
+
+dependencies {
+    compileOnly("org.spigotmc:spigot-api:1.21.11-R0.1-SNAPSHOT")
+    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
+    implementation("org.java-websocket:Java-WebSocket:1.5.3")
+    implementation("com.squareup.okhttp3:okhttp:4.10.0")
+    implementation("com.google.code.gson:gson:2.10.1")
+}
+
+tasks {
+    runServer {
+        // Configure the Minecraft version for our task.
+        // This is the only required configuration besides applying the plugin.
+        // Your plugin's jar (or shadowJar if present) will be used automatically.
+        minecraftVersion("1.21")
+    }
+}
+
+val targetJavaVersion = 21
+kotlin {
+    jvmToolchain(targetJavaVersion)
+}
+
+tasks.build {
+    dependsOn("shadowJar")
+}
+
+tasks.register<Copy>("installPlugin") {
+    dependsOn("shadowJar")
+    from(tasks.shadowJar.flatMap { it.archiveFile })
+    into(project.file("../testserver/plugins"))
+}
+
+tasks.processResources {
+    val props = mapOf("version" to version)
+    inputs.properties(props)
+    filteringCharset = "UTF-8"
+    filesMatching("plugin.yml") {
+        expand(props)
+    }
+}

+ 0 - 0
stellarplugin/gradle.properties


BIN
stellarplugin/gradle/wrapper/gradle-wrapper.jar


+ 7 - 0
stellarplugin/gradle/wrapper/gradle-wrapper.properties

@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists

+ 249 - 0
stellarplugin/gradlew

@@ -0,0 +1,249 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+#   Gradle start up script for POSIX generated by Gradle.
+#
+#   Important for running:
+#
+#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+#       noncompliant, but you have some other compliant shell such as ksh or
+#       bash, then to run this script, type that shell name before the whole
+#       command line, like:
+#
+#           ksh Gradle
+#
+#       Busybox and similar reduced shells will NOT work, because this script
+#       requires all of these POSIX shell features:
+#         * functions;
+#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+#         * compound commands having a testable exit status, especially «case»;
+#         * various built-in commands including «command», «set», and «ulimit».
+#
+#   Important for patching:
+#
+#   (2) This script targets any POSIX shell, so it avoids extensions provided
+#       by Bash, Ksh, etc; in particular arrays are avoided.
+#
+#       The "traditional" practice of packing multiple parameters into a
+#       space-separated string is a well documented source of bugs and security
+#       problems, so this is (mostly) avoided, by progressively accumulating
+#       options in "$@", and eventually passing that to Java.
+#
+#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+#       see the in-line comments for details.
+#
+#       There are tweaks for specific operating systems such as AIX, CygWin,
+#       Darwin, MinGW, and NonStop.
+#
+#   (3) This script is generated from the Groovy template
+#       https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+#       within the Gradle project.
+#
+#       You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+    APP_HOME=${app_path%"${app_path##*/}"}  # leaves a trailing /; empty if no leading path
+    [ -h "$app_path" ]
+do
+    ls=$( ls -ld "$app_path" )
+    link=${ls#*' -> '}
+    case $link in             #(
+      /*)   app_path=$link ;; #(
+      *)    app_path=$APP_HOME$link ;;
+    esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+    echo "$*"
+} >&2
+
+die () {
+    echo
+    echo "$*"
+    echo
+    exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in                #(
+  CYGWIN* )         cygwin=true  ;; #(
+  Darwin* )         darwin=true  ;; #(
+  MSYS* | MINGW* )  msys=true    ;; #(
+  NONSTOP* )        nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+        # IBM's JDK on AIX uses strange locations for the executables
+        JAVACMD=$JAVA_HOME/jre/sh/java
+    else
+        JAVACMD=$JAVA_HOME/bin/java
+    fi
+    if [ ! -x "$JAVACMD" ] ; then
+        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+else
+    JAVACMD=java
+    if ! command -v java >/dev/null 2>&1
+    then
+        die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+    case $MAX_FD in #(
+      max*)
+        # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+        # shellcheck disable=SC2039,SC3045
+        MAX_FD=$( ulimit -H -n ) ||
+            warn "Could not query maximum file descriptor limit"
+    esac
+    case $MAX_FD in  #(
+      '' | soft) :;; #(
+      *)
+        # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+        # shellcheck disable=SC2039,SC3045
+        ulimit -n "$MAX_FD" ||
+            warn "Could not set maximum file descriptor limit to $MAX_FD"
+    esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+#   * args from the command line
+#   * the main class name
+#   * -classpath
+#   * -D...appname settings
+#   * --module-path (only if needed)
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+    APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+    CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+    JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+    # Now convert the arguments - kludge to limit ourselves to /bin/sh
+    for arg do
+        if
+            case $arg in                                #(
+              -*)   false ;;                            # don't mess with options #(
+              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath
+                    [ -e "$t" ] ;;                      #(
+              *)    false ;;
+            esac
+        then
+            arg=$( cygpath --path --ignore --mixed "$arg" )
+        fi
+        # Roll the args list around exactly as many times as the number of
+        # args, so each arg winds up back in the position where it started, but
+        # possibly modified.
+        #
+        # NB: a `for` loop captures its iteration list before it begins, so
+        # changing the positional parameters here affects neither the number of
+        # iterations, nor the values presented in `arg`.
+        shift                   # remove old arg
+        set -- "$@" "$arg"      # push replacement arg
+    done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+#     and any embedded shellness will be escaped.
+#   * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+#     treated as '${Hostname}' itself on the command line.
+
+set -- \
+        "-Dorg.gradle.appname=$APP_BASE_NAME" \
+        -classpath "$CLASSPATH" \
+        org.gradle.wrapper.GradleWrapperMain \
+        "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+    die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+#   readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+#   set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+        printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+        xargs -n1 |
+        sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+        tr '\n' ' '
+    )" '"$@"'
+
+exec "$JAVACMD" "$@"

+ 92 - 0
stellarplugin/gradlew.bat

@@ -0,0 +1,92 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem      https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem  Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega

+ 1 - 0
stellarplugin/settings.gradle.kts

@@ -0,0 +1 @@
+rootProject.name = "stellarplugin"

+ 80 - 0
stellarplugin/src/main/kotlin/io/stellarfrontier/stellarplugin/SocketClient.kt

@@ -0,0 +1,80 @@
+package io.stellarfrontier.stellarplugin
+
+import org.java_websocket.client.WebSocketClient
+import org.java_websocket.handshake.ServerHandshake
+import java.net.URI
+import java.util.logging.Logger
+import java.util.concurrent.ConcurrentLinkedQueue
+
+class SocketClient(uri: URI, private val logger: Logger) : WebSocketClient(uri) {
+
+    private @Volatile var shouldReconnect = true
+
+    private val messageQueue = ConcurrentLinkedQueue<String>()
+    private val lock = Any()
+
+    fun shutdown() {
+        shouldReconnect = false
+        close()
+    }
+
+    override fun onOpen(handshakedata: ServerHandshake?) {
+        logger.info("Connected to WebSocket server")
+        // Socket.IO connection is established when we receive the Open packet (0)
+        // We must send the Connect packet (40) to join the default namespace
+        send("40")
+        logger.info("Sent Socket.IO Connect packet")
+
+        flushQueue()
+    }
+
+    private fun flushQueue() {
+        synchronized(lock) {
+            while (!messageQueue.isEmpty()) {
+                val msg = messageQueue.poll()
+                send(msg)
+                logger.info("Flushed queued message: $msg")
+            }
+        }
+    }
+
+    override fun onMessage(message: String?) {
+        if (message == "2") {
+            send("3")
+            return
+        }
+        logger.info("Received message: $message")
+    }
+    
+    fun sendSocketEvent(eventName: String, jsonObject: String) {
+        val packet = "42[\"$eventName\",$jsonObject]"
+        if (isOpen) {
+            logger.info("Sending event directly (via queue): $packet")
+            messageQueue.add(packet)
+            flushQueue()
+        } else {
+            messageQueue.add(packet)
+            logger.info("Connection closed, queued event: $packet")
+        }
+    }
+
+    override fun onClose(code: Int, reason: String?, remote: Boolean) {
+        if (shouldReconnect) {
+            logger.info("Disconnected from WebSocket server: $reason. Reconnecting in 5 seconds...")
+            Thread {
+                try {
+                    Thread.sleep(5000)
+                    reconnect()
+                } catch (e: Exception) {
+                    logger.severe("Failed to reconnect: ${e.message}")
+                }
+            }.start()
+        } else {
+            logger.info("Disconnected from WebSocket server: $reason")
+        }
+    }
+
+    override fun onError(ex: Exception?) {
+        logger.severe("WebSocket error: ${ex?.message}")
+    }
+}

+ 39 - 0
stellarplugin/src/main/kotlin/io/stellarfrontier/stellarplugin/StellarPlugin.kt

@@ -0,0 +1,39 @@
+package io.stellarfrontier.stellarplugin
+
+import io.stellarfrontier.stellarplugin.commands.LinkCommand
+import io.stellarfrontier.stellarplugin.listeners.GameListener
+import org.bukkit.plugin.java.JavaPlugin
+import java.net.URI
+
+class StellarPlugin : JavaPlugin() {
+
+    private var socketClient: SocketClient? = null
+
+    override fun onEnable() {
+        // Plugin startup logic
+        logger.info("StellarPlugin is enabling...")
+
+        // Connect to WebSocket
+        try {
+            val uri = URI("ws://localhost:5000/socket.io/?EIO=4&transport=websocket")
+            socketClient = SocketClient(uri, logger)
+            socketClient?.connect()
+        } catch (e: Exception) {
+            logger.severe("Failed to connect to WebSocket: ${e.message}")
+        }
+
+        // Register Listeners
+        server.pluginManager.registerEvents(GameListener(socketClient!!), this)
+
+        // Register Commands
+        getCommand("link")?.setExecutor(LinkCommand())
+        
+        logger.info("StellarPlugin enabled!")
+    }
+
+    override fun onDisable() {
+        // Plugin shutdown logic
+        socketClient?.shutdown()
+        logger.info("StellarPlugin disabled!")
+    }
+}

+ 63 - 0
stellarplugin/src/main/kotlin/io/stellarfrontier/stellarplugin/commands/LinkCommand.kt

@@ -0,0 +1,63 @@
+package io.stellarfrontier.stellarplugin.commands
+
+import com.google.gson.Gson
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.bukkit.ChatColor
+import org.bukkit.command.Command
+import org.bukkit.command.CommandExecutor
+import org.bukkit.command.CommandSender
+import org.bukkit.entity.Player
+import java.io.IOException
+
+class LinkCommand : CommandExecutor {
+    private val client = OkHttpClient()
+    private val gson = Gson()
+    private val JSON = "application/json; charset=utf-8".toMediaType()
+
+    override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>): Boolean {
+        if (sender !is Player) {
+            sender.sendMessage("Only players can use this command.")
+            return true
+        }
+
+        if (args.isEmpty()) {
+            sender.sendMessage("${ChatColor.RED}Usage: /link <code>")
+            return true
+        }
+
+        val code = args[0]
+        val data = mapOf(
+            "code" to code,
+            "uuid" to sender.uniqueId.toString(),
+            "username" to sender.name
+        )
+
+        val body = gson.toJson(data).toRequestBody(JSON)
+        val request = Request.Builder()
+            .url("http://localhost:5000/api/link") // TODO: Configurable URL
+            .post(body)
+            .build()
+
+        // Execute async to avoid blocking main thread
+        // In a real plugin, use BukkitScheduler.runTaskAsynchronously
+        Thread {
+            try {
+                client.newCall(request).execute().use { response ->
+                    if (response.isSuccessful) {
+                        sender.sendMessage("${ChatColor.GREEN}Account linked successfully!")
+                    } else {
+                        sender.sendMessage("${ChatColor.RED}Failed to link account: ${response.message}")
+                    }
+                }
+            } catch (e: IOException) {
+                sender.sendMessage("${ChatColor.RED}Error connecting to web server.")
+                e.printStackTrace()
+            }
+        }.start()
+
+        return true
+    }
+}

+ 64 - 0
stellarplugin/src/main/kotlin/io/stellarfrontier/stellarplugin/listeners/GameListener.kt

@@ -0,0 +1,64 @@
+package io.stellarfrontier.stellarplugin.listeners
+
+import com.google.gson.Gson
+import io.stellarfrontier.stellarplugin.SocketClient
+import org.bukkit.event.EventHandler
+import org.bukkit.event.Listener
+import org.bukkit.event.entity.PlayerDeathEvent
+import org.bukkit.event.player.AsyncPlayerChatEvent
+import org.bukkit.event.player.PlayerJoinEvent
+import org.bukkit.event.player.PlayerQuitEvent
+import org.bukkit.event.player.PlayerTeleportEvent
+import java.time.Instant
+
+class GameListener(private val socket: SocketClient) : Listener {
+    private val gson = Gson()
+
+    private fun sendEvent(type: String, player: String, uuid: String, content: String, location: String? = null, inventory: List<Map<String, Any>>? = null) {
+        val data: MutableMap<String, Any> = mutableMapOf(
+            "type" to type,
+            "player" to player,
+            "uuid" to uuid,
+            "content" to content,
+            "timestamp" to Instant.now().toString()
+        )
+        if (location != null) data["location"] = location
+        if (inventory != null) data["inventory"] = inventory
+
+        socket.sendSocketEvent("game_event", gson.toJson(data))
+    }
+
+    @EventHandler
+    fun onChat(event: AsyncPlayerChatEvent) {
+        sendEvent("CHAT", event.player.name, event.player.uniqueId.toString(), event.message)
+    }
+
+    @EventHandler
+    fun onJoin(event: PlayerJoinEvent) {
+        sendEvent("JOIN", event.player.name, event.player.uniqueId.toString(), "Joined the server")
+    }
+
+    @EventHandler
+    fun onQuit(event: PlayerQuitEvent) {
+        val inventory = event.player.inventory.contents.filterNotNull().map { item ->
+            mapOf("type" to item.type.toString(), "amount" to item.amount)
+        }
+        sendEvent("QUIT", event.player.name, event.player.uniqueId.toString(), "Left the server", null, inventory)
+    }
+
+    @EventHandler
+    fun onDeath(event: PlayerDeathEvent) {
+        val inventory = event.entity.inventory.contents.filterNotNull().map { item ->
+            mapOf("type" to item.type.toString(), "amount" to item.amount)
+        }
+        sendEvent("DEATH", event.entity.name, event.entity.uniqueId.toString(), event.deathMessage ?: "Died", event.entity.location.toString(), inventory)
+    }
+
+    @EventHandler
+    fun onTeleport(event: PlayerTeleportEvent) {
+        val inventory = event.player.inventory.contents.filterNotNull().map { item ->
+            mapOf("type" to item.type.toString(), "amount" to item.amount)
+        }
+        sendEvent("TELEPORT", event.player.name, event.player.uniqueId.toString(), "Teleported to ${event.to}", event.to.toString(), inventory)
+    }
+}

+ 4 - 0
stellarplugin/src/main/resources/plugin.yml

@@ -0,0 +1,4 @@
+name: stellarplugin
+version: '1.0-SNAPSHOT'
+main: io.stellarfrontier.stellarplugin.StellarPlugin
+api-version: '1.21'

+ 57 - 0
stellarwebapp/app.py

@@ -0,0 +1,57 @@
+from flask import Flask
+from flask_socketio import SocketIO
+from flask_sqlalchemy import SQLAlchemy
+from dotenv import load_dotenv
+import os
+
+# Load environment variables
+load_dotenv()
+
+from flask_login import LoginManager
+
+# Initialize extensions
+from extensions import socketio, login_manager, db
+from models import User
+
+def create_app():
+    app = Flask(__name__)
+    
+    # Configuration
+    app.config['SECRET_KEY'] = os.getenv('SECRET_KEY')
+    app.config['SQLALCHEMY_DATABASE_URI'] = f"mysql+mysqlconnector://{os.getenv('DB_USER')}:{os.getenv('DB_PASSWORD')}@{os.getenv('DB_HOST')}/{os.getenv('DB_NAME')}"
+    app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
+
+    # Initialize extensions with app
+    db.init_app(app)
+    socketio.init_app(app, cors_allowed_origins="*")
+    login_manager.init_app(app)
+    login_manager.login_view = 'auth.login'
+
+    @login_manager.user_loader
+    def load_user(user_id):
+        return User.query.get(int(user_id))
+
+    # Register Blueprints
+    from routes.main import main_bp
+    from routes.auth import auth_bp
+    from routes.admin import admin_bp
+    from routes.api import api_bp
+    
+    app.register_blueprint(main_bp)
+    app.register_blueprint(auth_bp, url_prefix='/auth')
+    app.register_blueprint(admin_bp, url_prefix='/admin')
+    app.register_blueprint(api_bp, url_prefix='/api')
+
+    # Import events (to register handlers)
+    import events
+
+    with app.app_context():
+        # Create tables if they don't exist
+        # Note: In production, use migrations (Alembic/Flask-Migrate)
+        db.create_all()
+
+    return app
+
+if __name__ == '__main__':
+    app = create_app()
+    socketio.run(app, debug=True, port=5000)

+ 80 - 0
stellarwebapp/events.py

@@ -0,0 +1,80 @@
+from flask_socketio import emit
+from extensions import socketio, db
+from models import Event, Inventory
+from flask import request
+import json
+from dateutil import parser
+from datetime import datetime
+
[email protected]('connect')
+def handle_connect():
+    print('Client connected')
+    emit('clear_events')
+    
+    # Send history
+    try:
+        recent_events = Event.query.order_by(Event.timestamp.desc()).limit(30).all()
+        print(f"Sending history: {len(recent_events)} events")
+        for event in reversed(recent_events):
+            emit('game_event', event.to_dict())
+    except Exception as e:
+        print(f"Error sending history: {e}")
+
[email protected]('disconnect')
+def handle_disconnect():
+    print('Client disconnected')
+
[email protected]('game_event')
+def handle_game_event(data):
+    # data = {type: 'CHAT', player: 'Name', content: 'Message', ...}
+    print(f"Received event: {data}")
+    
+    # Save to DB
+    try:
+        if not data.get('type'):
+            print(f"Error: Missing event type in {data}")
+            return
+
+        event_timestamp = None
+        if data.get('timestamp'):
+            try:
+                event_timestamp = parser.parse(data.get('timestamp'))
+            except Exception as e:
+                print(f"Error parsing timestamp: {e}")
+                event_timestamp = datetime.utcnow()
+
+        event = Event(
+            event_type=data.get('type'),
+            player_uuid=data.get('uuid'),
+            player_name=data.get('player'),
+            content=data.get('content'),
+            location=data.get('location'),
+            timestamp=event_timestamp or datetime.utcnow()
+        )
+        db.session.add(event)
+        db.session.commit()
+        print(f"Event saved to DB: {event.id}")
+
+        # If inventory data is present, save it
+        if 'inventory' in data:
+            inventory = Inventory(
+                event_id=event.id,
+                player_uuid=data.get('uuid'),
+                items=json.dumps(data.get('inventory'))
+            )
+            db.session.add(inventory)
+            db.session.commit()
+            print(f"Inventory saved for event {event.id}")
+            
+    except Exception as e:
+        print(f"Error saving event to DB: {str(e)}")
+        db.session.rollback()
+
+    # Broadcast to admin dashboard
+    print(f"Broadcasting event: {data}")
+    emit('game_event', data, broadcast=True)
+
[email protected]("*")
+def handle_any_event(event, methods=None, data=None):
+    if event not in ['connect', 'disconnect']:
+        print(f"Server received event: {event} with data: {data}")

+ 7 - 0
stellarwebapp/extensions.py

@@ -0,0 +1,7 @@
+from flask_sqlalchemy import SQLAlchemy
+from flask_socketio import SocketIO
+from flask_login import LoginManager
+
+db = SQLAlchemy()
+socketio = SocketIO(logger=True, engineio_logger=True)
+login_manager = LoginManager()

+ 103 - 0
stellarwebapp/models.py

@@ -0,0 +1,103 @@
+from flask_sqlalchemy import SQLAlchemy
+from flask_login import UserMixin
+from datetime import datetime
+import json
+
+from extensions import db
+
+class User(UserMixin, db.Model):
+    __tablename__ = 'users'
+    id = db.Column(db.Integer, primary_key=True)
+    discord_id = db.Column(db.String(50), unique=True, nullable=False)
+    username = db.Column(db.String(100), nullable=False)
+    minecraft_uuid = db.Column(db.String(36), unique=True, nullable=True)
+    minecraft_username = db.Column(db.String(16), nullable=True)
+    is_admin = db.Column(db.Boolean, default=False)
+    created_at = db.Column(db.DateTime, default=datetime.utcnow)
+
+class Event(db.Model):
+    __tablename__ = 'events'
+    id = db.Column(db.Integer, primary_key=True)
+    event_type = db.Column(db.String(50), nullable=False) # CHAT, JOIN, QUIT, DEATH, TELEPORT
+    player_uuid = db.Column(db.String(36), nullable=True)
+    player_name = db.Column(db.String(16), nullable=True)
+    content = db.Column(db.Text, nullable=True) # Chat message, death message, etc.
+    location = db.Column(db.String(100), nullable=True) # Serialized location
+    timestamp = db.Column(db.DateTime, default=datetime.utcnow)
+
+    def to_dict(self):
+        return {
+            'type': self.event_type,
+            'player': self.player_name,
+            'uuid': self.player_uuid,
+            'content': self.content,
+            'location': self.location,
+            'timestamp': self.timestamp.isoformat() if self.timestamp else None,
+            'inventory': json.loads(self.inventory.items) if self.inventory else None
+        }
+
+class Inventory(db.Model):
+    __tablename__ = 'inventories'
+    id = db.Column(db.Integer, primary_key=True)
+    event_id = db.Column(db.Integer, db.ForeignKey('events.id'), nullable=False)
+    player_uuid = db.Column(db.String(36), nullable=False)
+    items = db.Column(db.Text, nullable=False) # JSON serialized inventory
+    timestamp = db.Column(db.DateTime, default=datetime.utcnow)
+    
+    event = db.relationship('Event', backref=db.backref('inventory', uselist=False))
+
+class Ticket(db.Model):
+    __tablename__ = 'tickets'
+    id = db.Column(db.Integer, primary_key=True)
+    user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) # Creator
+    title = db.Column(db.String(200), nullable=False)
+    description = db.Column(db.Text, nullable=False)
+    status = db.Column(db.String(20), default='OPEN') # OPEN, CLOSED, IN_PROGRESS
+    closing_reason = db.Column(db.Text, nullable=True)
+    closed_by_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
+    created_at = db.Column(db.DateTime, default=datetime.utcnow)
+    
+    creator = db.relationship('User', backref='created_tickets', foreign_keys=[user_id])
+    closed_by = db.relationship('User', foreign_keys=[closed_by_id])
+    assignments = db.relationship('TicketAssignment', backref='ticket', cascade="all, delete-orphan")
+    comments = db.relationship('TicketComment', backref='ticket', cascade="all, delete-orphan", order_by='TicketComment.created_at')
+
+class TicketAssignment(db.Model):
+    __tablename__ = 'ticket_assignments'
+    user_id = db.Column(db.Integer, db.ForeignKey('users.id'), primary_key=True)
+    ticket_id = db.Column(db.Integer, db.ForeignKey('tickets.id'), primary_key=True)
+    assigned_at = db.Column(db.DateTime, default=datetime.utcnow)
+
+    user = db.relationship('User', backref='assignments')
+
+class TicketComment(db.Model):
+    __tablename__ = 'ticket_comments'
+    id = db.Column(db.Integer, primary_key=True)
+    ticket_id = db.Column(db.Integer, db.ForeignKey('tickets.id'), nullable=False)
+    user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
+    content = db.Column(db.Text, nullable=False)
+    is_hidden = db.Column(db.Boolean, default=False)
+    created_at = db.Column(db.DateTime, default=datetime.utcnow)
+
+    user = db.relationship('User', backref='comments')
+
+class LinkCode(db.Model):
+    __tablename__ = 'link_codes'
+    id = db.Column(db.Integer, primary_key=True)
+    user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
+    code = db.Column(db.String(10), unique=True, nullable=False)
+    created_at = db.Column(db.DateTime, default=datetime.utcnow)
+    expires_at = db.Column(db.DateTime, nullable=False)
+
+    user = db.relationship('User', backref=db.backref('link_code', uselist=False, cascade="all, delete-orphan"))
+
+class Application(db.Model):
+    __tablename__ = 'applications'
+    id = db.Column(db.Integer, primary_key=True)
+    user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
+    position = db.Column(db.String(50), nullable=False) # MODERATOR, DEVELOPER
+    content = db.Column(db.Text, nullable=False)
+    status = db.Column(db.String(20), default='PENDING') # PENDING, ACCEPTED, REJECTED
+    created_at = db.Column(db.DateTime, default=datetime.utcnow)
+
+    user = db.relationship('User', backref='applications')

BIN
stellarwebapp/requirements.txt


+ 0 - 0
stellarwebapp/routes/__init__.py


+ 52 - 0
stellarwebapp/routes/admin.py

@@ -0,0 +1,52 @@
+from flask import Blueprint, render_template, request
+from flask_login import login_required, current_user
+from functools import wraps
+from flask import abort
+
+admin_bp = Blueprint('admin', __name__)
+
+def admin_required(f):
+    @wraps(f)
+    def decorated_function(*args, **kwargs):
+        if not current_user.is_authenticated or not current_user.is_admin:
+            abort(403)
+        return f(*args, **kwargs)
+    return decorated_function
+
+@admin_bp.route('/')
+@admin_required
+def dashboard():
+    return render_template('admin/dashboard.html')
+
+@admin_bp.route('/users', methods=['GET', 'POST'])
+@login_required
+@admin_required
+def user_search():
+    from models import User
+    users = []
+    query = request.args.get('q', '')
+    
+    if query:
+        # Fuzzy search for Discord or Minecraft username
+        # utilizing ILIKE for case-insensitive search if supported, or just simple LIKE
+        search_term = f"%{query}%"
+        users = User.query.filter(
+            (User.username.ilike(search_term)) | 
+            (User.minecraft_username.ilike(search_term))
+        ).all()
+        
+    return render_template('admin/user_search.html', users=users, query=query)
+
+@admin_bp.route('/users/<int:user_id>')
+@login_required
+@admin_required
+def user_detail(user_id):
+    from models import User, Ticket, TicketAssignment
+    user = User.query.get_or_404(user_id)
+    
+    # Find relevant tickets
+    relevant_tickets = Ticket.query.outerjoin(TicketAssignment).filter(
+        (Ticket.user_id == user.id) | (TicketAssignment.user_id == user.id)
+    ).all()
+    
+    return render_template('admin/user_detail.html', user=user, tickets=relevant_tickets)

+ 45 - 0
stellarwebapp/routes/api.py

@@ -0,0 +1,45 @@
+from flask import Blueprint, request, jsonify
+from models import db, User
+
+from flask import Blueprint, request, jsonify
+from models import db, User, LinkCode
+from datetime import datetime
+
+api_bp = Blueprint('api', __name__)
+
+@api_bp.route('/link', methods=['POST'])
+def link_account():
+    data = request.json
+    code = data.get('code')
+    uuid = data.get('uuid')
+    username = data.get('username')
+    
+    if not code or not uuid or not username:
+        return jsonify({'success': False, 'message': 'Missing data'}), 400
+        
+    # Check code in database
+    link_entry = LinkCode.query.filter_by(code=code).first()
+    
+    if not link_entry:
+        return jsonify({'success': False, 'message': 'Invalid code'}), 400
+        
+    if link_entry.expires_at < datetime.utcnow():
+        db.session.delete(link_entry)
+        db.session.commit()
+        return jsonify({'success': False, 'message': 'Code expired'}), 400
+        
+    user = User.query.get(link_entry.user_id)
+    if not user:
+        return jsonify({'success': False, 'message': 'User not found'}), 404
+        
+    if user.minecraft_uuid:
+        return jsonify({'success': False, 'message': 'User already linked'}), 400
+        
+    user.minecraft_uuid = uuid
+    user.minecraft_username = username
+    
+    # Remove the used code
+    db.session.delete(link_entry)
+    db.session.commit()
+    
+    return jsonify({'success': True, 'message': f'Successfully linked to {user.username}'})

+ 75 - 0
stellarwebapp/routes/auth.py

@@ -0,0 +1,75 @@
+from flask import Blueprint, redirect, url_for, session, request, flash
+from flask_login import login_user, logout_user, login_required, current_user
+from models import db, User
+import requests
+import os
+
+auth_bp = Blueprint('auth', __name__)
+
+DISCORD_API_BASE_URL = 'https://discord.com/api'
+AUTHORIZATION_BASE_URL = DISCORD_API_BASE_URL + '/oauth2/authorize'
+TOKEN_URL = DISCORD_API_BASE_URL + '/oauth2/token'
+
+@auth_bp.route('/login')
+def login():
+    client_id = os.getenv('DISCORD_CLIENT_ID')
+    redirect_uri = os.getenv('DISCORD_REDIRECT_URI')
+    scope = 'identify'
+    discord_login_url = f"{AUTHORIZATION_BASE_URL}?response_type=code&client_id={client_id}&scope={scope}&redirect_uri={redirect_uri}&prompt=consent"
+    return redirect(discord_login_url)
+
+@auth_bp.route('/callback')
+def callback():
+    code = request.args.get('code')
+    if not code:
+        flash("Error: No code provided.", "danger")
+        return redirect(url_for('main.index'))
+
+    data = {
+        'client_id': os.getenv('DISCORD_CLIENT_ID'),
+        'client_secret': os.getenv('DISCORD_CLIENT_SECRET'),
+        'grant_type': 'authorization_code',
+        'code': code,
+        'redirect_uri': os.getenv('DISCORD_REDIRECT_URI'),
+        'scope': 'identify'
+    }
+    headers = {
+        'Content-Type': 'application/x-www-form-urlencoded'
+    }
+
+    response = requests.post(TOKEN_URL, data=data, headers=headers)
+    token_json = response.json()
+    
+    if 'access_token' not in token_json:
+        flash("Error: Failed to retrieve access token.", "danger")
+        return redirect(url_for('main.index'))
+
+    access_token = token_json['access_token']
+    user_headers = {
+        'Authorization': f"Bearer {access_token}"
+    }
+    user_response = requests.get(f"{DISCORD_API_BASE_URL}/users/@me", headers=user_headers)
+    user_data = user_response.json()
+
+    discord_id = user_data['id']
+    username = user_data['username']
+
+    user = User.query.filter_by(discord_id=discord_id).first()
+    if not user:
+        user = User(discord_id=discord_id, username=username)
+        db.session.add(user)
+        db.session.commit()
+    else:
+        user.username = username
+        db.session.commit()
+
+    login_user(user)
+    flash(f"Logged in as {username}!", "success")
+    return redirect(url_for('main.index'))
+
+@auth_bp.route('/logout')
+@login_required
+def logout():
+    logout_user()
+    flash("You have been logged out.", "info")
+    return redirect(url_for('main.index'))

+ 154 - 0
stellarwebapp/routes/main.py

@@ -0,0 +1,154 @@
+from flask import Blueprint, render_template, session, request, redirect, url_for
+from flask_login import login_required, current_user
+import random
+import string
+
+main_bp = Blueprint('main', __name__)
+
+@main_bp.route('/')
+def index():
+    return render_template('index.html')
+
+@main_bp.route('/rules')
+def rules():
+    return render_template('rules.html') # Need to create this or just use a placeholder
+
+@main_bp.route('/profile')
+@login_required
+def profile():
+    from models import LinkCode, db
+    from datetime import datetime, timedelta
+    
+    link_code = None
+    if not current_user.minecraft_uuid:
+        # Check for existing valid code
+        existing_code = LinkCode.query.filter_by(user_id=current_user.id).first()
+        
+        if existing_code and existing_code.expires_at > datetime.utcnow():
+            link_code = existing_code.code
+        else:
+            # Generate new code
+            if existing_code:
+                db.session.delete(existing_code)
+                
+            code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
+            expires_at = datetime.utcnow() + timedelta(minutes=10)
+            
+            new_link_code = LinkCode(user_id=current_user.id, code=code, expires_at=expires_at)
+            db.session.add(new_link_code)
+            db.session.commit()
+            link_code = code
+            
+    return render_template('user/profile.html', user=current_user, link_code=link_code)
+
+@main_bp.route('/apply', methods=['GET', 'POST'])
+@login_required
+def apply():
+    if request.method == 'POST':
+        # Save application logic here
+        pass
+    return render_template('apply.html')
+
+@main_bp.route('/tickets', methods=['GET', 'POST'])
+@login_required
+def tickets():
+    from models import Ticket, TicketAssignment, db
+    
+    if request.method == 'POST':
+        title = request.form.get('title')
+        description = request.form.get('description')
+        
+        if title and description:
+            ticket = Ticket(
+                user_id=current_user.id,
+                title=title,
+                description=description
+            )
+            db.session.add(ticket)
+            db.session.commit()
+            
+            # Automatically assign the creator to the ticket? 
+            # Or just rely on creator field. The user said "multiple players... assigned".
+            # Let's assign the creator as well so they show up in the "relevant" list easily if we query by assignment.
+            # But usually creator is separate.
+            # Let's just save it for now.
+            
+            # If we want to assign admins automatically, we could do it here.
+            
+            return redirect(url_for('main.tickets'))
+
+    # List tickets relevant to the user:
+    # 1. Tickets created by the user
+    # 2. Tickets assigned to the user
+    
+    # Using a union or simple OR query
+    # Since we have a separate TicketAssignment model, we can join.
+    
+    relevant_tickets = Ticket.query.outerjoin(TicketAssignment).filter(
+        (Ticket.user_id == current_user.id) | (TicketAssignment.user_id == current_user.id)
+    ).all()
+    
+    return render_template('tickets.html', tickets=relevant_tickets)
+
+@main_bp.route('/tickets/<int:ticket_id>', methods=['GET', 'POST'])
+@login_required
+def ticket_detail(ticket_id):
+    from models import Ticket, TicketComment, TicketAssignment, db
+    
+    ticket = Ticket.query.get_or_404(ticket_id)
+    
+    # Access Control
+    is_creator = ticket.user_id == current_user.id
+    is_assigned = TicketAssignment.query.filter_by(ticket_id=ticket.id, user_id=current_user.id).first() is not None
+    is_admin = current_user.is_admin
+    
+    if not (is_creator or is_assigned or is_admin):
+        return render_template('errors/403.html'), 403 # Or just redirect with flash
+        
+    if request.method == 'POST':
+        action = request.form.get('action')
+        
+        if action == 'comment':
+            content = request.form.get('content')
+            is_hidden = request.form.get('is_hidden') == 'on'
+            
+            # Only admins can make hidden comments
+            if is_hidden and not is_admin:
+                is_hidden = False
+                
+            if content:
+                comment = TicketComment(
+                    ticket_id=ticket.id,
+                    user_id=current_user.id,
+                    content=content,
+                    is_hidden=is_hidden
+                )
+                db.session.add(comment)
+                db.session.commit()
+                
+        elif action == 'close':
+            # Only creator or admin can close
+            if not (is_creator or is_admin):
+                return "Unauthorized", 403
+                
+            reason = request.form.get('reason')
+            if reason:
+                ticket.status = 'CLOSED'
+                ticket.closing_reason = reason
+                ticket.closed_by_id = current_user.id
+                db.session.commit()
+        
+        elif action == 'toggle_hidden':
+            if not is_admin:
+                return "Unauthorized", 403
+            
+            comment_id = request.form.get('comment_id')
+            comment = TicketComment.query.get(comment_id)
+            if comment and comment.ticket_id == ticket.id:
+                comment.is_hidden = not comment.is_hidden
+                db.session.commit()
+                
+        return redirect(url_for('main.ticket_detail', ticket_id=ticket.id))
+        
+    return render_template('ticket_detail.html', ticket=ticket, is_admin=is_admin, is_creator=is_creator)
+

+ 118 - 0
stellarwebapp/templates/admin/dashboard.html

@@ -0,0 +1,118 @@
+{% extends "base.html" %}
+
+{% block content %}
+<div class="columns">
+    <div class="column is-3">
+        <aside class="menu">
+            <p class="menu-label">Administration</p>
+            <ul class="menu-list">
+                <li><a href="{{ url_for('admin.dashboard') }}" class="is-active">Dashboard</a></li>
+                <li><a href="{{ url_for('admin.user_search') }}">User Search</a></li>
+                <li><a>Players</a></li>
+                <li><a>Bans</a></li>
+            </ul>
+        </aside>
+    </div>
+    <div class="column">
+        <h1 class="title">Live Feed</h1>
+        <div class="box" style="height: 400px; overflow-y: auto; background-color: #111;" id="event-feed">
+            <!-- Events will be appended here via WebSocket -->
+        </div>
+
+        <div class="field is-grouped">
+            <div class="control">
+                <div class="select">
+                    <select id="event-filter">
+                        <option value="ALL">All Events</option>
+                        <option value="CHAT">Chat</option>
+                        <option value="JOIN">Join/Quit</option>
+                        <option value="DEATH">Death</option>
+                    </select>
+                </div>
+            </div>
+            <div class="control">
+                <button class="button is-info" onclick="clearFeed()">Clear Feed</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+{% endblock %}
+
+{% block scripts %}
+<script>
+    const socket = io();
+    const feed = document.getElementById('event-feed');
+    const filter = document.getElementById('event-filter');
+
+    socket.on('connect', () => {
+        console.log('Connected to WebSocket');
+    });
+
+    socket.on('game_event', (data) => {
+        const div = document.createElement('div');
+        div.className = 'notification is-small is-dark';
+        div.style.marginBottom = '0.5rem';
+        div.style.padding = '0.5rem';
+        div.dataset.type = data.type; // Store type for filtering
+
+        // Check if we should show this event based on current filter
+        if (!shouldShowEvent(data.type, filter.value)) {
+            div.style.display = 'none';
+        }
+
+        let content = '';
+        const time = new Date(data.timestamp).toLocaleTimeString();
+
+        switch (data.type) {
+            case 'CHAT':
+                content = `[${time}] <strong>${data.player}</strong>: ${data.content}`;
+                break;
+            case 'JOIN':
+                content = `[${time}] <span class="has-text-success">+</span> <strong>${data.player}</strong> joined the game.`;
+                break;
+            case 'QUIT':
+                content = `[${time}] <span class="has-text-danger">-</span> <strong>${data.player}</strong> left the game.`;
+                break;
+            case 'DEATH':
+                content = `[${time}] <span class="has-text-danger">☠</span> ${data.content}`;
+                break;
+            default:
+                content = `[${time}] [${data.type}] ${data.content}`;
+        }
+
+        div.innerHTML = content;
+        feed.appendChild(div);
+
+        // Only scroll if visible or if we want to force scroll (usually good to scroll if new event arrived)
+        if (div.style.display !== 'none') {
+            feed.scrollTop = feed.scrollHeight;
+        }
+    });
+
+    filter.addEventListener('change', () => {
+        const selectedFilter = filter.value;
+        const events = feed.children;
+
+        for (let event of events) {
+            const type = event.dataset.type;
+            if (shouldShowEvent(type, selectedFilter)) {
+                event.style.display = '';
+            } else {
+                event.style.display = 'none';
+            }
+        }
+    });
+
+    function shouldShowEvent(eventType, filterValue) {
+        if (filterValue === 'ALL') return true;
+        if (filterValue === eventType) return true;
+        if (filterValue === 'JOIN' && (eventType === 'JOIN' || eventType === 'QUIT')) return true;
+        return false;
+    }
+
+    function clearFeed() {
+        feed.innerHTML = '';
+    }
+</script>
+{% endblock %}

+ 77 - 0
stellarwebapp/templates/admin/user_detail.html

@@ -0,0 +1,77 @@
+{% extends "base.html" %}
+
+{% block content %}
+<div class="columns">
+    <div class="column is-4">
+        <div class="box">
+            <h1 class="title">{{ user.username }}</h1>
+            <h2 class="subtitle">User Details</h2>
+            <table class="table is-fullwidth">
+                <tbody>
+                    <tr>
+                        <th>ID</th>
+                        <td>{{ user.id }}</td>
+                    </tr>
+                    <tr>
+                        <th>Discord ID</th>
+                        <td>{{ user.discord_id }}</td>
+                    </tr>
+                    <tr>
+                        <th>Minecraft UUID</th>
+                        <td>{{ user.minecraft_uuid or 'Not Linked' }}</td>
+                    </tr>
+                    <tr>
+                        <th>Minecraft Username</th>
+                        <td>{{ user.minecraft_username or 'Not Linked' }}</td>
+                    </tr>
+                    <tr>
+                        <th>Registered</th>
+                        <td>{{ user.created_at.strftime('%Y-%m-%d %H:%M') if user.created_at else 'Unknown' }}</td>
+                    </tr>
+                    <tr>
+                        <th>Admin Status</th>
+                        <td>
+                            <span class="tag is-{{ 'danger' if user.is_admin else 'light' }}">
+                                {{ 'Admin' if user.is_admin else 'User' }}
+                            </span>
+                        </td>
+                    </tr>
+                </tbody>
+            </table>
+            <a href="{{ url_for('admin.user_search') }}" class="button is-light is-fullwidth">Back to Search</a>
+        </div>
+    </div>
+
+    <div class="column is-8">
+        <div class="box">
+            <h2 class="subtitle">Relevant Tickets</h2>
+            <table class="table is-fullwidth is-hoverable">
+                <thead>
+                    <tr>
+                        <th>ID</th>
+                        <th>Title</th>
+                        <th>Status</th>
+                        <th>Date</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    {% for ticket in tickets %}
+                    <tr>
+                        <td>{{ ticket.id }}</td>
+                        <td><a href="{{ url_for('main.ticket_detail', ticket_id=ticket.id) }}"><strong>{{ ticket.title
+                                    }}</strong></a></td>
+                        <td><span class="tag is-{{ 'success' if ticket.status == 'CLOSED' else 'warning' }}">{{
+                                ticket.status }}</span></td>
+                        <td>{{ ticket.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
+                    </tr>
+                    {% else %}
+                    <tr>
+                        <td colspan="4">No relevant tickets found.</td>
+                    </tr>
+                    {% endfor %}
+                </tbody>
+            </table>
+        </div>
+    </div>
+</div>
+{% endblock %}

+ 54 - 0
stellarwebapp/templates/admin/user_search.html

@@ -0,0 +1,54 @@
+{% extends "base.html" %}
+
+{% block content %}
+<h1 class="title">User Search</h1>
+
+<div class="box">
+    <form method="GET" action="{{ url_for('admin.user_search') }}">
+        <div class="field has-addons">
+            <div class="control is-expanded">
+                <input class="input" type="text" name="q" placeholder="Search by Discord or Minecraft username..."
+                    value="{{ query }}">
+            </div>
+            <div class="control">
+                <button class="button is-info">Search</button>
+            </div>
+        </div>
+    </form>
+</div>
+
+{% if query %}
+<div class="box">
+    <h2 class="subtitle">Results for "{{ query }}"</h2>
+    <table class="table is-fullwidth is-hoverable">
+        <thead>
+            <tr>
+                <th>ID</th>
+                <th>Discord User</th>
+                <th>Minecraft User</th>
+                <th>Joined</th>
+                <th>Actions</th>
+            </tr>
+        </thead>
+        <tbody>
+            {% for user in users %}
+            <tr>
+                <td>{{ user.id }}</td>
+                <td>{{ user.username }}</td>
+                <td>{{ user.minecraft_username or 'N/A' }}</td>
+                <td>{{ user.created_at.strftime('%Y-%m-%d %H:%M') if user.created_at else 'Unknown' }}</td>
+                <td>
+                    <a href="{{ url_for('admin.user_detail', user_id=user.id) }}" class="button is-small is-link">View
+                        Details</a>
+                </td>
+            </tr>
+            {% else %}
+            <tr>
+                <td colspan="5">No users found.</td>
+            </tr>
+            {% endfor %}
+        </tbody>
+    </table>
+</div>
+{% endif %}
+{% endblock %}

+ 29 - 0
stellarwebapp/templates/apply.html

@@ -0,0 +1,29 @@
+{% extends "base.html" %}
+
+{% block content %}
+<h1 class="title">Staff Application</h1>
+<form method="POST">
+    <div class="field">
+        <label class="label">Position</label>
+        <div class="control">
+            <div class="select">
+                <select name="position">
+                    <option value="MODERATOR">Moderator</option>
+                    <option value="DEVELOPER">Developer</option>
+                </select>
+            </div>
+        </div>
+    </div>
+
+    <div class="field">
+        <label class="label">Why do you want to join?</label>
+        <div class="control">
+            <textarea class="textarea" name="content" placeholder="Tell us about yourself..."></textarea>
+        </div>
+    </div>
+
+    <div class="control">
+        <button class="button is-link">Submit Application</button>
+    </div>
+</form>
+{% endblock %}

+ 229 - 0
stellarwebapp/templates/base.html

@@ -0,0 +1,229 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Stellar Frontier</title>
+    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/css/bulma.min.css">
+    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
+    <style>
+        body {
+            background-color: #0a0a0a;
+            color: #e0e0e0;
+            min-height: 100vh;
+        }
+
+        .navbar {
+            background-color: #1a1a1a;
+            border-bottom: 1px solid #333;
+        }
+
+        .navbar-item,
+        .navbar-link {
+            color: #e0e0e0;
+        }
+
+        .navbar-item:hover,
+        .navbar-link:hover {
+            background-color: #333;
+            color: #fff;
+        }
+
+        .card {
+            background-color: #1a1a1a;
+            color: #e0e0e0;
+            border: 1px solid #333;
+        }
+
+        .title,
+        .subtitle,
+        .label,
+        .input,
+        .textarea,
+        .select select {
+            color: #e0e0e0;
+        }
+
+        .input,
+        .textarea,
+        .select select {
+            background-color: #2a2a2a;
+            border-color: #444;
+        }
+
+        .input::placeholder,
+        .select select::placeholder,
+        .textarea::placeholder {
+            color: #e0e0e04d;
+        }
+
+        .input:focus,
+        .textarea:focus,
+        .select select:focus {
+            border-color: #666;
+        }
+
+        .box {
+            background-color: #1a1a1a;
+            color: #e0e0e0;
+            border: 1px solid #333;
+        }
+
+        .content h1,
+        .content h2,
+        .content h3,
+        .content h4,
+        .content h5,
+        .content h6,
+        .content label,
+        .content p {
+            color: #e0e0e0;
+        }
+
+        .label {
+            color: #b5b5b5;
+        }
+
+        .menu-label {
+            color: #888;
+        }
+
+        .menu-list a {
+            color: #e0e0e0;
+        }
+
+        .menu-list a:hover {
+            background-color: #333;
+            color: #fff;
+        }
+
+        .menu-list a.is-active {
+            background-color: #3273dc;
+            color: #fff;
+        }
+
+        .table {
+            background-color: #1a1a1a;
+            color: #e0e0e0;
+        }
+
+        .table th {
+            color: #fff;
+        }
+
+        .table tr:hover,
+        .table.is-hoverable tbody tr:not(.is-selected):hover {
+            background-color: #2a2a2a;
+        }
+
+        .table td,
+        .table th {
+            border-color: #333;
+        }
+
+        .modal-card-head,
+        .modal-card-body,
+        .modal-card-foot {
+            background-color: #1a1a1a;
+            border-color: #333;
+            color: #e0e0e0;
+        }
+
+        .modal-card-title {
+            color: #e0e0e0;
+        }
+
+        hr {
+            background-color: #333;
+        }
+
+        strong {
+            color: #fff;
+        }
+
+        /* Space background effect */
+        body::before {
+            content: "";
+            position: fixed;
+            top: 0;
+            left: 0;
+            width: 100%;
+            height: 100%;
+            background-image:
+                radial-gradient(white, rgba(255, 255, 255, .2) 2px, transparent 3px),
+                radial-gradient(white, rgba(255, 255, 255, .15) 1px, transparent 2px),
+                radial-gradient(white, rgba(255, 255, 255, .1) 2px, transparent 3px);
+            background-size: 550px 550px, 350px 350px, 250px 250px;
+            background-position: 0 0, 40 60, 130 270;
+            opacity: 0.1;
+            z-index: -1;
+        }
+    </style>
+</head>
+
+<body>
+    <nav class="navbar" role="navigation" aria-label="main navigation">
+        <div class="navbar-brand">
+            <a class="navbar-item" href="/">
+                <strong>Stellar Frontier</strong>
+            </a>
+        </div>
+
+        <div class="navbar-menu">
+            <div class="navbar-start">
+                <a class="navbar-item" href="/">Home</a>
+                <a class="navbar-item" href="/rules">Rules</a>
+                {% if current_user and current_user.is_authenticated %}
+                <a class="navbar-item" href="/tickets">Support</a>
+                <a class="navbar-item" href="/apply">Apply</a>
+                {% endif %}
+            </div>
+
+            <div class="navbar-end">
+                <div class="navbar-item">
+                    <div class="buttons">
+                        {% if current_user and current_user.is_authenticated %}
+                        {% if current_user.is_admin %}
+                        <a class="button is-warning" href="/admin">
+                            <strong>Admin Panel</strong>
+                        </a>
+                        {% endif %}
+                        <a class="button is-light" href="/profile">
+                            Profile
+                        </a>
+                        <a class="button is-danger" href="/auth/logout">
+                            Log out
+                        </a>
+                        {% else %}
+                        <a class="button is-primary" href="/auth/login">
+                            <strong>Log in with Discord</strong>
+                        </a>
+                        {% endif %}
+                    </div>
+                </div>
+            </div>
+        </div>
+    </nav>
+
+    <section class="section">
+        <div class="container">
+            {% with messages = get_flashed_messages(with_categories=true) %}
+            {% if messages %}
+            {% for category, message in messages %}
+            <div class="notification is-{{ category }}">
+                {{ message }}
+            </div>
+            {% endfor %}
+            {% endif %}
+            {% endwith %}
+
+            {% block content %}{% endblock %}
+        </div>
+    </section>
+
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
+    {% block scripts %}{% endblock %}
+</body>
+
+</html>

+ 23 - 0
stellarwebapp/templates/index.html

@@ -0,0 +1,23 @@
+{% extends "base.html" %}
+
+{% block content %}
+<section class="hero is-medium is-dark">
+    <div class="hero-body has-text-centered">
+        <p class="title">
+            Welcome to Stellar Frontier
+        </p>
+        <p class="subtitle">
+            The ultimate space survival experience.
+        </p>
+        <div class="content">
+            <p>Join us at <code>play.stellarfrontier.io</code></p>
+            <a href="https://discord.gg/example" class="button is-link is-outlined">
+                <span class="icon">
+                    <i class="fab fa-discord"></i>
+                </span>
+                <span>Join our Discord</span>
+            </a>
+        </div>
+    </div>
+</section>
+{% endblock %}

+ 13 - 0
stellarwebapp/templates/rules.html

@@ -0,0 +1,13 @@
+{% extends "base.html" %}
+
+{% block content %}
+<h1 class="title">Server Rules</h1>
+<div class="content">
+    <ol>
+        <li>Be respectful to other players.</li>
+        <li>No griefing or stealing.</li>
+        <li>No hacking or cheating.</li>
+        <li>Have fun!</li>
+    </ol>
+</div>
+{% endblock %}

+ 164 - 0
stellarwebapp/templates/ticket_detail.html

@@ -0,0 +1,164 @@
+{% extends "base.html" %}
+
+{% block content %}
+<div class="columns">
+    <div class="column is-8">
+        <div class="box">
+            <h1 class="title">{{ ticket.title }}</h1>
+            <div class="tags">
+                <span class="tag is-{{ 'success' if ticket.status == 'CLOSED' else 'warning' }}">{{ ticket.status
+                    }}</span>
+                <span class="tag is-info">Created by {{ ticket.creator.username }}</span>
+                <span class="tag is-dark">{{ ticket.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
+            </div>
+
+            <div class="content">
+                <p>{{ ticket.description }}</p>
+            </div>
+
+            {% if ticket.status == 'CLOSED' %}
+            <div class="notification is-warning is-light">
+                <strong>Closed by {{ ticket.closed_by.username }}</strong><br>
+                Reason: {{ ticket.closing_reason }}
+            </div>
+            {% endif %}
+        </div>
+
+        <h2 class="subtitle">Comments</h2>
+        {% for comment in ticket.comments %}
+        {% if not comment.is_hidden or is_admin %}
+        <div class="box {% if comment.is_hidden %}is-hidden-comment{% endif %}"
+            style="{% if comment.is_hidden %}background-color: #3a1a1a; border-color: #5a2a2a;{% endif %}">
+            <article class="media">
+                <div class="media-content">
+                    <div class="content">
+                        <p>
+                            <strong>{{ comment.user.username }}</strong> <small>{{ comment.created_at.strftime('%Y-%m-%d
+                                %H:%M') }}</small>
+                            {% if comment.is_hidden %}
+                            <span class="tag is-danger is-light ml-2">Hidden</span>
+                            {% endif %}
+                            <br>
+                            {{ comment.content }}
+                        </p>
+                    </div>
+                </div>
+                {% if is_admin %}
+                <div class="media-right">
+                    <form method="POST" style="display: inline;">
+                        <input type="hidden" name="action" value="toggle_hidden">
+                        <input type="hidden" name="comment_id" value="{{ comment.id }}">
+                        <button
+                            class="button is-small is-{{ 'success' if comment.is_hidden else 'warning' }} is-outlined">
+                            {{ 'Unhide' if comment.is_hidden else 'Hide' }}
+                        </button>
+                    </form>
+                </div>
+                {% endif %}
+            </article>
+        </div>
+        {% endif %}
+        {% else %}
+        <p>No comments yet.</p>
+        {% endfor %}
+
+        {% if ticket.status != 'CLOSED' %}
+        <div class="box">
+            <form method="POST">
+                <input type="hidden" name="action" value="comment">
+                <div class="field">
+                    <label class="label">Add Comment</label>
+                    <div class="control">
+                        <textarea class="textarea" name="content" placeholder="Type your comment here..."
+                            required></textarea>
+                    </div>
+                </div>
+
+                {% if is_admin %}
+                <div class="field">
+                    <div class="control">
+                        <label class="checkbox">
+                            <input type="checkbox" name="is_hidden">
+                            Admin Comment (Hidden)
+                        </label>
+                    </div>
+                </div>
+                {% endif %}
+
+                <div class="control">
+                    <button class="button is-link">Submit Comment</button>
+                </div>
+            </form>
+        </div>
+        {% endif %}
+    </div>
+
+    <div class="column is-4">
+        <div class="box">
+            <h2 class="subtitle">Actions</h2>
+            {% if ticket.status != 'CLOSED' %}
+            {% if is_admin or is_creator %}
+            <button class="button is-danger is-fullwidth" id="close-btn">Close Ticket</button>
+            {% else %}
+            <p>Only admins or the creator can close this ticket.</p>
+            {% endif %}
+            {% else %}
+            <p>This ticket is closed.</p>
+            {% endif %}
+
+            <hr>
+            <a href="{{ url_for('main.tickets') }}" class="button is-light is-fullwidth">Back to List</a>
+        </div>
+    </div>
+</div>
+
+<!-- Close Modal -->
+<div class="modal" id="close-modal">
+    <div class="modal-background"></div>
+    <div class="modal-card">
+        <header class="modal-card-head">
+            <p class="modal-card-title">Close Ticket</p>
+            <button class="delete" aria-label="close" id="modal-close-x"></button>
+        </header>
+        <section class="modal-card-body">
+            <form method="POST" id="close-form">
+                <input type="hidden" name="action" value="close">
+                <div class="field">
+                    <label class="label">Reason for closing</label>
+                    <div class="control">
+                        <textarea class="textarea" name="reason" placeholder="Resolved, duplicate, etc."
+                            required></textarea>
+                    </div>
+                </div>
+            </form>
+        </section>
+        <footer class="modal-card-foot">
+            <button class="button is-danger" onclick="document.getElementById('close-form').submit()">Confirm
+                Close</button>
+            <button class="button" id="modal-cancel">Cancel</button>
+        </footer>
+    </div>
+</div>
+
+<script>
+    const modal = document.getElementById('close-modal');
+    const closeBtn = document.getElementById('close-btn');
+    const modalCloseX = document.getElementById('modal-close-x');
+    const modalCancel = document.getElementById('modal-cancel');
+    const modalBg = document.querySelector('.modal-background');
+
+    if (closeBtn) {
+        closeBtn.addEventListener('click', () => {
+            modal.classList.add('is-active');
+        });
+    }
+
+    const closeModal = () => {
+        modal.classList.remove('is-active');
+    };
+
+    if (modalCloseX) modalCloseX.addEventListener('click', closeModal);
+    if (modalCancel) modalCancel.addEventListener('click', closeModal);
+    if (modalBg) modalBg.addEventListener('click', closeModal);
+</script>
+{% endblock %}

+ 58 - 0
stellarwebapp/templates/tickets.html

@@ -0,0 +1,58 @@
+{% extends "base.html" %}
+
+{% block content %}
+<h1 class="title">Support Tickets</h1>
+
+<div class="box">
+    <h2 class="subtitle">Create New Ticket</h2>
+    <form method="POST">
+        <div class="field">
+            <label class="label">Title</label>
+            <div class="control">
+                <input class="input" type="text" name="title" placeholder="Brief summary">
+            </div>
+        </div>
+
+        <div class="field">
+            <label class="label">Description</label>
+            <div class="control">
+                <textarea class="textarea" name="description" placeholder="Describe your issue..."></textarea>
+            </div>
+        </div>
+
+        <div class="control">
+            <button class="button is-link">Submit Ticket</button>
+        </div>
+    </form>
+</div>
+
+<div class="box">
+    <h2 class="subtitle">Your Tickets</h2>
+    <table class="table is-fullwidth is-hoverable">
+        <thead>
+            <tr>
+                <th>ID</th>
+                <th>Title</th>
+                <th>Status</th>
+                <th>Date</th>
+            </tr>
+        </thead>
+        <tbody>
+            {% for ticket in tickets %}
+            <tr>
+                <td>{{ ticket.id }}</td>
+                <td><a href="{{ url_for('main.ticket_detail', ticket_id=ticket.id) }}"><strong>{{ ticket.title
+                            }}</strong></a></td>
+                <td><span class="tag is-{{ 'success' if ticket.status == 'CLOSED' else 'warning' }}">{{ ticket.status
+                        }}</span></td>
+                <td>{{ ticket.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
+            </tr>
+            {% else %}
+            <tr>
+                <td colspan="4">No tickets found.</td>
+            </tr>
+            {% endfor %}
+        </tbody>
+    </table>
+</div>
+{% endblock %}

+ 50 - 0
stellarwebapp/templates/user/profile.html

@@ -0,0 +1,50 @@
+{% extends "base.html" %}
+
+{% block content %}
+<h1 class="title">User Profile</h1>
+<div class="box">
+    <div class="media">
+        <div class="media-left">
+            <figure class="image is-64x64">
+                <!-- Placeholder for Discord Avatar -->
+                <img src="https://bulma.io/images/placeholders/128x128.png" alt="Image">
+            </figure>
+        </div>
+        <div class="media-content">
+            <div class="content">
+                <p>
+                    <strong>{{ current_user.username }}</strong>
+                    <br>
+                    Discord ID: {{ current_user.discord_id }}
+                </p>
+            </div>
+        </div>
+    </div>
+</div>
+
+<div class="box">
+    <h2 class="subtitle">Minecraft Account Linking</h2>
+    {% if current_user.minecraft_uuid %}
+    <div class="notification is-success">
+        Linked to Minecraft account: <strong>{{ current_user.minecraft_username }}</strong>
+    </div>
+    {% else %}
+    <div class="notification is-warning">
+        Not linked to any Minecraft account.
+    </div>
+    <p>To link your account, run the following command in-game:</p>
+    <br>
+    <div class="field has-addons">
+        <div class="control is-expanded">
+            <input class="input" type="text" value="/link {{ link_code }}" readonly>
+        </div>
+        <div class="control">
+            <button class="button is-info">
+                Copy
+            </button>
+        </div>
+    </div>
+    <p class="help">This code will expire in 10 minutes.</p>
+    {% endif %}
+</div>
+{% endblock %}

+ 33 - 0
stellarwebapp/update_db.py

@@ -0,0 +1,33 @@
+from app import create_app, db
+from sqlalchemy import text
+
+app = create_app()
+
+with app.app_context():
+    # Add missing columns to tickets table
+    try:
+        with db.engine.connect() as conn:
+            conn.execute(text("ALTER TABLE tickets ADD COLUMN closing_reason TEXT"))
+            conn.execute(text("ALTER TABLE tickets ADD COLUMN closed_by_id INTEGER"))
+            conn.execute(text("ALTER TABLE tickets ADD CONSTRAINT fk_tickets_closed_by FOREIGN KEY (closed_by_id) REFERENCES users(id)"))
+            conn.commit()
+            print("Added columns to tickets table.")
+    except Exception as e:
+        print(f"Error updating tickets table (might already exist): {e}")
+
+    # Add is_hidden to ticket_comments table
+    try:
+        with db.engine.connect() as conn:
+            conn.execute(text("ALTER TABLE ticket_comments ADD COLUMN is_hidden BOOLEAN DEFAULT FALSE"))
+            conn.commit()
+            print("Added is_hidden to ticket_comments table.")
+    except Exception as e:
+        print(f"Error updating ticket_comments table (might already exist): {e}")
+
+    # Create missing tables (including link_codes)
+    try:
+        # This will create any missing tables, including ticket_comments and link_codes
+        db.create_all()
+        print("Created missing tables.")
+    except Exception as e:
+        print(f"Error creating tables: {e}")

+ 53 - 0
verify_system.py

@@ -0,0 +1,53 @@
+import socketio
+import requests
+import time
+import json
+
+# Test API
+def test_api():
+    print("Testing API...")
+    # Simulate a link request
+    # Note: This will fail if the code is not in the server's memory, 
+    # but we just want to see if the endpoint is reachable.
+    try:
+        response = requests.post('http://localhost:5000/api/link', json={
+            'code': 'TESTCODE',
+            'uuid': 'test-uuid',
+            'username': 'TestPlayer'
+        })
+        print(f"API Response: {response.status_code} - {response.json()}")
+    except Exception as e:
+        print(f"API Connection Failed: {e}")
+
+# Test WebSocket
+def test_websocket():
+    print("Testing WebSocket...")
+    sio = socketio.Client()
+
+    @sio.event
+    def connect():
+        print("WebSocket Connected!")
+        # Send a test event
+        sio.emit('game_event', {
+            'type': 'CHAT',
+            'player': 'TestPlayer',
+            'uuid': 'test-uuid',
+            'content': 'Hello from test script!'
+        })
+        print("Test event sent.")
+        sio.disconnect()
+
+    @sio.event
+    def connect_error(data):
+        print(f"WebSocket Connection Failed: {data}")
+
+    try:
+        sio.connect('http://localhost:5000')
+        sio.wait()
+    except Exception as e:
+        print(f"WebSocket Error: {e}")
+
+if __name__ == "__main__":
+    # Ensure the server is running before running this script
+    test_api()
+    test_websocket()