Compare commits
10 Commits
5f9ac3d4a9
...
699cbfa79e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
699cbfa79e | ||
|
|
64126b1fc0 | ||
|
|
04563d3c28 | ||
|
|
08c6afeecd | ||
|
|
46ccc879da | ||
|
|
b0e8a43331 | ||
|
|
08b8aad09d | ||
|
|
c121593938 | ||
|
|
51c559b7e0 | ||
|
|
f1ca80cbae |
16
README.md
16
README.md
@@ -38,7 +38,7 @@ Few of them send back HTML. That's accomplished by filling in an intermediary st
|
|||||||
|
|
||||||
### pkg/model
|
### pkg/model
|
||||||
|
|
||||||
Model hosts all business logic, that is, all actions the user can make regardless of the protocol of invocation. Each exported method represent a logical action on its own and must depend on the model struct and its arguments only. This is crucial for testing.
|
Model hosts all business logic, that is, all actions the user can make regardless of the protocol of invocation. Each exported method represents a logical action on its own and must depend on the model struct and its arguments only. This is crucial for testing.
|
||||||
|
|
||||||
Except for edge cases, each method shall accept context.Context and use it to pass cancellation signals database calls etc.
|
Except for edge cases, each method shall accept context.Context and use it to pass cancellation signals database calls etc.
|
||||||
|
|
||||||
@@ -56,10 +56,18 @@ Thanks to the strict separation of different parts of MVC, the model can be test
|
|||||||
|
|
||||||
Brief end-to-end testing may be added later.
|
Brief end-to-end testing may be added later.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
Tinyquiz can hardly be viewed as critical application or as containing sensitive information, therefore certain trade-offs were accepted.
|
||||||
|
|
||||||
|
There is no classical authentication; your identity is determined by the id contained in URL. This is usually viewed as bad practice, because the token gets saved in your browsing history, but in Tinyquiz, it becomes effectively worthless as soon as the quiz ends. On the other hand, it enables you to play multiple games in different browser tabs at the same time and to reaload the tabs anytime without loosing state - all while keeping the implementation very simple.
|
||||||
|
|
||||||
|
Games and quizzes aren't protected by any password, just the code. It is pretty secure though, the code is made by concatenating a sequential part (to prevent collisions) and a random part (to make it difficult to guess).
|
||||||
|
|
||||||
|
Unlike the previous part, no trade-offs were accepted in the server security. Go is a GCed language doing its best to prevent memory corruption bugs. All database queries are assembled by passing the user supplied input separately thus preventing SQL injection. HTML output is handled by the well tested `html/template` standard library which automatically context-aware escapes included content thus preventing XSS.
|
||||||
|
|
||||||
## Building and running
|
## Building and running
|
||||||
|
|
||||||
*Go 1.16 is expected to be released in February 2021. It will bring static files embedding into the binary. Until it happens, the binary has to be run from the root of the project to be able to find the `ui` directory.*
|
The reference way to build the app is in the `flake.nix`. For non-Nix users, building shall be pretty trivial though. Just obtain a new enough version of Go (see `go.mod`) and build the runnable package of your choice (usually `go build ./cmd/web`). You may have to run `go generate ./...` to build the ORM files etc.
|
||||||
|
|
||||||
The reference way to build the app is in the `flake.nix`. For non-Nix users, building shall be pretty trivial though. Just obtain a new enough version of Go (see `go.mod`) and build the runnable package of your choice (usually `go build ./cmd/web`).
|
|
||||||
|
|
||||||
Tinyquiz requires Postgresql, though other database systems might be added later thanks to ent. Postgresql configuration is currently hardcoded in the binary.
|
Tinyquiz requires Postgresql, though other database systems might be added later thanks to ent. Postgresql configuration is currently hardcoded in the binary.
|
||||||
|
|||||||
@@ -14,12 +14,36 @@ import (
|
|||||||
"vkane.cz/tinyquiz/pkg/rtcomm"
|
"vkane.cz/tinyquiz/pkg/rtcomm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (app *application) home(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
func (app *application) homeSuccess(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||||
|
app.home(w, r, homeForm{}, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
type homeForm struct {
|
||||||
|
Join struct {
|
||||||
|
Code string
|
||||||
|
Name string
|
||||||
|
Errors []string
|
||||||
|
}
|
||||||
|
NewSession struct {
|
||||||
|
Code string
|
||||||
|
Name string
|
||||||
|
Errors []string
|
||||||
|
}
|
||||||
|
NewGame struct {
|
||||||
|
Title string
|
||||||
|
Name string
|
||||||
|
Errors []string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) home(w http.ResponseWriter, r *http.Request, formData homeForm, status int) {
|
||||||
type homeData struct {
|
type homeData struct {
|
||||||
Stats model.Stats
|
Stats model.Stats
|
||||||
|
Form homeForm
|
||||||
templateData
|
templateData
|
||||||
}
|
}
|
||||||
td := &homeData{}
|
td := &homeData{}
|
||||||
|
td.Form = formData
|
||||||
setDefaultTemplateData(&td.templateData)
|
setDefaultTemplateData(&td.templateData)
|
||||||
|
|
||||||
if stats, err := app.model.GetStats(r.Context()); err == nil {
|
if stats, err := app.model.GetStats(r.Context()); err == nil {
|
||||||
@@ -28,10 +52,14 @@ func (app *application) home(w http.ResponseWriter, r *http.Request, params http
|
|||||||
app.serverError(w, err)
|
app.serverError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
w.WriteHeader(status)
|
||||||
app.render(w, r, "home.page.tmpl.html", td)
|
app.render(w, r, "home.page.tmpl.html", td)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (app *application) help(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||||
|
app.render(w, r, "help.page.tmpl.html", nil)
|
||||||
|
}
|
||||||
|
|
||||||
func (app *application) play(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
func (app *application) play(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
app.clientError(w, http.StatusBadRequest)
|
app.clientError(w, http.StatusBadRequest)
|
||||||
@@ -40,9 +68,13 @@ func (app *application) play(w http.ResponseWriter, r *http.Request, params http
|
|||||||
|
|
||||||
var code = strings.TrimSpace(params.ByName("code"))
|
var code = strings.TrimSpace(params.ByName("code"))
|
||||||
var player = strings.ToLower(strings.TrimSpace(r.PostForm.Get("player")))
|
var player = strings.ToLower(strings.TrimSpace(r.PostForm.Get("player")))
|
||||||
|
var form homeForm
|
||||||
|
form.Join.Code = code
|
||||||
|
form.Join.Name = player
|
||||||
|
|
||||||
if len(player) < 1 {
|
if len(player) < 1 {
|
||||||
app.clientError(w, http.StatusBadRequest)
|
form.Join.Errors = []string{"Zadejte jméno hráče"}
|
||||||
|
app.home(w, r, form, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,10 +93,12 @@ func (app *application) play(w http.ResponseWriter, r *http.Request, params http
|
|||||||
http.Redirect(w, r, "/game/"+url.PathEscape(player.ID.String()), http.StatusSeeOther)
|
http.Redirect(w, r, "/game/"+url.PathEscape(player.ID.String()), http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
} else if errors.Is(err, model.NoSuchEntity) {
|
} else if errors.Is(err, model.NoSuchEntity) {
|
||||||
app.clientError(w, http.StatusNotFound)
|
form.Join.Errors = []string{"Hra s tímto kódem nebyla nalezena"}
|
||||||
|
app.home(w, r, form, http.StatusNotFound)
|
||||||
return
|
return
|
||||||
} else if errors.Is(err, model.ConstraintViolation) {
|
} else if errors.Is(err, model.ConstraintViolation) {
|
||||||
app.clientError(w, http.StatusForbidden)
|
form.Join.Errors = []string{"Hráč s tímto jménem již existuje"}
|
||||||
|
app.home(w, r, form, http.StatusForbidden)
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
app.serverError(w, err)
|
app.serverError(w, err)
|
||||||
@@ -81,9 +115,13 @@ func (app *application) createSession(w http.ResponseWriter, r *http.Request, pa
|
|||||||
|
|
||||||
var code = strings.TrimSpace(r.PostForm.Get("code"))
|
var code = strings.TrimSpace(r.PostForm.Get("code"))
|
||||||
var player = strings.ToLower(strings.TrimSpace(r.PostForm.Get("organiser")))
|
var player = strings.ToLower(strings.TrimSpace(r.PostForm.Get("organiser")))
|
||||||
|
var form homeForm
|
||||||
|
form.NewSession.Code = code
|
||||||
|
form.NewSession.Name = player
|
||||||
|
|
||||||
if len(player) < 1 {
|
if len(player) < 1 {
|
||||||
app.clientError(w, http.StatusBadRequest)
|
form.NewSession.Errors = []string{"Zadejte jméno organizátora"}
|
||||||
|
app.home(w, r, form, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +135,8 @@ func (app *application) createSession(w http.ResponseWriter, r *http.Request, pa
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else if errors.Is(err, model.NoSuchEntity) {
|
} else if errors.Is(err, model.NoSuchEntity) {
|
||||||
app.clientError(w, http.StatusNotFound)
|
form.NewSession.Errors = []string{"Hra s tímto kódem nebyla nalezena"}
|
||||||
|
app.home(w, r, form, http.StatusNotFound)
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
app.serverError(w, err)
|
app.serverError(w, err)
|
||||||
@@ -253,9 +292,13 @@ func (app *application) createGame(w http.ResponseWriter, r *http.Request, param
|
|||||||
}
|
}
|
||||||
var name = r.PostFormValue("name")
|
var name = r.PostFormValue("name")
|
||||||
var author = r.PostFormValue("author")
|
var author = r.PostFormValue("author")
|
||||||
|
var form homeForm
|
||||||
|
form.NewGame.Title = name
|
||||||
|
form.NewGame.Name = author
|
||||||
file, _, err := r.FormFile("game")
|
file, _, err := r.FormFile("game")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.clientError(w, http.StatusBadRequest)
|
form.NewGame.Errors = []string{"Nahrajte soubor s otázkami"}
|
||||||
|
app.home(w, r, form, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,11 +310,9 @@ func (app *application) createGame(w http.ResponseWriter, r *http.Request, param
|
|||||||
app.serverError(w, err)
|
app.serverError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else if errors.Is(err, gameCreator.ErrInvalidSyntax) || errors.Is(err, gameCreator.ErrTooManyQuestions) || errors.Is(err, gameCreator.ErrTooManyChoices) {
|
|
||||||
app.clientError(w, http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
} else {
|
} else {
|
||||||
app.clientError(w, http.StatusBadRequest)
|
form.NewGame.Errors = []string{"Soubor s otázkami není v pořádku"}
|
||||||
|
app.home(w, r, form, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"vkane.cz/tinyquiz/pkg/model"
|
"vkane.cz/tinyquiz/pkg/model"
|
||||||
"vkane.cz/tinyquiz/pkg/model/ent"
|
"vkane.cz/tinyquiz/pkg/model/ent"
|
||||||
@@ -35,8 +37,14 @@ func setDefaultTemplateData(td *templateData) {
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var addr string
|
var addr string
|
||||||
|
var socket bool
|
||||||
if env, ok := os.LookupEnv("TINYQUIZ_LISTEN"); ok {
|
if env, ok := os.LookupEnv("TINYQUIZ_LISTEN"); ok {
|
||||||
addr = env
|
addr = env
|
||||||
|
const unixPrefix = "unix:"
|
||||||
|
if strings.HasPrefix(addr, unixPrefix) {
|
||||||
|
socket = true
|
||||||
|
addr = strings.TrimPrefix(addr, unixPrefix)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
addr = "[::1]:8080"
|
addr = "[::1]:8080"
|
||||||
}
|
}
|
||||||
@@ -101,7 +109,7 @@ func main() {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
mux := httprouter.New()
|
mux := httprouter.New()
|
||||||
mux.GET("/", app.home)
|
mux.GET("/", app.homeSuccess)
|
||||||
mux.POST("/play/:code", app.play)
|
mux.POST("/play/:code", app.play)
|
||||||
mux.POST("/session", app.createSession)
|
mux.POST("/session", app.createSession)
|
||||||
mux.GET("/game/:playerUid", app.game)
|
mux.GET("/game/:playerUid", app.game)
|
||||||
@@ -111,6 +119,7 @@ func main() {
|
|||||||
mux.GET("/template", app.downloadTemplate)
|
mux.GET("/template", app.downloadTemplate)
|
||||||
mux.POST("/game", app.createGame)
|
mux.POST("/game", app.createGame)
|
||||||
mux.GET("/quiz/:gameUid", app.showGame)
|
mux.GET("/quiz/:gameUid", app.showGame)
|
||||||
|
mux.GET("/help", app.help)
|
||||||
|
|
||||||
mux.GET("/ws/:playerUid", app.processWebSocket)
|
mux.GET("/ws/:playerUid", app.processWebSocket)
|
||||||
|
|
||||||
@@ -126,6 +135,13 @@ func main() {
|
|||||||
Handler: mux,
|
Handler: mux,
|
||||||
}
|
}
|
||||||
log.Printf("Starting server on %s\n", addr)
|
log.Printf("Starting server on %s\n", addr)
|
||||||
err := srv.ListenAndServe()
|
if socket {
|
||||||
log.Fatal(err)
|
if listener, err := net.Listen("unix", addr); err == nil {
|
||||||
|
log.Fatal(srv.Serve(listener))
|
||||||
|
} else {
|
||||||
|
errorLog.Fatal(err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errorLog.Fatal(srv.ListenAndServe())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
flake.lock
generated
17
flake.lock
generated
@@ -1,5 +1,21 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
|
"nix-filter": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1619956448,
|
||||||
|
"narHash": "sha256-4OZjkK1vot9vdg+8jj9+hm4c6mSEc9/OJDQzNvLHBKE=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "nix-filter",
|
||||||
|
"rev": "cf06efe613a06f1505f187712a307c05b3901884",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"ref": "master",
|
||||||
|
"repo": "nix-filter",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1618886496,
|
"lastModified": 1618886496,
|
||||||
@@ -18,6 +34,7 @@
|
|||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
|
"nix-filter": "nix-filter",
|
||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
flake.nix
13
flake.nix
@@ -1,16 +1,18 @@
|
|||||||
{
|
{
|
||||||
|
|
||||||
description = "Tinyquiz – an open source online quiz platform";
|
description = "Tinyquiz – an open source online quiz platform";
|
||||||
|
|
||||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||||
|
inputs.nix-filter.url = "github:numtide/nix-filter/master";
|
||||||
|
|
||||||
outputs = { self, nixpkgs }:
|
outputs = { self, nixpkgs, nix-filter }:
|
||||||
let
|
let
|
||||||
pkgs = nixpkgs.legacyPackages.x86_64-linux;
|
pkgs = nixpkgs.legacyPackages.x86_64-linux;
|
||||||
buildCmd = name:
|
buildCmd = name:
|
||||||
(pkgs.buildGoPackage {
|
(pkgs.buildGoPackage {
|
||||||
pname = "tinyquiz-${name}";
|
pname = "tinyquiz-${name}";
|
||||||
version = "dev";
|
version = "dev";
|
||||||
src = ./.;
|
src = with nix-filter.lib; filter { root = ./.; include = [ (inDirectory "cmd") (inDirectory "pkg") (inDirectory "ui") "go.mod" "go.sum" ]; };
|
||||||
goDeps = ./deps.nix;
|
goDeps = ./deps.nix;
|
||||||
preBuild = "go generate vkane.cz/tinyquiz/...";
|
preBuild = "go generate vkane.cz/tinyquiz/...";
|
||||||
checkPhase = "go test -race vkane.cz/tinyquiz/...";
|
checkPhase = "go test -race vkane.cz/tinyquiz/...";
|
||||||
@@ -57,12 +59,6 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
config = lib.mkIf cfg.enable {
|
config = lib.mkIf cfg.enable {
|
||||||
users.groups.tinyquiz = {};
|
|
||||||
users.users.tinyquiz = {
|
|
||||||
description = "Tinyquiz service user";
|
|
||||||
group = "tinyquiz";
|
|
||||||
isSystemUser = true;
|
|
||||||
};
|
|
||||||
systemd.services.tinyquiz = {
|
systemd.services.tinyquiz = {
|
||||||
description = "Tinyquiz service";
|
description = "Tinyquiz service";
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
@@ -71,6 +67,7 @@
|
|||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
ExecStart = "${self.packages.x86_64-linux.tinyquiz-web}/bin/web";
|
ExecStart = "${self.packages.x86_64-linux.tinyquiz-web}/bin/web";
|
||||||
User = "tinyquiz";
|
User = "tinyquiz";
|
||||||
|
DynamicUser = true;
|
||||||
};
|
};
|
||||||
environment = cfg.config;
|
environment = cfg.config;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ func (m *Model) RegisterPlayer(playerName string, sessionCode string, now time.T
|
|||||||
}
|
}
|
||||||
defer tx.Commit()
|
defer tx.Commit()
|
||||||
|
|
||||||
if s, err := tx.Session.Query().Where(session.Code(sessionCode)).Only(c); ent.IsNotFound(err) {
|
if s, err := tx.Session.Query().Where(session.CodeEqualFold(sessionCode)).Only(c); ent.IsNotFound(err) {
|
||||||
return nil, NoSuchEntity
|
return nil, NoSuchEntity
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -96,7 +96,7 @@ func (m *Model) CreateSession(organiserName string, gameCode string, now time.Ti
|
|||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
if gameId, err := tx.Game.Query().Where(game.Code(gameCode)).OnlyID(c); err == nil {
|
if gameId, err := tx.Game.Query().Where(game.CodeEqualFold(gameCode)).OnlyID(c); err == nil {
|
||||||
if incremental, err := m.getCodeIncremental(c); err == nil {
|
if incremental, err := m.getCodeIncremental(c); err == nil {
|
||||||
if code, err := codeGenerator.GenerateRandomCode(incremental, codeRandomPartLength); err == nil {
|
if code, err := codeGenerator.GenerateRandomCode(incremental, codeRandomPartLength); err == nil {
|
||||||
if s, err := tx.Session.Create().SetID(uuid.New()).SetCreated(now).SetCode(string(code)).SetGameID(gameId).Save(c); err == nil {
|
if s, err := tx.Session.Create().SetID(uuid.New()).SetCreated(now).SetCode(string(code)).SetGameID(gameId).Save(c); err == nil {
|
||||||
|
|||||||
24
ui/html/help.page.tmpl.html
Normal file
24
ui/html/help.page.tmpl.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{{- template "base" . -}}
|
||||||
|
|
||||||
|
{{- define "additional-css" -}}
|
||||||
|
<link rel="stylesheet" href="/static/home.css">
|
||||||
|
{{ end -}}
|
||||||
|
|
||||||
|
{{- define "additional-js" -}}
|
||||||
|
<script src="/static/home.js"></script>
|
||||||
|
{{ end -}}
|
||||||
|
|
||||||
|
{{- define "header" }}
|
||||||
|
<h1>Popis souboru s otázkami</h1>
|
||||||
|
{{ end -}}
|
||||||
|
|
||||||
|
{{- define "main" }}
|
||||||
|
<div style="max-width: 75vw;">
|
||||||
|
<p>
|
||||||
|
Soubor je CSV (oddělovačem je čárka, kódování UTF-8) bez záhlaví. Lze jej otevřít a upravit obvyklými kancelářskými programy (Microsoft Excel, LibreOffice…).
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Každý neprázdný řádek odpovídá buďto otázce, nebo odpovědi. Otázka má svůj nadpis v prvním sloupci. Odpověď má první sloupec prázný, svůj nadpis má ve druhém sloupci a váže se k nejbližší předcházející otázce. Otázky mohou volitelně (krom první) ve druhém sloupci uvést čas na odpověď v milisekundách, jinak se použije hodnota předchozí otázky. Odpovědi, které mají ve třetím sloupci číslo 1 se považují za správné.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{ end -}}
|
||||||
@@ -22,28 +22,89 @@
|
|||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<h1>Připojit se ke hře</h1>
|
<h1>Připojit se ke hře</h1>
|
||||||
|
{{- with .Form.Join }}
|
||||||
|
{{- with .Errors }}
|
||||||
|
<ul class="error">
|
||||||
|
{{ range . }}<li>{{ . }}</li>{{ end }}
|
||||||
|
</ul>
|
||||||
|
{{- end }}
|
||||||
<form id="join" method="post">
|
<form id="join" method="post">
|
||||||
<label>Kód hry: <input type="text" name="code" placeholder="Kód hry"></label>
|
<label>Kód hry: <input type="text" name="code" placeholder="Kód hry" required value="{{ .Code }}"></label>
|
||||||
<label>Jméno hráče: <input type="text" name="player" placeholder="Jméno"></label>
|
<label>Jméno hráče: <input type="text" name="player" placeholder="Jméno" required value="{{ .Name }}"></label>
|
||||||
<input type="submit" value="Připojit do hry">
|
<input type="submit" value="Připojit do hry">
|
||||||
</form>
|
</form>
|
||||||
|
{{- end }}
|
||||||
|
<button class="help" aria-label="Zobrazit nápovědu"></button>
|
||||||
|
<div class="message">
|
||||||
|
<p>
|
||||||
|
Takto se připojíte k již založené (i probíhající) hře.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Kód hry</strong> získáte od jejího organizátora (vidíte-li na sdílený monitor, je napsán v závorkách za názvem kvízu).
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Jméno hráče</strong> se zobrazuje ostatním hráčům při hře i následně na výsledkovce. Doporučuje se volit jej s ohledem na dobré mravy a případné nároky na svou anonymitu, ochranu osobních údajů apod.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<h1>Začít hrát</h1>
|
<h1>Zorganizovat novou hru</h1>
|
||||||
|
{{- with .Form.NewSession }}
|
||||||
|
{{- with .Errors }}
|
||||||
|
<ul class="error">
|
||||||
|
{{ range . }}<li>{{ . }}</li>{{ end }}
|
||||||
|
</ul>
|
||||||
|
{{- end }}
|
||||||
<form id="play" method="post" action="/session">
|
<form id="play" method="post" action="/session">
|
||||||
<label>Kód kvízu: <input type="text" name="code" placeholder="Kód kvizu"></label>
|
<label>Kód kvízu: <input type="text" name="code" placeholder="Kód kvizu" required value="{{ .Code }}"></label>
|
||||||
<label>Jméno organizátora: <input type="text" name="organiser" placeholder="Jméno"></label>
|
<label>Jméno organizátora: <input type="text" name="organiser" placeholder="Jméno" required value="{{ .Name }}"></label>
|
||||||
<input type="submit" value="Začit hrát">
|
<input type="submit" value="Začit hrát">
|
||||||
</form>
|
</form>
|
||||||
|
{{- end }}
|
||||||
|
<button class="help" aria-label="Zobrazit nápovědu"></button>
|
||||||
|
<div class="message">
|
||||||
|
<p>
|
||||||
|
Takto se stanete organizátorem hry. Sami nemůžete odpovídat, ale získáte kód pro připojení ostatních hráčů.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Kód kvízu</strong> získáte od jeho autora, který jej získal při vytvoření.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Jméno organizátora</strong> se zobrazuje ostatním hráčům při hře. Doporučuje se volit jej s ohledem na případné nároky na svou anonymitu, ochranu osobních údajů apod.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
|
{{- with .Form.NewGame }}
|
||||||
|
{{- with .Errors }}
|
||||||
|
<ul class="error">
|
||||||
|
{{ range . }}<li>{{ . }}</li>{{ end }}
|
||||||
|
</ul>
|
||||||
|
{{- end }}
|
||||||
<h1>Vytvořit nový kvíz</h1>
|
<h1>Vytvořit nový kvíz</h1>
|
||||||
<p>Stáhnout <a href="/template" download>šablonu nového kvízu</a></p>
|
<p><a href="/template" download>Šablona nového kvízu</a>.</p>
|
||||||
|
<p><a href="/help">Popis formátu</a></p>
|
||||||
<form id="new" enctype="multipart/form-data" method="post" action="/game">
|
<form id="new" enctype="multipart/form-data" method="post" action="/game">
|
||||||
<label>Jméno kvízu: <input type="text" name="name" placeholder="Jméno kvízu"></label>
|
<label>Jméno kvízu: <input type="text" name="name" placeholder="Jméno kvízu" required value="{{ .Title }}"></label>
|
||||||
<label>Jméno autora: <input type="text" name="author" placeholder="Jméno"></label>
|
<label>Jméno autora: <input type="text" name="author" placeholder="Jméno" required value="{{ .Name }}"></label>
|
||||||
<label>Kvíz: <input type="file" name="game" accept="text/csv"></label>
|
<label>Kvíz: <input type="file" name="game" accept="text/csv" required></label>
|
||||||
<input type="submit" value="Vytvořit">
|
<input type="submit" value="Vytvořit">
|
||||||
</form>
|
</form>
|
||||||
|
{{- end }}
|
||||||
|
<button class="help" aria-label="Zobrazit nápovědu"></button>
|
||||||
|
<div class="message">
|
||||||
|
<p>
|
||||||
|
Takto vytvoříte nový kvíz (sadu otázek a odpovědí), který následně můžete použít při zakládání hry.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Jméno kvízu</strong> se zobrazí organizátorům i hráčům při hře.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Jméno autora</strong> je doplňkový údaj na podrobnostech kvízu; při následné hře vidět není. Přesto se doporučuje volit jej s ohledem na případné nároky na svou anonymitu, ochranu osobních údajů apod.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Kvíz</strong> je CSV soubor formátu popsaného na samostatné stránce. Nejpohodlnější je vyjít z dodané šablony.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{{ end -}}
|
{{ end -}}
|
||||||
|
|||||||
@@ -10,9 +10,52 @@ section {
|
|||||||
border: 2px solid black;
|
border: 2px solid black;
|
||||||
margin: 2rem;
|
margin: 2rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
#join, #play, #new {
|
#join, #play, #new {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: red;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help::after {
|
||||||
|
content: "?";
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help {
|
||||||
|
border: 2px solid;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
position: absolute;
|
||||||
|
right: 1.5rem;
|
||||||
|
top: 0;
|
||||||
|
display: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.show {
|
||||||
|
display: block;
|
||||||
|
background-color: white;
|
||||||
|
border: 2px dotted black;
|
||||||
|
width: 50vw;
|
||||||
|
max-width: 20rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,4 +4,17 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const code = joinForm.querySelector("input[name=\"code\"]").value;
|
const code = joinForm.querySelector("input[name=\"code\"]").value;
|
||||||
joinForm.action = "/play/" + encodeURIComponent(code);
|
joinForm.action = "/play/" + encodeURIComponent(code);
|
||||||
});
|
});
|
||||||
|
document.body.addEventListener("click", () => {
|
||||||
|
for (const help of document.querySelectorAll(".message.show")) {
|
||||||
|
help.classList.remove("show");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const helps = document.getElementsByClassName("help");
|
||||||
|
for (const help of helps) {
|
||||||
|
help.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
console.log(e.target);
|
||||||
|
e.target.parentElement.querySelector(".message").classList.toggle("show");
|
||||||
|
})
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user