From 22b700e2412badcda29129556e887b33c3de2a7f Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Thu, 5 Mar 2026 21:25:59 +0200 Subject: [PATCH] Initial Commit --- .dockerignore | 9 + .gitignore | 26 ++ Dockerfile | 28 ++ README.md | 55 +++ frontend-mockup.html | 520 +++++++++++++++++++++++++++++ go.mod | 42 +++ go.sum | 93 ++++++ src/config/config.go | 16 + src/controllers/page_controller.go | 20 ++ src/main.go | 19 ++ src/middleware/cache_control.go | 20 ++ src/models/room_setup.go | 31 ++ src/routes/routes.go | 25 ++ src/server/router.go | 20 ++ src/templates/body-config.html | 127 +++++++ src/templates/footer.html | 10 + src/templates/header.html | 18 + src/templates/index.html | 5 + static/css/styles.css | 426 +++++++++++++++++++++++ static/img/.gitkeep | 0 static/js/app.js | 184 ++++++++++ 21 files changed, 1694 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 frontend-mockup.html create mode 100644 go.mod create mode 100644 go.sum create mode 100644 src/config/config.go create mode 100644 src/controllers/page_controller.go create mode 100644 src/main.go create mode 100644 src/middleware/cache_control.go create mode 100644 src/models/room_setup.go create mode 100644 src/routes/routes.go create mode 100644 src/server/router.go create mode 100644 src/templates/body-config.html create mode 100644 src/templates/footer.html create mode 100644 src/templates/header.html create mode 100644 src/templates/index.html create mode 100644 static/css/styles.css create mode 100644 static/img/.gitkeep create mode 100644 static/js/app.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e652a09 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.gitignore +.vscode +.idea +README.md +frontend-mockup.html +**/*.log +**/.DS_Store +tmp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3e1c7cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Binaries +*.exe +*.dll +*.so +*.dylib +*.test +*.out + +# Build output +bin/ +dist/ + +# Go workspace files +.coverprofile + +# IDE/editor +.vscode/ +.idea/ + +# OS files +.DS_Store +Thumbs.db + +# Environment +.env +.env.* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e02b094 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM golang:1.22-alpine AS build + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY src ./src +COPY static ./static + +RUN CGO_ENABLED=0 GOOS=linux go build -o /out/scrum-solitare ./src + +FROM alpine:3.21 + +WORKDIR /app + +RUN addgroup -S app && adduser -S app -G app + +COPY --from=build /out/scrum-solitare /app/scrum-solitare +COPY --from=build /app/src/templates /app/src/templates +COPY --from=build /app/static /app/static + +EXPOSE 8002 +ENV PORT=8002 + +USER app + +ENTRYPOINT ["/app/scrum-solitare"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..4441bc6 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# Scrum Solitare + +Win98-themed Scrum Poker web app scaffold using Go + Gin. + +## Features + +- Gin server with default port `8002` +- Gzip compression enabled +- Cache headers for static `css`, `js`, and image assets +- Template rendering from `src/templates` +- Static file hosting from `static/` +- `/` currently serves a room configuration page (UI only) + +## Project Layout + +- `src/main.go`: Application bootstrap +- `src/config/`: Environment and runtime configuration +- `src/server/`: Gin engine construction and middleware wiring +- `src/routes/`: Route registration +- `src/controllers/`: HTTP handlers/controllers +- `src/middleware/`: Custom Gin middleware +- `src/models/`: Page/view data models +- `src/templates/`: HTML templates (`header`, `body`, `footer`, and `index` composition) +- `static/css/`: Stylesheets +- `static/js/`: Frontend scripts +- `static/img/`: Image assets + +## Run Locally + +```bash +go mod tidy +go run ./src +``` + +Open `http://localhost:8002`. + +## Environment Variables + +- `PORT`: Optional server port override (default is `8002`) + +## Docker + +Build image: + +```bash +docker build -t scrum-solitare . +``` + +Run container: + +```bash +docker run --rm -p 8002:8002 scrum-solitare +``` + +Then open `http://localhost:8002`. diff --git a/frontend-mockup.html b/frontend-mockup.html new file mode 100644 index 0000000..c84052d --- /dev/null +++ b/frontend-mockup.html @@ -0,0 +1,520 @@ + + + + + + Retro Scrum Poker + + + + + + + + +
+ +
+ +
+ +
+
+ Join.exe +
+ +
+
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+ +
+ +
+
+ ScrumPoker_Solitaire.exe +
+ + + +
+
+
+
+
+ +
+
+ Network Users +
+
+
    +
  • + Alice_Dev + Voted +
  • +
  • + Bob_QA + Voting... +
  • +
  • + Charlie_PM + Viewer +
  • +
