Compare commits
7 Commits
cbe7ddc51b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d3a71e1d0f | |||
| 09e65803c9 | |||
| c38a33a7aa | |||
| 53b673f86d | |||
| b6c2506bd4 | |||
| 9ee7281b6b | |||
| 3c18473588 |
18
admin.go
18
admin.go
@@ -15,7 +15,10 @@ type difficultyLevel struct {
|
|||||||
func adminLoginHandler(w http.ResponseWriter, r *http.Request) {
|
func adminLoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
http.ServeFile(w, r, "templates/adminLogin.html")
|
err := adminLoginTemplate.Execute(w, false)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Could not render template", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
http.Error(w, "Error parsing form", http.StatusBadRequest)
|
http.Error(w, "Error parsing form", http.StatusBadRequest)
|
||||||
@@ -29,7 +32,10 @@ func adminLoginHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
).Scan(new(int))
|
).Scan(new(int))
|
||||||
switch {
|
switch {
|
||||||
case err == sql.ErrNoRows:
|
case err == sql.ErrNoRows:
|
||||||
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
err := adminLoginTemplate.Execute(w, true)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Could not render template", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
case err != nil:
|
case err != nil:
|
||||||
http.Error(w, "Database error", http.StatusInternalServerError)
|
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||||
default:
|
default:
|
||||||
@@ -38,6 +44,8 @@ func adminLoginHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
Value: base64.StdEncoding.EncodeToString([]byte(username + ":" + hashPassword(password))),
|
Value: base64.StdEncoding.EncodeToString([]byte(username + ":" + hashPassword(password))),
|
||||||
Path: "/admin/",
|
Path: "/admin/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
Secure: true,
|
||||||
})
|
})
|
||||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
@@ -61,8 +69,8 @@ func isAdmin(r *http.Request) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
var username, passwordHash string
|
var username, passwordHash string
|
||||||
regexp := regexp.MustCompile(`^([^:]+):([a-f0-9]+)$`)
|
regex := regexp.MustCompile(`^([^:]+):([a-f0-9]+)$`)
|
||||||
matches := regexp.FindStringSubmatch(string(decoded))
|
matches := regex.FindStringSubmatch(string(decoded))
|
||||||
if len(matches) != 3 {
|
if len(matches) != 3 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -80,7 +88,7 @@ func adminHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.ServeFile(w, r, "templates/adminPanel.html")
|
http.ServeFileFS(w, r, templatesFS, "templates/adminPanel.html")
|
||||||
}
|
}
|
||||||
|
|
||||||
func adminTeamsHandler(w http.ResponseWriter, r *http.Request) {
|
func adminTeamsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
67
klice.go
67
klice.go
@@ -8,6 +8,7 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
)
|
)
|
||||||
@@ -40,7 +41,10 @@ func loginHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
err := db.QueryRow("SELECT 1 FROM teams WHERE password = ?", hashedPassword).Scan(new(int))
|
err := db.QueryRow("SELECT 1 FROM teams WHERE password = ?", hashedPassword).Scan(new(int))
|
||||||
switch {
|
switch {
|
||||||
case err == sql.ErrNoRows:
|
case err == sql.ErrNoRows:
|
||||||
http.Error(w, "No team found", http.StatusUnauthorized)
|
err = LoginTemplate.Execute(w, true)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Could not render template", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
case err != nil:
|
case err != nil:
|
||||||
http.Error(w, "Could not retrieve team", http.StatusInternalServerError)
|
http.Error(w, "Could not retrieve team", http.StatusInternalServerError)
|
||||||
@@ -53,6 +57,7 @@ func loginHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
Secure: true,
|
||||||
}
|
}
|
||||||
http.SetCookie(w, cookie)
|
http.SetCookie(w, cookie)
|
||||||
|
|
||||||
@@ -60,13 +65,16 @@ func loginHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
redir.MaxAge = -1
|
redir.MaxAge = -1
|
||||||
http.SetCookie(w, redir)
|
http.SetCookie(w, redir)
|
||||||
http.Redirect(w, r, redir.Value, http.StatusSeeOther)
|
http.Redirect(w, r, safeRedirectURL(redir.Value), http.StatusSeeOther)
|
||||||
} else {
|
} else {
|
||||||
http.Redirect(w, r, "/team", http.StatusSeeOther)
|
http.Redirect(w, r, "/team", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
http.ServeFile(w, r, "templates/login.html")
|
err := LoginTemplate.Execute(w, false)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Could not render template", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
}
|
}
|
||||||
@@ -109,6 +117,7 @@ func isLoggedIn(w http.ResponseWriter, r *http.Request) (bool, int) {
|
|||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
Secure: true,
|
||||||
}
|
}
|
||||||
http.SetCookie(w, redir)
|
http.SetCookie(w, redir)
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
@@ -146,8 +155,8 @@ func teamInfoHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func qrHandler(w http.ResponseWriter, r *http.Request) {
|
func qrHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
uid, found := strings.CutPrefix(r.URL.Path, "/qr/")
|
uid := r.PathValue("qr")
|
||||||
if !found || uid == "" {
|
if uid == "" {
|
||||||
http.Error(w, "Invalid QR code", http.StatusBadRequest)
|
http.Error(w, "Invalid QR code", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -228,18 +237,20 @@ func qrHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// get penalties for this task and team
|
// get penalties for this task and team
|
||||||
err = db.QueryRow("SELECT minutes FROM penalties WHERE team_id = ? AND task_id = ?", teamID, taskID).Scan(&penalty)
|
if help == 0 {
|
||||||
if err == sql.ErrNoRows {
|
err = db.QueryRow("SELECT minutes FROM penalties WHERE team_id = ? AND task_id = ?", teamID, taskID).Scan(&penalty)
|
||||||
penalty = 0
|
if err == sql.ErrNoRows {
|
||||||
} else if err != nil {
|
penalty = 0
|
||||||
http.Error(w, "Could not retrieve penalties", http.StatusInternalServerError)
|
} else if err != nil {
|
||||||
return
|
http.Error(w, "Could not retrieve penalties", http.StatusInternalServerError)
|
||||||
}
|
return
|
||||||
// determine help level based on penalties
|
}
|
||||||
if penalty > 0 && penalty <= smallHelpPenalty {
|
// determine help level based on penalties
|
||||||
help = 1
|
if penalty > 0 && penalty <= smallHelpPenalty {
|
||||||
} else if penalty > smallHelpPenalty {
|
help = 1
|
||||||
help = 2
|
} else if penalty > smallHelpPenalty {
|
||||||
|
help = 2
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handle answer and help form submission
|
// handle answer and help form submission
|
||||||
@@ -284,7 +295,7 @@ func qrHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "Could not retrieve help text", http.StatusInternalServerError)
|
http.Error(w, "Could not retrieve help text", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
CipherTemplateData.HelpText = helpText
|
CipherTemplateData.HelpText = template.HTML(helpText)
|
||||||
case 2: // next cipher
|
case 2: // next cipher
|
||||||
// get end clue
|
// get end clue
|
||||||
var endClue string
|
var endClue string
|
||||||
@@ -329,6 +340,13 @@ func qrHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func safeRedirectURL(u string) string {
|
||||||
|
if strings.HasPrefix(u, "/") && !strings.HasPrefix(u, "//") {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
return "/team"
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var err error
|
var err error
|
||||||
db, err = sql.Open("sqlite3", dbFile+"?_fk=on")
|
db, err = sql.Open("sqlite3", dbFile+"?_fk=on")
|
||||||
@@ -343,7 +361,7 @@ func main() {
|
|||||||
http.HandleFunc("/login", loginHandler)
|
http.HandleFunc("/login", loginHandler)
|
||||||
http.HandleFunc("/logout", logoutHandler)
|
http.HandleFunc("/logout", logoutHandler)
|
||||||
http.HandleFunc("/team", teamInfoHandler)
|
http.HandleFunc("/team", teamInfoHandler)
|
||||||
http.HandleFunc("/qr/", qrHandler)
|
http.HandleFunc("/qr/{qr...}", qrHandler)
|
||||||
// admin app
|
// admin app
|
||||||
http.HandleFunc("/admin/login", adminLoginHandler)
|
http.HandleFunc("/admin/login", adminLoginHandler)
|
||||||
http.HandleFunc("/admin/logout", adminLogoutHandler)
|
http.HandleFunc("/admin/logout", adminLogoutHandler)
|
||||||
@@ -360,6 +378,15 @@ func main() {
|
|||||||
// static files
|
// static files
|
||||||
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: ":8080",
|
||||||
|
Handler: nil,
|
||||||
|
ReadTimeout: 10 * time.Second, // zabrání Slowloris útokům
|
||||||
|
WriteTimeout: 15 * time.Second, // omezení dlouhých odpovědí
|
||||||
|
IdleTimeout: 60 * time.Second, // ukončení nečinných spojení
|
||||||
|
MaxHeaderBytes: 1 << 20, // max. 1 MB hlavičky
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Println("Server started at :8080")
|
fmt.Println("Server started at :8080")
|
||||||
http.ListenAndServe(":8080", nil)
|
srv.ListenAndServe()
|
||||||
}
|
}
|
||||||
|
|||||||
28
templates.go
28
templates.go
@@ -1,14 +1,18 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"embed"
|
||||||
"html/template"
|
"html/template"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
//go:embed templates/*.html
|
||||||
|
var templatesFS embed.FS
|
||||||
|
|
||||||
type CipherTemplateS struct {
|
type CipherTemplateS struct {
|
||||||
ID int
|
ID int
|
||||||
Order uint
|
Order uint
|
||||||
Assignment template.HTML
|
Assignment template.HTML
|
||||||
HelpText string
|
HelpText template.HTML
|
||||||
FinalClue string
|
FinalClue string
|
||||||
Coordinates string
|
Coordinates string
|
||||||
PositionHint string
|
PositionHint string
|
||||||
@@ -53,7 +57,7 @@ type AdminCipherTemplateS struct {
|
|||||||
ID int
|
ID int
|
||||||
Assignment template.HTML
|
Assignment template.HTML
|
||||||
Solution string
|
Solution string
|
||||||
Clue string
|
Clue template.HTML
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminPositionsTemplateS struct {
|
type AdminPositionsTemplateS struct {
|
||||||
@@ -86,12 +90,14 @@ type AdminPenaltiesTemplateS struct {
|
|||||||
Minutes int
|
Minutes int
|
||||||
}
|
}
|
||||||
|
|
||||||
var CipherTemplate = template.Must(template.ParseFiles("templates/assignment.html"))
|
var CipherTemplate = template.Must(template.ParseFS(templatesFS, "templates/assignment.html"))
|
||||||
var TeamTemplate = template.Must(template.ParseFiles("templates/team.html"))
|
var TeamTemplate = template.Must(template.ParseFS(templatesFS, "templates/team.html"))
|
||||||
var AdminTeamsTemplate = template.Must(template.ParseFiles("templates/adminTeams.html"))
|
var AdminTeamsTemplate = template.Must(template.ParseFS(templatesFS, "templates/adminTeams.html"))
|
||||||
var AdminRoutesTemplate = template.Must(template.ParseFiles("templates/adminRoutes.html"))
|
var AdminRoutesTemplate = template.Must(template.ParseFS(templatesFS, "templates/adminRoutes.html"))
|
||||||
var AdminLevelTemplate = template.Must(template.ParseFiles("templates/adminLevels.html"))
|
var AdminLevelTemplate = template.Must(template.ParseFS(templatesFS, "templates/adminLevels.html"))
|
||||||
var AdminCipherTemplate = template.Must(template.ParseFiles("templates/adminCiphers.html"))
|
var AdminCipherTemplate = template.Must(template.ParseFS(templatesFS, "templates/adminCiphers.html"))
|
||||||
var AdminPositionsTemplate = template.Must(template.ParseFiles("templates/adminPositions.html"))
|
var AdminPositionsTemplate = template.Must(template.ParseFS(templatesFS, "templates/adminPositions.html"))
|
||||||
var AdminQRsTemplate = template.Must(template.ParseFiles("templates/adminQR.html"))
|
var AdminQRsTemplate = template.Must(template.ParseFS(templatesFS, "templates/adminQR.html"))
|
||||||
var AdminPenaltiesTemplate = template.Must(template.ParseFiles("templates/adminPenalties.html"))
|
var AdminPenaltiesTemplate = template.Must(template.ParseFS(templatesFS, "templates/adminPenalties.html"))
|
||||||
|
var LoginTemplate = template.Must(template.ParseFS(templatesFS, "templates/login.html"))
|
||||||
|
var adminLoginTemplate = template.Must(template.ParseFS(templatesFS, "templates/adminLogin.html"))
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
<label for="solution">Řešení:</label>
|
<label for="solution">Řešení:</label>
|
||||||
<input type="text" id="solution" name="solution" required>
|
<input type="text" id="solution" name="solution" required>
|
||||||
<label for="clue">Nápověda:</label>
|
<label for="clue">Nápověda:</label>
|
||||||
<input type="text" id="clue" name="clue" required>
|
<textarea id="clue" name="clue" cols="40" rows="5" required></textarea>
|
||||||
<input type="submit" value="Přidat šifru">
|
<input type="submit" value="Přidat šifru">
|
||||||
</form>
|
</form>
|
||||||
<hr>
|
<hr>
|
||||||
|
|||||||
@@ -2,17 +2,33 @@
|
|||||||
<html lang="cs">
|
<html lang="cs">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="utf-8">
|
||||||
<title>Admin Login</title>
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Admin — přihlášení</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<h1>Admin Login</h1>
|
<h1>Admin Login</h1>
|
||||||
<form method="post">
|
|
||||||
Uživatelské jméno: <input type="text" name="username"><br>
|
{{if .}}
|
||||||
Heslo: <input type="password" name="password"><br>
|
<p style="color: red;"><strong>Chybné uživatelské jméno nebo heslo.</strong></p>
|
||||||
<input type="submit" value="Přihlásit se">
|
{{end}}
|
||||||
|
|
||||||
|
<form method="post" autocomplete="off">
|
||||||
|
<div>
|
||||||
|
<label for="username">Uživatelské jméno</label><br>
|
||||||
|
<input id="username" name="username" type="text" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password">Heslo</label><br>
|
||||||
|
<input id="password" name="password" type="password" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="submit" value="Přihlásit se">
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<p><a href="/admin">Zpět na admin panel</a></p>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -2,56 +2,269 @@
|
|||||||
<html lang="cs">
|
<html lang="cs">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Zadání šifry {{.Order}}</title>
|
<title>Zadání šifry {{.Order}}</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f5f7fb;
|
||||||
|
--card: #ffffff;
|
||||||
|
--accent: #0b6efd;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--border: #e6e9ee;
|
||||||
|
--radius: 12px;
|
||||||
|
--gap: 14px;
|
||||||
|
font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(180deg, #f8fbff, var(--bg));
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 28px auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 8px 24px rgba(20, 30, 60, 0.06);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 18px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--gap);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(11, 110, 253, 0.12);
|
||||||
|
background: linear-gradient(180deg, var(--accent), #0654d6);
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.ghost {
|
||||||
|
background: #fff;
|
||||||
|
color: #0f172a;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.warn {
|
||||||
|
background: var(--danger);
|
||||||
|
border-color: rgba(239, 68, 68, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.helpbox {
|
||||||
|
background: #f8faff;
|
||||||
|
border: 1px solid rgba(11, 110, 253, 0.08);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #0b2540;
|
||||||
|
}
|
||||||
|
|
||||||
|
.solution {
|
||||||
|
background: #fbffef;
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.12);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #0b3a12;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="submit"],
|
||||||
|
button[type="button"] {
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[readonly] {
|
||||||
|
background: #f3f4f6;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--danger);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width:640px) {
|
||||||
|
.controls {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<h1>Zadání šifry {{.Order}}</h1>
|
<div class="wrap">
|
||||||
<p>{{.Assignment}}</p>
|
<div class="card">
|
||||||
<hr>
|
<h1>Zadání šifry {{.Order}}</h1>
|
||||||
{{if eq .Help 0}}
|
<div class="meta">Aktuální úkol</div>
|
||||||
<p>Požádat o malou nápovědu.</p>
|
|
||||||
<form method="post">
|
<div class="assignment">
|
||||||
<input type="hidden" name="help" value="1">
|
{{.Assignment}}
|
||||||
<input type="submit" value="Zobrazit nápovědu">
|
</div>
|
||||||
</form>
|
|
||||||
{{else if eq .Help 1}}
|
<div class="controls">
|
||||||
<p>Nápověda: {{.HelpText}}</p>
|
{{if eq .Help 0}}
|
||||||
<p>Vzdát se a ukázat pozici další šifry.</p>
|
<div>
|
||||||
<form method="post">
|
<div class="muted">Požádat o malou nápovědu</div>
|
||||||
<input type="hidden" name="help" value="2">
|
<form method="post" style="margin-top:8px;">
|
||||||
<input type="submit" value="Vzdát se">
|
<input type="hidden" name="help" value="1">
|
||||||
</form>
|
<input class="btn" type="submit" value="Zobrazit nápovědu">
|
||||||
{{else}}
|
</form>
|
||||||
<p>Řešení: {{.Solution}}</p>
|
</div>
|
||||||
{{end}}
|
{{else if eq .Help 1}}
|
||||||
<hr>
|
<div style="flex:1">
|
||||||
{{if ne .Help 2}}
|
<div class="helpbox">Nápověda: {{.HelpText}}</div>
|
||||||
{{if .Wrong}}<p style="color:red;">Špatné řešení, zkus to znovu.</p>{{end}}
|
<div style="margin-top:12px">
|
||||||
<form method="post">
|
<div class="muted">Pokud se chcete vzdát a získat pozici další šifry:</div>
|
||||||
Řešení: <input type="text" name="solution"><br>
|
<form method="post" style="margin-top:8px;">
|
||||||
<input type="submit" value="Odeslat">
|
<input type="hidden" name="help" value="2">
|
||||||
</form>
|
<input class="btn warn" type="submit" value="Vzdát se">
|
||||||
{{else}}
|
</form>
|
||||||
<p>
|
</div>
|
||||||
Souřadnice další šifry:
|
</div>
|
||||||
<input id="gps" value="{{.Coordinates}}" readonly />
|
{{else}}
|
||||||
<br>
|
<div style="flex:1">
|
||||||
<button onclick="copyToClipboard()">Zkopírovat do schránky</button>
|
<div class="solution">Řešení: {{.Solution}}</div>
|
||||||
<br>
|
</div>
|
||||||
Nápověda k nalezení pozice: {{.PositionHint}}
|
{{end}}
|
||||||
</p>
|
</div>
|
||||||
<p>Nápověda k nalezení cíle: {{.FinalClue}}</p>
|
|
||||||
{{end}}
|
<hr>
|
||||||
|
|
||||||
|
{{if ne .Help 2}}
|
||||||
|
{{if .Wrong}}<p class="error">Špatné řešení, zkus to znovu.</p>{{end}}
|
||||||
|
<form method="post" style="margin-top:12px;">
|
||||||
|
<label for="solution">Zadejte řešení</label>
|
||||||
|
<div class="row">
|
||||||
|
<input id="solution" name="solution" type="text" autocomplete="off"
|
||||||
|
placeholder="Zadejte odpověď...">
|
||||||
|
<input class="btn ghost" type="submit" value="Odeslat">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<div>
|
||||||
|
<p class="muted">Souřadnice další šifry</p>
|
||||||
|
<div class="row">
|
||||||
|
<div class="gps" style="flex:1">
|
||||||
|
<input id="gps" value="{{.Coordinates}}" readonly>
|
||||||
|
<button class="btn ghost" type="button" onclick="copyToClipboard()">Zkopírovat</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="muted" style="margin-top:8px">Nápověda k nalezení pozice: <strong>{{.PositionHint}}</strong>
|
||||||
|
</p>
|
||||||
|
<p class="muted">Nápověda k nalezení cíle: <strong>{{.FinalClue}}</strong></p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function copyToClipboard() {
|
||||||
|
const el = document.getElementById('gps');
|
||||||
|
if (!el) return;
|
||||||
|
const text = el.value || el.innerText;
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(text).catch(() => { fallbackCopy(el); });
|
||||||
|
} else {
|
||||||
|
fallbackCopy(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function fallbackCopy(el) {
|
||||||
|
el.select ? el.select() : null;
|
||||||
|
try {
|
||||||
|
document.execCommand('copy');
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
window.getSelection ? window.getSelection().removeAllRanges() : null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
<script>
|
|
||||||
function copyToClipboard() {
|
|
||||||
let copyText = document.querySelector("#gps");
|
|
||||||
copyText.select();
|
|
||||||
document.execCommand("copy");
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -3,17 +3,123 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>Login</title>
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Přihlášení</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f5f7fb;
|
||||||
|
--card: #ffffff;
|
||||||
|
--accent: #0b6efd;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--radius: 12px;
|
||||||
|
--border: #e6e9ee;
|
||||||
|
--error-bg: #fff1f2;
|
||||||
|
--error-border: #fecaca;
|
||||||
|
font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(180deg, #f8fbff, var(--bg));
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 72px auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--card);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 10px 30px rgba(20, 30, 60, 0.06);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: var(--error-bg);
|
||||||
|
border: 1px solid var(--error-border);
|
||||||
|
color: #9f1239;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="password"] {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width:420px) {
|
||||||
|
.wrap {
|
||||||
|
margin: 32px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<h1>Login</h1>
|
<div class="wrap">
|
||||||
<form action="/login" method="post">
|
<div class="card">
|
||||||
<label for="password">Heslo:</label>
|
<h1>Přihlášení do týmu</h1>
|
||||||
<input type="password" id="password" name="password" required>
|
<div class="sub">Zadejte své týmové heslo</div>
|
||||||
<br>
|
|
||||||
<button type="submit">Přihlásit se</button>
|
{{if .}}
|
||||||
</form>
|
<div class="error">Chybné heslo — zkuste to prosím znovu.</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form action="/login" method="post">
|
||||||
|
<div>
|
||||||
|
<label for="password">Heslo</label>
|
||||||
|
<input id="password" name="password" type="password" required autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn" type="submit">Přihlásit se</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -3,18 +3,112 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Tým: {{.TeamName}}</title>
|
<title>Tým: {{.TeamName}}</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f5f7fb;
|
||||||
|
--card: #ffffff;
|
||||||
|
--accent: #0b6efd;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--radius: 12px;
|
||||||
|
--border: #e6e9ee;
|
||||||
|
font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(180deg, #f8fbff, var(--bg));
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 36px auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--card);
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 8px 20px rgba(20, 30, 60, 0.06);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(11, 110, 253, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width:560px) {
|
||||||
|
.info {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<h1>Tým: {{.TeamName}}</h1>
|
<div class="wrap">
|
||||||
Obtížnost: {{.Difficulty}}<br>
|
<div class="card">
|
||||||
Poslední šifra : {{.LastCipher}}<br>
|
<h1>Tým: {{.TeamName}}</h1>
|
||||||
Penalizace: {{.Penalties}}<br>
|
<div class="meta">Přehled týmu</div>
|
||||||
<hr>
|
|
||||||
<form action="/logout" method="get">
|
<div class="info">
|
||||||
<input type="submit" value="Odhlásit">
|
<div class="details">
|
||||||
</form>
|
<div><span class="label">Obtížnost:</span> {{.Difficulty}}</div>
|
||||||
|
<div><span class="label">Právě řeší šifru:</span> {{.LastCipher}}</div>
|
||||||
|
<div><span class="label">Penalizace (min):</span> {{.Penalties}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action="/logout" method="get">
|
||||||
|
<button class="logout" type="submit">Odhlásit</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
Reference in New Issue
Block a user