+
+
+ +
+
+ Vote Results.log +
+
+ + + + + + + + + + + + + + + + + + + + + +
Card PickedParticipants
5Alice_Dev, Dave_BE
8Eve_Designer
?Bob_QA
+
+
+ +
+
+ + + + \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c9be9b4 --- /dev/null +++ b/go.mod @@ -0,0 +1,42 @@ +module scrum-solitare + +go 1.24.0 + +require ( + github.com/gin-contrib/gzip v1.2.4 + github.com/gin-gonic/gin v1.11.0 +) + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.14.1 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.10 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.28.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.55.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + golang.org/x/tools v0.38.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7bc9fc3 --- /dev/null +++ b/go.sum @@ -0,0 +1,93 @@ +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w= +github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= +github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/gzip v1.2.4 h1:yNz4EhPC2kHSZJD1oc1zwp7MLEhEZ3goQeGM3a1b6jU= +github.com/gin-contrib/gzip v1.2.4/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= +github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk= +github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/src/config/config.go b/src/config/config.go new file mode 100644 index 0000000..f05717c --- /dev/null +++ b/src/config/config.go @@ -0,0 +1,16 @@ +package config + +import "os" + +type Config struct { + Port string +} + +func Load() Config { + port := os.Getenv("PORT") + if port == "" { + port = "8002" + } + + return Config{Port: port} +} diff --git a/src/controllers/page_controller.go b/src/controllers/page_controller.go new file mode 100644 index 0000000..b0a00f2 --- /dev/null +++ b/src/controllers/page_controller.go @@ -0,0 +1,20 @@ +package controllers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "scrum-solitare/src/models" +) + +type PageController struct{} + +func NewPageController() *PageController { + return &PageController{} +} + +func (pc *PageController) ShowRoomSetup(c *gin.Context) { + pageData := models.DefaultRoomSetupPageData() + c.HTML(http.StatusOK, "index.html", pageData) +} diff --git a/src/main.go b/src/main.go new file mode 100644 index 0000000..0fdf2e3 --- /dev/null +++ b/src/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "log" + + "scrum-solitare/src/config" + "scrum-solitare/src/controllers" + "scrum-solitare/src/server" +) + +func main() { + cfg := config.Load() + pageController := controllers.NewPageController() + router := server.NewRouter(pageController) + + if err := router.Run(":" + cfg.Port); err != nil { + log.Fatalf("server failed to start: %v", err) + } +} diff --git a/src/middleware/cache_control.go b/src/middleware/cache_control.go new file mode 100644 index 0000000..020adda --- /dev/null +++ b/src/middleware/cache_control.go @@ -0,0 +1,20 @@ +package middleware + +import ( + "path/filepath" + "strings" + + "github.com/gin-gonic/gin" +) + +func StaticCacheControl() gin.HandlerFunc { + return func(c *gin.Context) { + ext := strings.ToLower(filepath.Ext(c.Request.URL.Path)) + switch ext { + case ".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico": + c.Header("Cache-Control", "public, max-age=31536000, immutable") + c.Header("Vary", "Accept-Encoding") + } + c.Next() + } +} diff --git a/src/models/room_setup.go b/src/models/room_setup.go new file mode 100644 index 0000000..9918c9a --- /dev/null +++ b/src/models/room_setup.go @@ -0,0 +1,31 @@ +package models + +type RoomSetupPageData struct { + DefaultRoomName string + DefaultUsername string + DefaultMaxPeople int + DefaultScale string + DefaultRevealMode string + DefaultVotingTime int + DefaultModerator string + AllowSpectators bool + AnonymousVoting bool + AutoResetCards bool + DefaultStatus string +} + +func DefaultRoomSetupPageData() RoomSetupPageData { + return RoomSetupPageData{ + DefaultRoomName: "Sprint Planning", + DefaultUsername: "", + DefaultMaxPeople: 50, + DefaultScale: "fibonacci", + DefaultRevealMode: "manual", + DefaultVotingTime: 0, + DefaultModerator: "creator", + AllowSpectators: true, + AnonymousVoting: true, + AutoResetCards: true, + DefaultStatus: "Ready to create room.", + } +} diff --git a/src/routes/routes.go b/src/routes/routes.go new file mode 100644 index 0000000..0cd938d --- /dev/null +++ b/src/routes/routes.go @@ -0,0 +1,25 @@ +package routes + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "scrum-solitare/src/controllers" + "scrum-solitare/src/middleware" +) + +func Register(r *gin.Engine, pageController *controllers.PageController) { + registerStatic(r) + registerPages(r, pageController) +} + +func registerStatic(r *gin.Engine) { + static := r.Group("/static") + static.Use(middleware.StaticCacheControl()) + static.StaticFS("/", http.Dir("static")) +} + +func registerPages(r *gin.Engine, pageController *controllers.PageController) { + r.GET("/", pageController.ShowRoomSetup) +} diff --git a/src/server/router.go b/src/server/router.go new file mode 100644 index 0000000..87a68ae --- /dev/null +++ b/src/server/router.go @@ -0,0 +1,20 @@ +package server + +import ( + "github.com/gin-contrib/gzip" + "github.com/gin-gonic/gin" + + "scrum-solitare/src/controllers" + "scrum-solitare/src/routes" +) + +func NewRouter(pageController *controllers.PageController) *gin.Engine { + r := gin.New() + r.Use(gin.Logger(), gin.Recovery()) + r.Use(gzip.Gzip(gzip.DefaultCompression)) + + r.LoadHTMLGlob("src/templates/*.html") + routes.Register(r, pageController) + + return r +} diff --git a/src/templates/body-config.html b/src/templates/body-config.html new file mode 100644 index 0000000..87c53b2 --- /dev/null +++ b/src/templates/body-config.html @@ -0,0 +1,127 @@ +{{ define "body" }} +
+
+ CreateRoom.exe + +
+ +
+

Configure your Scrum Poker room and share the invite link with your team.

+ +
+
+
+
+ + +
+ +
+
+ + +
+ +
+ +
+ +
+
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ +
+ + sec +
+
+ +
+ + +
+
+ +
+ Room options + + + +
+
+ + +
+ +
+ {{ .DefaultStatus }} +
+ +
+ + +
+
+
+
+{{ end }} diff --git a/src/templates/footer.html b/src/templates/footer.html new file mode 100644 index 0000000..25c7ac7 --- /dev/null +++ b/src/templates/footer.html @@ -0,0 +1,10 @@ +{{ define "footer" }} + + + + + +{{ end }} diff --git a/src/templates/header.html b/src/templates/header.html new file mode 100644 index 0000000..3cd3669 --- /dev/null +++ b/src/templates/header.html @@ -0,0 +1,18 @@ +{{ define "header" }} + + + + + + Retro Scrum Poker - Room Setup + + + + + + +
+ +
+
+{{ end }} diff --git a/src/templates/index.html b/src/templates/index.html new file mode 100644 index 0000000..cc1b7ad --- /dev/null +++ b/src/templates/index.html @@ -0,0 +1,5 @@ +{{ define "index.html" }} +{{ template "header" . }} +{{ template "body" . }} +{{ template "footer" . }} +{{ end }} diff --git a/static/css/styles.css b/static/css/styles.css new file mode 100644 index 0000000..ba3a893 --- /dev/null +++ b/static/css/styles.css @@ -0,0 +1,426 @@ +:root { + --desktop-bg: #008080; + --window-bg: #c0c0c0; + --window-text: #000000; + --border-light: #ffffff; + --border-dark: #000000; + --border-mid-light: #dfdfdf; + --border-mid-dark: #808080; + --title-bg: #000080; + --title-text: #ffffff; + --input-bg: #ffffff; + --status-bg: #b3b3b3; + --board-bg: #0f6d3d; + --card-bg: #ffffff; + --card-text: #000000; +} + +[data-theme="dark"] { + --desktop-bg: #0a0a0a; + --window-bg: #2b2b2b; + --window-text: #e0e0e0; + --border-light: #555555; + --border-dark: #000000; + --border-mid-light: #3a3a3a; + --border-mid-dark: #1a1a1a; + --title-bg: #000000; + --title-text: #00ff00; + --input-bg: #111111; + --status-bg: #1b1b1b; + --board-bg: #0a2c14; + --card-bg: #171717; + --card-text: #00ff66; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; + font-family: 'VT323', monospace; +} + +body { + background-color: var(--desktop-bg); + color: var(--window-text); + min-height: 100vh; + display: flex; + flex-direction: column; + background-image: radial-gradient(circle, rgba(0, 0, 0, 0.12) 1px, transparent 1px); + background-size: 4px 4px; +} + +#desktop { + flex: 1; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 28px 16px 40px; +} + +.top-bar { + position: fixed; + top: 12px; + right: 12px; + z-index: 10; +} + +.window { + background-color: var(--window-bg); + border: 2px solid; + border-color: var(--border-light) var(--border-dark) var(--border-dark) var(--border-light); + padding: 2px; + box-shadow: inset 1px 1px var(--border-mid-light), inset -1px -1px var(--border-mid-dark); +} + +.title-bar { + background-color: var(--title-bg); + color: var(--title-text); + padding: 2px 4px; + font-size: 1.25rem; + display: flex; + justify-content: space-between; + align-items: center; + letter-spacing: 0.8px; +} + +.title-bar-controls { + display: inline-flex; + gap: 2px; +} + +.title-bar-controls button { + background: var(--window-bg); + border: 1px solid; + border-color: var(--border-light) var(--border-dark) var(--border-dark) var(--border-light); + width: 16px; + height: 16px; + font-size: 0.8rem; + line-height: 10px; + text-align: center; + color: var(--window-text); + font-weight: bold; + pointer-events: none; +} + +.window-content { + padding: 12px; +} + +.config-window { + width: 100%; + max-width: 980px; +} + +.intro-copy { + font-size: 1.3rem; + margin-bottom: 12px; +} + +.room-form { + display: flex; + flex-direction: column; + gap: 10px; +} + +.config-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + gap: 12px; + align-items: start; +} + +.config-panel { + display: flex; + flex-direction: column; + gap: 10px; +} + +.field-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.field-group { + display: flex; + flex-direction: column; +} + +.field-group label, +legend { + font-size: 1.2rem; + margin-bottom: 4px; +} + +input[type="text"], +input[type="number"], +select { + background: var(--input-bg); + color: var(--window-text); + border: 2px solid; + border-color: var(--border-dark) var(--border-light) var(--border-light) var(--border-dark); + padding: 5px 6px; + font-size: 1.2rem; + width: 100%; + outline: none; +} + +input[type="text"]:focus, +input[type="number"]:focus, +select:focus { + box-shadow: inset 0 0 0 1px var(--title-bg); +} + +input[type="number"] { + appearance: textfield; + -moz-appearance: textfield; +} + +input[type="number"]::-webkit-outer-spin-button, +input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.number-input-wrap { + display: flex; + align-items: center; + background: var(--input-bg); + border: 2px solid; + border-color: var(--border-dark) var(--border-light) var(--border-light) var(--border-dark); + padding: 0 6px; + min-height: 38px; +} + +.number-input-wrap input[type="number"] { + border: 0; + box-shadow: none; + background: transparent; + padding: 5px 0; +} + +.number-with-unit { + gap: 8px; +} + +.input-unit { + font-size: 1rem; + opacity: 0.8; + min-width: 26px; + text-align: right; +} + +.options-box { + padding: 8px; +} + +.options-box legend { + padding: 0 4px; +} + +.option-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 1.2rem; + margin: 6px 0; +} + +.option-item input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: #000080; +} + +[data-theme="dark"] .option-item input[type="checkbox"] { + accent-color: #00aa00; +} + +.preview-window { + width: 100%; +} + +.preview-content { + display: flex; + flex-direction: column; + gap: 10px; +} + +.preview-meta { + display: flex; + justify-content: space-between; + font-size: 1.1rem; +} + +.preview-board { + background: var(--board-bg); + border: 2px solid; + border-color: var(--border-dark) var(--border-light) var(--border-light) var(--border-dark); + border-radius: 2px; + min-height: 190px; + padding: 10px; +} + +.preview-cards { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-content: flex-start; +} + +.preview-card { + position: relative; + width: 50px; + height: 72px; + background: var(--card-bg); + border: 2px solid #000; + border-radius: 5px; + color: var(--card-text); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + font-weight: bold; + box-shadow: 1px 1px 0 rgba(0, 0, 0, 0.5); + user-select: none; + transition: transform 180ms ease, opacity 180ms ease; + cursor: default; +} + +.preview-card-remove { + position: absolute; + top: -6px; + right: -6px; + width: 18px; + height: 18px; + border: 1px solid; + border-color: var(--border-light) var(--border-dark) var(--border-dark) var(--border-light); + background: #a40000; + color: #fff; + font-size: 0.8rem; + line-height: 14px; + opacity: 0; + transform: scale(0.85); + pointer-events: none; + transition: opacity 130ms ease, transform 130ms ease; + cursor: pointer; +} + +.preview-card:hover .preview-card-remove, +.preview-card-remove:focus { + opacity: 1; + transform: scale(1); + pointer-events: auto; +} + +.preview-card.is-removing { + opacity: 0; + transform: scale(0.55) rotate(-8deg); +} + +.card-editor { + display: flex; + flex-direction: column; + gap: 4px; +} + +.card-editor label { + font-size: 1.1rem; +} + +.card-editor-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 6px; +} + +.status-line { + background: var(--status-bg); + border: 2px solid; + border-color: var(--border-dark) var(--border-light) var(--border-light) var(--border-dark); + padding: 5px 8px; + font-size: 1.1rem; + min-height: 30px; +} + +.actions-row { + text-align: right; + margin-top: 4px; +} + +.btn { + background: var(--window-bg); + color: var(--window-text); + border: 2px solid; + border-color: var(--border-light) var(--border-dark) var(--border-dark) var(--border-light); + box-shadow: inset 1px 1px var(--border-mid-light), inset -1px -1px var(--border-mid-dark); + padding: 4px 12px; + font-size: 1.2rem; + cursor: pointer; + margin-left: 6px; +} + +.btn:active { + border-color: var(--border-dark) var(--border-light) var(--border-light) var(--border-dark); + box-shadow: inset 1px 1px var(--border-mid-dark), inset -1px -1px var(--border-mid-light); + padding: 5px 11px 3px 13px; +} + +.btn-primary { + font-weight: bold; +} + +.taskbar { + height: 30px; + background: var(--window-bg); + border-top: 2px solid var(--border-light); + box-shadow: inset 0 1px var(--border-mid-light); + display: flex; + align-items: center; + gap: 8px; + padding: 3px 6px; +} + +.taskbar-start, +.taskbar-status { + border: 2px solid; + border-color: var(--border-light) var(--border-dark) var(--border-dark) var(--border-light); + box-shadow: inset 1px 1px var(--border-mid-light), inset -1px -1px var(--border-mid-dark); + padding: 0 8px; + font-size: 1.1rem; + height: 20px; + display: inline-flex; + align-items: center; +} + +.taskbar-status { + min-width: 180px; +} + +@media (max-width: 960px) { + .config-layout { + grid-template-columns: 1fr; + } +} + +@media (max-width: 720px) { + #desktop { + align-items: flex-start; + padding-top: 56px; + } + + .field-row { + grid-template-columns: 1fr; + } + + .actions-row { + display: flex; + justify-content: flex-end; + gap: 8px; + } + + .btn { + margin-left: 0; + } +} diff --git a/static/img/.gitkeep b/static/img/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..e98105d --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,184 @@ +const themeToggleBtn = document.getElementById('theme-toggle'); +const roomConfigForm = document.getElementById('room-config-form'); +const statusLine = document.getElementById('config-status'); +const scaleSelect = document.getElementById('estimation-scale'); +const maxPeopleInput = document.getElementById('max-people'); +const previewScale = document.getElementById('preview-scale'); +const previewMaxPeople = document.getElementById('preview-max-people'); +const previewCards = document.getElementById('preview-cards'); +const customCardInput = document.getElementById('custom-card'); +const addCardButton = document.getElementById('add-card'); + +const SCALE_PRESETS = { + fibonacci: ['0', '1', '2', '3', '5', '8', '13', '21', '?'], + tshirt: ['XS', 'S', 'M', 'L', 'XL', '?'], + 'powers-of-two': ['1', '2', '4', '8', '16', '32', '?'], +}; + +let isDarkMode = false; +let nextCardID = 1; +let currentCards = []; + +themeToggleBtn.addEventListener('click', () => { + isDarkMode = !isDarkMode; + if (isDarkMode) { + document.documentElement.setAttribute('data-theme', 'dark'); + themeToggleBtn.textContent = 'Light Mode'; + return; + } + + document.documentElement.removeAttribute('data-theme'); + themeToggleBtn.textContent = 'Dark Mode'; +}); + +function createCard(value) { + return { id: nextCardID++, value: value.toString() }; +} + +function getCardsForScale(scale) { + return (SCALE_PRESETS[scale] || SCALE_PRESETS.fibonacci).map(createCard); +} + +function captureCardPositions() { + const positions = new Map(); + previewCards.querySelectorAll('.preview-card').forEach((el) => { + positions.set(el.dataset.cardId, el.getBoundingClientRect()); + }); + return positions; +} + +function animateCardReflow(previousPositions) { + previewCards.querySelectorAll('.preview-card').forEach((el) => { + const oldRect = previousPositions.get(el.dataset.cardId); + if (!oldRect) { + el.style.opacity = '0'; + el.style.transform = 'scale(0.85)'; + requestAnimationFrame(() => { + el.style.opacity = '1'; + el.style.transform = 'translate(0, 0) scale(1)'; + }); + return; + } + + const newRect = el.getBoundingClientRect(); + const deltaX = oldRect.left - newRect.left; + const deltaY = oldRect.top - newRect.top; + + if (deltaX === 0 && deltaY === 0) { + return; + } + + el.style.transform = `translate(${deltaX}px, ${deltaY}px)`; + requestAnimationFrame(() => { + el.style.transform = 'translate(0, 0)'; + }); + }); +} + +function renderCards(previousPositions = new Map()) { + previewCards.innerHTML = ''; + + currentCards.forEach((card) => { + const cardEl = document.createElement('div'); + cardEl.className = 'preview-card'; + cardEl.dataset.cardId = String(card.id); + cardEl.textContent = card.value; + + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'preview-card-remove'; + removeBtn.textContent = 'X'; + removeBtn.setAttribute('aria-label', `Remove card ${card.value}`); + removeBtn.addEventListener('click', (event) => { + event.stopPropagation(); + removeCard(card.id); + }); + + cardEl.appendChild(removeBtn); + previewCards.appendChild(cardEl); + }); + + animateCardReflow(previousPositions); +} + +function removeCard(cardID) { + const cardEl = previewCards.querySelector(`[data-card-id="${cardID}"]`); + if (!cardEl) { + return; + } + + cardEl.classList.add('is-removing'); + + window.setTimeout(() => { + const previousPositions = captureCardPositions(); + currentCards = currentCards.filter((card) => card.id !== cardID); + renderCards(previousPositions); + }, 160); +} + +function resetCardsForCurrentScale() { + const previousPositions = captureCardPositions(); + currentCards = getCardsForScale(scaleSelect.value); + renderCards(previousPositions); +} + +function updatePreviewMeta() { + previewScale.textContent = `Scale: ${scaleSelect.value}`; + previewMaxPeople.textContent = `Max: ${maxPeopleInput.value || 0}`; +} + +addCardButton.addEventListener('click', () => { + const value = customCardInput.value.trim(); + if (!value) { + return; + } + + const previousPositions = captureCardPositions(); + currentCards.push(createCard(value.slice(0, 8))); + renderCards(previousPositions); + customCardInput.value = ''; + customCardInput.focus(); +}); + +customCardInput.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + addCardButton.click(); + } +}); + +scaleSelect.addEventListener('change', () => { + resetCardsForCurrentScale(); + updatePreviewMeta(); + statusLine.textContent = 'Card deck reset to selected estimation scale.'; +}); + +maxPeopleInput.addEventListener('input', () => { + updatePreviewMeta(); +}); + +roomConfigForm.addEventListener('submit', (event) => { + event.preventDefault(); + + const formData = new FormData(roomConfigForm); + const roomName = (formData.get('roomName') || '').toString().trim(); + const username = (formData.get('username') || '').toString().trim(); + + if (!roomName || !username) { + statusLine.textContent = 'Please fill in both room name and username.'; + return; + } + + statusLine.textContent = `Room "${roomName}" prepared by ${username} with ${currentCards.length} cards.`; +}); + +roomConfigForm.addEventListener('reset', () => { + window.setTimeout(() => { + updatePreviewMeta(); + resetCardsForCurrentScale(); + statusLine.textContent = 'Room settings reset to defaults.'; + }, 0); +}); + +updatePreviewMeta(); +resetCardsForCurrentScale();