Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
7df663d9c4
|
|||
|
1e998dabf3
|
|||
|
01ca54ce6f
|
|||
|
3828c146ee
|
|||
|
548cb92ca3
|
|||
|
7420e2b890
|
|||
|
73aff92505
|
|||
|
85a2a3116b
|
|||
|
cb4a210567
|
|||
|
3c7be22019
|
|||
|
193391d837
|
|||
|
b8edbcb403
|
@@ -0,0 +1 @@
|
||||
ROOT_DIR=./data
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
# binary
|
||||
sprintpadawan
|
||||
server
|
||||
main
|
||||
|
||||
# db
|
||||
app.db
|
||||
app.db-wal
|
||||
|
||||
# env
|
||||
.env
|
||||
|
||||
# os
|
||||
.DS_Store
|
||||
.direnv/
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# Build stage
|
||||
FROM golang:alpine AS builder
|
||||
RUN apk add --no-cache build-base
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -o sprintpadawan main.go
|
||||
|
||||
# Runtime stage
|
||||
FROM alpine:latest
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
WORKDIR /app
|
||||
|
||||
ENV ROOT_DIR=/data
|
||||
RUN mkdir -p /data
|
||||
|
||||
COPY --from=builder /app/sprintpadawan .
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["./sprintpadawan"]
|
||||
@@ -0,0 +1,13 @@
|
||||
.PHONY: dev build clean
|
||||
|
||||
# Run the development server
|
||||
dev:
|
||||
go run main.go
|
||||
|
||||
# Build the standalone binary
|
||||
build:
|
||||
go build -o sprintpadawan main.go
|
||||
|
||||
# Clean the compiled binary
|
||||
clean:
|
||||
rm -f sprintpadawan
|
||||
@@ -1,2 +1,29 @@
|
||||
# sprintpadawan
|
||||
# SprintPadawan
|
||||
|
||||
A lightweight real-time sprint planning tool. Built with Go, HTMX, and Turso (The FOSS DB, not the platform).
|
||||
|
||||
## Development
|
||||
|
||||
This project uses Go 1.26. If you use Nix, a flake is provided to set up the environment. You can load it with `nix develop` or `direnv allow`.
|
||||
|
||||
Available Make commands:
|
||||
* `make dev`: Runs the development server.
|
||||
* `make build`: Compiles everything into a single binary.
|
||||
* `make clean`: Removes the binary.
|
||||
|
||||
When running locally without Docker, the application will create an `app.db` SQLite database file in your current working directory.
|
||||
|
||||
## Docker
|
||||
|
||||
The project includes a Docker setup for those who use it.
|
||||
|
||||
1. Create a `.env` file in the project root.
|
||||
2. Set the `ROOT_DIR` variable to the directory on your host machine where you want the database to be saved.
|
||||
|
||||
Example `.env`:
|
||||
ROOT_DIR=/home/user/sprintpadawan_data
|
||||
|
||||
3. Start the container:
|
||||
docker compose up -d
|
||||
|
||||
The Docker container maps your host `ROOT_DIR` to `/data` inside the container. Sprint Padawan is permanently configured to write its database to `/data` when running in Docker.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
0.2.0
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"sprintpadawan/lib"
|
||||
)
|
||||
|
||||
func handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
if isLoggedIn(r) {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
renderTemplate(w, "login.html", nil)
|
||||
return
|
||||
}
|
||||
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
|
||||
user, err := lib.GetUserByUsername(username)
|
||||
if err != nil || !lib.CheckPasswordHash(password, user.PasswordHash) {
|
||||
renderTemplate(w, "login.html", map[string]string{"Error": "Invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
sessionID, err := lib.CreateSession(user.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to create session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session_token",
|
||||
Value: sessionID,
|
||||
Expires: time.Now().Add(24 * time.Hour),
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
if isLoggedIn(r) {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
renderTemplate(w, "register.html", nil)
|
||||
return
|
||||
}
|
||||
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
confirm := r.FormValue("confirm_password")
|
||||
|
||||
if password != confirm {
|
||||
renderTemplate(w, "register.html", map[string]string{"Error": "Passwords do not match"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := lib.CreateUser(username, password); err != nil {
|
||||
renderTemplate(w, "register.html", map[string]string{"Error": "Username taken"})
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("session_token")
|
||||
if err == nil {
|
||||
if err := lib.DeleteSession(cookie.Value); err != nil {
|
||||
log.Printf("delete session error: %v", err)
|
||||
}
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session_token",
|
||||
Value: "",
|
||||
Expires: time.Now().Add(-1 * time.Hour),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Path: "/",
|
||||
})
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"sprintpadawan/lib"
|
||||
)
|
||||
|
||||
type RoomView struct {
|
||||
ID int
|
||||
Name string
|
||||
Code string
|
||||
Scale string
|
||||
IsOwner bool
|
||||
MemberCount int
|
||||
}
|
||||
|
||||
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
user := r.Context().Value(userKey).(*lib.User)
|
||||
rooms, err := lib.GetRoomSummariesForUser(user.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to load rooms", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := struct {
|
||||
*lib.User
|
||||
Rooms []RoomView
|
||||
}{
|
||||
User: user,
|
||||
}
|
||||
|
||||
for _, room := range rooms {
|
||||
data.Rooms = append(data.Rooms, RoomView{
|
||||
ID: room.ID,
|
||||
Name: room.Name,
|
||||
Code: room.Code,
|
||||
Scale: room.Scale,
|
||||
IsOwner: room.OwnerID == user.ID,
|
||||
MemberCount: room.MemberCount,
|
||||
})
|
||||
}
|
||||
|
||||
if err := templates.ExecuteTemplate(w, "index.html", data); err != nil {
|
||||
log.Printf("template error: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"sprintpadawan/lib"
|
||||
)
|
||||
|
||||
func handleNewRoom(w http.ResponseWriter, r *http.Request) {
|
||||
if err := templates.ExecuteTemplate(w, "room_form.html", nil); err != nil {
|
||||
log.Printf("template error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func handleCreateRoom(w http.ResponseWriter, r *http.Request) {
|
||||
user := r.Context().Value(userKey).(*lib.User)
|
||||
name := r.FormValue("name")
|
||||
scale := r.FormValue("scale")
|
||||
room, err := lib.CreateRoom(name, user.ID, scale)
|
||||
if err != nil {
|
||||
log.Printf("create room error: %v", err)
|
||||
http.Error(w, "failed to create room", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/rooms/%d", room.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func handleJoinRoom(w http.ResponseWriter, r *http.Request) {
|
||||
user := r.Context().Value(userKey).(*lib.User)
|
||||
code := r.FormValue("code")
|
||||
room, err := lib.GetRoomByCode(code)
|
||||
if err != nil {
|
||||
http.Error(w, "room not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := lib.AddUserToRoom(room.ID, user.ID); err != nil {
|
||||
http.Error(w, "failed to join room", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/rooms/%d", room.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func handleRoom(w http.ResponseWriter, r *http.Request) {
|
||||
roomID, err := getRoomID(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid room id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
user := r.Context().Value(userKey).(*lib.User)
|
||||
room, err := lib.GetRoomByID(roomID)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := lib.AddUserToRoom(room.ID, user.ID); err != nil {
|
||||
http.Error(w, "failed to join room", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := buildRoomData(room, user)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to load room data", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
renderTemplate(w, "room.html", data)
|
||||
}
|
||||
|
||||
func handlePartialStories(w http.ResponseWriter, r *http.Request) {
|
||||
roomID, err := getRoomID(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid room id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
user := r.Context().Value(userKey).(*lib.User)
|
||||
renderRoomStories(w, roomID, user)
|
||||
}
|
||||
|
||||
func handlePartialMembers(w http.ResponseWriter, r *http.Request) {
|
||||
roomID, err := getRoomID(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid room id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
user := r.Context().Value(userKey).(*lib.User)
|
||||
renderRoomMembers(w, roomID, user)
|
||||
}
|
||||
|
||||
func handlePartialVoteArea(w http.ResponseWriter, r *http.Request) {
|
||||
roomID, err := getRoomID(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid room id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
user := r.Context().Value(userKey).(*lib.User)
|
||||
room, err := lib.GetRoomByID(roomID)
|
||||
if err != nil {
|
||||
http.Error(w, "room not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if room.ActiveStoryID == nil {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
story, err := lib.GetStoryByID(*room.ActiveStoryID)
|
||||
if err != nil {
|
||||
http.Error(w, "story not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
roomData, err := buildRoomData(room, user)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to load room data", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := struct {
|
||||
RoomData RoomData
|
||||
Story lib.Story
|
||||
}{
|
||||
RoomData: roomData,
|
||||
Story: *story,
|
||||
}
|
||||
renderTemplate(w, "vote-area", data)
|
||||
}
|
||||
|
||||
func handleDeleteRoom(w http.ResponseWriter, r *http.Request) {
|
||||
roomID, err := getRoomID(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid room id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
user := r.Context().Value(userKey).(*lib.User)
|
||||
|
||||
room, err := lib.GetRoomByID(roomID)
|
||||
if err != nil {
|
||||
http.Error(w, "room not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if room.OwnerID != user.ID {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
err = lib.DeleteRoom(roomID)
|
||||
if err != nil {
|
||||
log.Printf("delete room error: %v", err)
|
||||
http.Error(w, "failed to delete room", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package api
|
||||
|
||||
import "net/http"
|
||||
|
||||
func SetupRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/login", handleLogin)
|
||||
mux.HandleFunc("/register", handleRegister)
|
||||
mux.HandleFunc("/logout", handleLogout)
|
||||
|
||||
mux.HandleFunc("/", requireAuth(handleIndex))
|
||||
mux.HandleFunc("/rooms/new", requireAuth(handleNewRoom))
|
||||
mux.HandleFunc("/rooms/create", requireAuth(handleCreateRoom))
|
||||
mux.HandleFunc("/rooms/join", requireAuth(handleJoinRoom))
|
||||
mux.HandleFunc("/rooms/{id}", requireAuth(handleRoom))
|
||||
mux.HandleFunc("/rooms/{id}/delete", requireAuth(handleDeleteRoom))
|
||||
|
||||
mux.HandleFunc("/rooms/{id}/partial/stories", requireAuth(handlePartialStories))
|
||||
mux.HandleFunc("/rooms/{id}/partial/members", requireAuth(handlePartialMembers))
|
||||
mux.HandleFunc("/rooms/{id}/partial/vote-area", requireAuth(handlePartialVoteArea))
|
||||
|
||||
mux.HandleFunc("/rooms/{id}/stories/new", requireAuth(handleNewStoryForm))
|
||||
mux.HandleFunc("/rooms/{id}/stories", requireAuth(handleAddStory))
|
||||
mux.HandleFunc("/rooms/{id}/active", requireAuth(handleSetActiveStory))
|
||||
mux.HandleFunc("/rooms/{id}/vote", requireAuth(handleVote))
|
||||
mux.HandleFunc("/rooms/{id}/reveal", requireAuth(handleReveal))
|
||||
mux.HandleFunc("/rooms/{id}/stories/{story_id}/edit", requireAuth(handleEditStoryForm))
|
||||
mux.HandleFunc("/rooms/{id}/stories/{story_id}/rename", requireAuth(handleRenameStory))
|
||||
mux.HandleFunc("/rooms/{id}/stories/{story_id}/delete", requireAuth(handleDeleteStory))
|
||||
mux.HandleFunc("/rooms/{id}/stories/{story_id}/reset", requireAuth(handleResetStory))
|
||||
mux.HandleFunc("/rooms/{id}/stories/{story_id}/unreveal", requireAuth(handleUnrevealStory))
|
||||
mux.HandleFunc("/sse/{room_id}", requireAuth(handleSSE))
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"sprintpadawan/lib"
|
||||
)
|
||||
|
||||
func handleSSE(w http.ResponseWriter, r *http.Request) {
|
||||
roomID, err := getPathInt(r, "room_id")
|
||||
if err != nil {
|
||||
http.Error(w, "invalid room id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
user, ok := r.Context().Value(userKey).(*lib.User)
|
||||
if !ok {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
ch := make(chan string, 10)
|
||||
addSSEClient(roomID, user.ID, ch)
|
||||
broadcast(roomID, "members")
|
||||
|
||||
notify := r.Context().Done()
|
||||
heartbeat := time.NewTicker(25 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
|
||||
fmt.Fprint(w, ": connected\n\n")
|
||||
flusher.Flush()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-notify:
|
||||
removeSSEClient(roomID, ch)
|
||||
broadcast(roomID, "members")
|
||||
return
|
||||
case <-heartbeat.C:
|
||||
fmt.Fprint(w, ": keep-alive\n\n")
|
||||
flusher.Flush()
|
||||
case event := <-ch:
|
||||
fmt.Fprintf(w, "event: %s\ndata: true\n\n", event)
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"sprintpadawan/lib"
|
||||
)
|
||||
|
||||
func handleNewStoryForm(w http.ResponseWriter, r *http.Request) {
|
||||
roomID, err := getRoomID(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid room id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
room, err := lib.GetRoomByID(roomID)
|
||||
if err != nil {
|
||||
http.Error(w, "room not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
renderTemplate(w, "story_form.html", room)
|
||||
}
|
||||
|
||||
func handleAddStory(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := getRoomID(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid room id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
user := r.Context().Value(userKey).(*lib.User)
|
||||
title := r.FormValue("title")
|
||||
if _, err := lib.CreateStory(id, title); err != nil {
|
||||
log.Printf("create story error: %v", err)
|
||||
http.Error(w, "failed to create story", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
broadcast(id, "stories")
|
||||
if isHTMX(r) {
|
||||
renderRoomStories(w, id, user)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func handleSetActiveStory(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := getRoomID(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid room id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
user := r.Context().Value(userKey).(*lib.User)
|
||||
sid, err := getFormInt(r, "story_id")
|
||||
if err != nil {
|
||||
http.Error(w, "invalid story id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := lib.SetActiveStory(id, sid); err != nil {
|
||||
http.Error(w, "failed to set active story", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
broadcast(id, "stories")
|
||||
broadcast(id, "members")
|
||||
if isHTMX(r) {
|
||||
renderRoomStories(w, id, user)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func handleResetStory(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := getRoomID(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid room id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
user := r.Context().Value(userKey).(*lib.User)
|
||||
sid, err := getPathInt(r, "story_id")
|
||||
if err != nil {
|
||||
http.Error(w, "invalid story id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := lib.UnrevealStory(sid); err != nil {
|
||||
http.Error(w, "failed to reset story", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := lib.ClearVotesForStory(sid); err != nil {
|
||||
http.Error(w, "failed to clear votes", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
broadcast(id, "stories")
|
||||
broadcast(id, "members")
|
||||
if isHTMX(r) {
|
||||
renderRoomStories(w, id, user)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func handleVote(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := getRoomID(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid room id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
user := r.Context().Value(userKey).(*lib.User)
|
||||
value := r.FormValue("value")
|
||||
sid, err := getFormInt(r, "story_id")
|
||||
if err != nil {
|
||||
http.Error(w, "invalid story id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := lib.VoteOnStory(sid, user.ID, value); err != nil {
|
||||
http.Error(w, "failed to vote", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
broadcast(id, "members")
|
||||
story, storyErr := lib.GetStoryByID(sid)
|
||||
if storyErr == nil && story.Voted {
|
||||
broadcast(id, "stories")
|
||||
}
|
||||
|
||||
if isHTMX(r) {
|
||||
if storyErr != nil {
|
||||
http.Error(w, "story not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
room, err := lib.GetRoomByID(id)
|
||||
if err != nil {
|
||||
http.Error(w, "room not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
tmplData := struct {
|
||||
RoomID int
|
||||
Story lib.Story
|
||||
Scale string
|
||||
UserVote string
|
||||
}{
|
||||
RoomID: id,
|
||||
Story: *story,
|
||||
Scale: room.Scale,
|
||||
UserVote: value,
|
||||
}
|
||||
renderTemplate(w, "vote_form.html", tmplData)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func handleReveal(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := getRoomID(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid room id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
user := r.Context().Value(userKey).(*lib.User)
|
||||
sid, err := getFormInt(r, "story_id")
|
||||
if err != nil {
|
||||
http.Error(w, "invalid story id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := lib.RevealVotes(sid); err != nil {
|
||||
http.Error(w, "failed to reveal votes", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
broadcast(id, "stories")
|
||||
if isHTMX(r) {
|
||||
renderRoomStories(w, id, user)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func handleUnrevealStory(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := getRoomID(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid room id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
user := r.Context().Value(userKey).(*lib.User)
|
||||
sid, err := getPathInt(r, "story_id")
|
||||
if err != nil {
|
||||
http.Error(w, "invalid story id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := lib.UnrevealStory(sid); err != nil {
|
||||
http.Error(w, "failed to hide votes", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
broadcast(id, "stories")
|
||||
if isHTMX(r) {
|
||||
renderRoomStories(w, id, user)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func handleEditStoryForm(w http.ResponseWriter, r *http.Request) {
|
||||
roomID, err := getRoomID(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid room id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
storyID, err := getPathInt(r, "story_id")
|
||||
if err != nil {
|
||||
http.Error(w, "invalid story id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
story, err := lib.GetStoryByID(storyID)
|
||||
if err != nil {
|
||||
http.Error(w, "story not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
data := struct {
|
||||
RoomID int
|
||||
Story lib.Story
|
||||
}{
|
||||
RoomID: roomID,
|
||||
Story: *story,
|
||||
}
|
||||
renderTemplate(w, "story_edit.html", data)
|
||||
}
|
||||
|
||||
func handleRenameStory(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := getRoomID(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid room id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
user := r.Context().Value(userKey).(*lib.User)
|
||||
sid, err := getPathInt(r, "story_id")
|
||||
if err != nil {
|
||||
http.Error(w, "invalid story id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
title := r.FormValue("title")
|
||||
if err := lib.RenameStory(sid, title); err != nil {
|
||||
http.Error(w, "failed to rename story", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
broadcast(id, "stories")
|
||||
if isHTMX(r) {
|
||||
renderRoomStories(w, id, user)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func handleDeleteStory(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := getRoomID(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid room id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
user := r.Context().Value(userKey).(*lib.User)
|
||||
sid, err := getPathInt(r, "story_id")
|
||||
if err != nil {
|
||||
http.Error(w, "invalid story id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := lib.DeleteStory(sid); err != nil {
|
||||
http.Error(w, "failed to delete story", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
broadcast(id, "stories")
|
||||
if isHTMX(r) {
|
||||
renderRoomStories(w, id, user)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"sprintpadawan/lib"
|
||||
)
|
||||
|
||||
var templates *template.Template
|
||||
|
||||
func InitTemplates(fsys fs.FS) {
|
||||
templates = template.Must(template.New("").Funcs(template.FuncMap{
|
||||
"scaleToOptions": scaleToOptions,
|
||||
"derefInt": func(i *int) int {
|
||||
if i == nil {
|
||||
return 0
|
||||
}
|
||||
return *i
|
||||
},
|
||||
"slice": func(s string, start, end int) string {
|
||||
if len(s) == 0 {
|
||||
return ""
|
||||
}
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if end > len(s) || end <= 0 {
|
||||
end = len(s)
|
||||
}
|
||||
if start > end {
|
||||
return ""
|
||||
}
|
||||
return s[start:end]
|
||||
},
|
||||
"dict": func(values ...interface{}) (map[string]interface{}, error) {
|
||||
if len(values)%2 != 0 {
|
||||
return nil, fmt.Errorf("odd number of arguments to dict")
|
||||
}
|
||||
d := make(map[string]interface{})
|
||||
for i := 0; i < len(values); i += 2 {
|
||||
key, ok := values[i].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("dict keys must be strings")
|
||||
}
|
||||
d[key] = values[i+1]
|
||||
}
|
||||
return d, nil
|
||||
},
|
||||
}).ParseFS(fsys, "templates/*.html"))
|
||||
}
|
||||
|
||||
func isHTMX(r *http.Request) bool {
|
||||
return r.Header.Get("HX-Request") == "true"
|
||||
}
|
||||
|
||||
func renderTemplate(w http.ResponseWriter, name string, data interface{}) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := templates.ExecuteTemplate(w, name, data); err != nil {
|
||||
log.Printf("template error (%s): %v", name, err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func renderRoomStories(w http.ResponseWriter, roomID int, user *lib.User) {
|
||||
room, err := lib.GetRoomByID(roomID)
|
||||
if err != nil {
|
||||
http.Error(w, "room not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
data, err := buildRoomData(room, user)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to load room data", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
renderTemplate(w, "stories-panel", data)
|
||||
}
|
||||
|
||||
func renderRoomMembers(w http.ResponseWriter, roomID int, user *lib.User) {
|
||||
room, err := lib.GetRoomByID(roomID)
|
||||
if err != nil {
|
||||
http.Error(w, "room not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
data, err := buildRoomData(room, user)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to load room data", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
renderTemplate(w, "members-panel", data)
|
||||
}
|
||||
|
||||
func requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("session_token")
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
user, err := lib.GetUserFromSession(cookie.Value)
|
||||
if err != nil || user == nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), userKey, user)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
func isLoggedIn(r *http.Request) bool {
|
||||
cookie, err := r.Cookie("session_token")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
user, err := lib.GetUserFromSession(cookie.Value)
|
||||
return err == nil && user != nil
|
||||
}
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"sprintpadawan/lib"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const userKey contextKey = "user"
|
||||
|
||||
type sseClient struct {
|
||||
ch chan string
|
||||
userID int
|
||||
}
|
||||
|
||||
type MemberView struct {
|
||||
Username string
|
||||
HasVoted bool
|
||||
ID int
|
||||
}
|
||||
|
||||
type RoomData struct {
|
||||
*lib.Room
|
||||
User *lib.User
|
||||
Members []MemberView
|
||||
Stories []lib.Story
|
||||
IsOwner bool
|
||||
UserVotes map[int]string
|
||||
StoryVotes map[int][]lib.VoteView
|
||||
}
|
||||
|
||||
var (
|
||||
roomClients = make(map[int][]*sseClient)
|
||||
clientsMu sync.RWMutex
|
||||
)
|
||||
|
||||
func addSSEClient(roomID, userID int, ch chan string) {
|
||||
clientsMu.Lock()
|
||||
defer clientsMu.Unlock()
|
||||
roomClients[roomID] = append(roomClients[roomID], &sseClient{ch: ch, userID: userID})
|
||||
}
|
||||
|
||||
func removeSSEClient(roomID int, ch chan string) {
|
||||
clientsMu.Lock()
|
||||
defer clientsMu.Unlock()
|
||||
clients := roomClients[roomID]
|
||||
for i, c := range clients {
|
||||
if c.ch == ch {
|
||||
roomClients[roomID] = append(clients[:i], clients[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(roomClients[roomID]) == 0 {
|
||||
delete(roomClients, roomID)
|
||||
}
|
||||
}
|
||||
|
||||
func broadcast(roomID int, event string) {
|
||||
clientsMu.RLock()
|
||||
snapshot := append([]*sseClient(nil), roomClients[roomID]...)
|
||||
clientsMu.RUnlock()
|
||||
|
||||
if len(snapshot) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
deadSet := make(map[*sseClient]struct{})
|
||||
for _, c := range snapshot {
|
||||
select {
|
||||
case c.ch <- event:
|
||||
default:
|
||||
deadSet[c] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
if len(deadSet) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
clientsMu.Lock()
|
||||
defer clientsMu.Unlock()
|
||||
|
||||
current := roomClients[roomID]
|
||||
alive := current[:0]
|
||||
for _, c := range current {
|
||||
if _, dead := deadSet[c]; dead {
|
||||
continue
|
||||
}
|
||||
alive = append(alive, c)
|
||||
}
|
||||
if len(alive) == 0 {
|
||||
delete(roomClients, roomID)
|
||||
return
|
||||
}
|
||||
roomClients[roomID] = alive
|
||||
}
|
||||
|
||||
func GetConnectedUserIDs(roomID int) []int {
|
||||
clientsMu.RLock()
|
||||
defer clientsMu.RUnlock()
|
||||
seen := make(map[int]bool)
|
||||
var ids []int
|
||||
for _, c := range roomClients[roomID] {
|
||||
if !seen[c.userID] {
|
||||
seen[c.userID] = true
|
||||
ids = append(ids, c.userID)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func scaleToOptions(scale string) []string {
|
||||
switch scale {
|
||||
case "fibonacci":
|
||||
return []string{"1", "2", "3", "5", "8", "13", "21", "?"}
|
||||
case "tshirt":
|
||||
return []string{"XS", "S", "M", "L", "XL", "XXL", "?"}
|
||||
case "linear":
|
||||
return []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "?"}
|
||||
default:
|
||||
return []string{"1", "2", "3", "5", "8", "13", "21", "?"}
|
||||
}
|
||||
}
|
||||
|
||||
func getRoomID(r *http.Request) (int, error) {
|
||||
return getPathInt(r, "id")
|
||||
}
|
||||
|
||||
func getPathInt(r *http.Request, key string) (int, error) {
|
||||
raw := r.PathValue(key)
|
||||
if raw == "" {
|
||||
return 0, errors.New("missing path parameter")
|
||||
}
|
||||
id, err := strconv.Atoi(raw)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, errors.New("invalid path parameter")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func getFormInt(r *http.Request, key string) (int, error) {
|
||||
raw := r.FormValue(key)
|
||||
if raw == "" {
|
||||
return 0, errors.New("missing form value")
|
||||
}
|
||||
id, err := strconv.Atoi(raw)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, errors.New("invalid form value")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func buildRoomData(room *lib.Room, user *lib.User) (RoomData, error) {
|
||||
members, err := lib.GetRoomMembers(room.ID)
|
||||
if err != nil {
|
||||
return RoomData{}, err
|
||||
}
|
||||
|
||||
stories, err := lib.GetStoriesForRoom(room.ID)
|
||||
if err != nil {
|
||||
return RoomData{}, err
|
||||
}
|
||||
|
||||
connectedIDs := GetConnectedUserIDs(room.ID)
|
||||
connectedMap := make(map[int]bool)
|
||||
for _, cid := range connectedIDs {
|
||||
connectedMap[cid] = true
|
||||
}
|
||||
connectedMap[user.ID] = true
|
||||
|
||||
storyIDs := make([]int, 0, len(stories))
|
||||
revealedStoryIDs := make([]int, 0, len(stories))
|
||||
for _, s := range stories {
|
||||
storyIDs = append(storyIDs, s.ID)
|
||||
if s.Voted {
|
||||
revealedStoryIDs = append(revealedStoryIDs, s.ID)
|
||||
}
|
||||
}
|
||||
|
||||
votesByStory, err := lib.GetVotesForStories(storyIDs)
|
||||
if err != nil {
|
||||
return RoomData{}, err
|
||||
}
|
||||
|
||||
storyVotes, err := lib.GetVoteViewsForStories(revealedStoryIDs)
|
||||
if err != nil {
|
||||
return RoomData{}, err
|
||||
}
|
||||
|
||||
activeVotesByUser := make(map[int]bool)
|
||||
if room.ActiveStoryID != nil {
|
||||
for _, v := range votesByStory[*room.ActiveStoryID] {
|
||||
activeVotesByUser[v.UserID] = true
|
||||
}
|
||||
}
|
||||
|
||||
var memberViews []MemberView
|
||||
for _, m := range members {
|
||||
if !connectedMap[m.ID] {
|
||||
continue
|
||||
}
|
||||
hasVoted := activeVotesByUser[m.ID]
|
||||
memberViews = append(memberViews, MemberView{
|
||||
Username: m.Username,
|
||||
HasVoted: hasVoted,
|
||||
ID: m.ID,
|
||||
})
|
||||
}
|
||||
|
||||
userVotes := make(map[int]string)
|
||||
for _, s := range stories {
|
||||
for _, v := range votesByStory[s.ID] {
|
||||
if v.UserID == user.ID {
|
||||
userVotes[s.ID] = v.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return RoomData{
|
||||
Room: room,
|
||||
User: user,
|
||||
Members: memberViews,
|
||||
Stories: stories,
|
||||
IsOwner: room.OwnerID == user.ID,
|
||||
UserVotes: userVotes,
|
||||
StoryVotes: storyVotes,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
container_name: sprintpadawan
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ${ROOT_DIR:-./data}:/data
|
||||
restart: unless-stopped
|
||||
Generated
+61
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1777268161,
|
||||
"narHash": "sha256-bxrdOn8SCOv8tN4JbTF/TXq7kjo9ag4M+C8yzzIRYbE=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "1c3fe55ad329cbcb28471bb30f05c9827f724c76",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
description = "SprintPadawan Go 1.26 development environment";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
};
|
||||
in
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = [
|
||||
pkgs.go
|
||||
pkgs.gopls
|
||||
pkgs.gotools
|
||||
pkgs.go-tools
|
||||
pkgs.gnumake
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
echo "SprintPadawan Dev Environment Loaded"
|
||||
go version
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
module sprintpadawan
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
golang.org/x/crypto v0.50.0
|
||||
turso.tech/database/tursogo v0.5.3
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/ebitengine/purego v0.10.0 // indirect
|
||||
github.com/tursodatabase/turso-go-platform-libs v0.5.3 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
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/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tursodatabase/turso-go-platform-libs v0.5.3 h1:JvlE+vC2IbyVrxlAVuPoBKvDCQF9hX4c17amhthNqSg=
|
||||
github.com/tursodatabase/turso-go-platform-libs v0.5.3/go.mod h1:bo+Lpv5OYOX1gRV9L5DLKMsYxmDs56SkZwnCOLEFcxU=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
turso.tech/database/tursogo v0.5.3 h1:ICh0AtACo8aVxPj/wsqCnT8Erl/WkNYuhy59/L5RDTI=
|
||||
turso.tech/database/tursogo v0.5.3/go.mod h1:JjsqX4S3fT1arHjqKuiuGxhJQE+VlYboU5YVqHOjy6s=
|
||||
@@ -0,0 +1,134 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "turso.tech/database/tursogo"
|
||||
)
|
||||
|
||||
var DB *sql.DB
|
||||
|
||||
// init sqlite db — creates app.db at project root (run from root) or ROOT_DIR if set
|
||||
func InitDB() {
|
||||
var err error
|
||||
|
||||
dbPath := "app.db"
|
||||
if rootDir := os.Getenv("ROOT_DIR"); rootDir != "" {
|
||||
dbPath = filepath.Join(rootDir, "app.db")
|
||||
}
|
||||
|
||||
DB, err = sql.Open("turso", dbPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err = DB.Exec("PRAGMA foreign_keys = ON;"); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// make users table
|
||||
userTable := `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);`
|
||||
_, err = DB.Exec(userTable)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// make sessions table
|
||||
sessionTable := `
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);`
|
||||
_, err = DB.Exec(sessionTable)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// make rooms table
|
||||
roomTable := `
|
||||
CREATE TABLE IF NOT EXISTS rooms (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
scale TEXT NOT NULL DEFAULT 'fibonacci',
|
||||
owner_id INTEGER NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
active_story_id INTEGER,
|
||||
FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);`
|
||||
_, err = DB.Exec(roomTable)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// make room_members table
|
||||
memberTable := `
|
||||
CREATE TABLE IF NOT EXISTS room_members (
|
||||
room_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
joined_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (room_id, user_id),
|
||||
FOREIGN KEY(room_id) REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);`
|
||||
_, err = DB.Exec(memberTable)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// make stories table
|
||||
storyTable := `
|
||||
CREATE TABLE IF NOT EXISTS stories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
points INTEGER,
|
||||
voted INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY(room_id) REFERENCES rooms(id) ON DELETE CASCADE
|
||||
);`
|
||||
_, err = DB.Exec(storyTable)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// make votes table
|
||||
voteTable := `
|
||||
CREATE TABLE IF NOT EXISTS votes (
|
||||
story_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
PRIMARY KEY (story_id, user_id),
|
||||
FOREIGN KEY(story_id) REFERENCES stories(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);`
|
||||
_, err = DB.Exec(voteTable)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
indexes := []string{
|
||||
"CREATE INDEX IF NOT EXISTS idx_sessions_user_expires ON sessions(user_id, expires_at);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_rooms_owner ON rooms(owner_id);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_room_members_room ON room_members(room_id);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_room_members_user ON room_members(user_id);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_stories_room ON stories(room_id);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_votes_story ON votes(story_id);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_votes_user ON votes(user_id);",
|
||||
}
|
||||
for _, stmt := range indexes {
|
||||
if _, err = DB.Exec(stmt); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Room struct {
|
||||
ID int
|
||||
Name string
|
||||
Code string
|
||||
Scale string
|
||||
OwnerID int
|
||||
CreatedAt time.Time
|
||||
ActiveStoryID *int
|
||||
}
|
||||
|
||||
type RoomMember struct {
|
||||
RoomID int
|
||||
UserID int
|
||||
JoinedAt time.Time
|
||||
}
|
||||
|
||||
type RoomSummary struct {
|
||||
Room
|
||||
MemberCount int
|
||||
}
|
||||
|
||||
// generate unique room code
|
||||
func generateRoomCode() string {
|
||||
code := uuid.New().String()[:8]
|
||||
return code
|
||||
}
|
||||
|
||||
// create room
|
||||
func CreateRoom(name string, ownerID int, scale string) (*Room, error) {
|
||||
code := generateRoomCode()
|
||||
|
||||
tx, err := DB.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
createdAt := time.Now()
|
||||
res, err := tx.Exec("INSERT INTO rooms (name, code, scale, owner_id, created_at, active_story_id) VALUES (?, ?, ?, ?, ?, NULL)", name, code, scale, ownerID, createdAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
roomID64, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
roomID := int(roomID64)
|
||||
|
||||
if _, err := tx.Exec("INSERT INTO room_members (room_id, user_id, joined_at) VALUES (?, ?, ?)", roomID, ownerID, createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Room{
|
||||
ID: roomID,
|
||||
Name: name,
|
||||
Code: code,
|
||||
Scale: scale,
|
||||
OwnerID: ownerID,
|
||||
CreatedAt: createdAt,
|
||||
ActiveStoryID: nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// get room by id
|
||||
func GetRoomByID(id int) (*Room, error) {
|
||||
row := DB.QueryRow("SELECT id, name, code, scale, owner_id, created_at, active_story_id FROM rooms WHERE id = ?", id)
|
||||
r := &Room{}
|
||||
err := row.Scan(&r.ID, &r.Name, &r.Code, &r.Scale, &r.OwnerID, &r.CreatedAt, &r.ActiveStoryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// get room by code
|
||||
func GetRoomByCode(code string) (*Room, error) {
|
||||
row := DB.QueryRow("SELECT id, name, code, scale, owner_id, created_at, active_story_id FROM rooms WHERE code = ?", code)
|
||||
r := &Room{}
|
||||
err := row.Scan(&r.ID, &r.Name, &r.Code, &r.Scale, &r.OwnerID, &r.CreatedAt, &r.ActiveStoryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// get rooms for user
|
||||
func GetRoomsForUser(userID int) ([]Room, error) {
|
||||
summaries, err := GetRoomSummariesForUser(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rooms := make([]Room, 0, len(summaries))
|
||||
for _, s := range summaries {
|
||||
rooms = append(rooms, s.Room)
|
||||
}
|
||||
return rooms, nil
|
||||
}
|
||||
|
||||
func GetRoomSummariesForUser(userID int) ([]RoomSummary, error) {
|
||||
rows, err := DB.Query(`
|
||||
SELECT r.id, r.name, r.code, r.scale, r.owner_id, r.created_at, r.active_story_id, COUNT(rm.user_id) as member_count
|
||||
FROM rooms r
|
||||
LEFT JOIN room_members rm ON r.id = rm.room_id
|
||||
WHERE r.owner_id = ?
|
||||
GROUP BY r.id
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var rooms []RoomSummary
|
||||
for rows.Next() {
|
||||
var r RoomSummary
|
||||
var memberCount int
|
||||
err := rows.Scan(&r.ID, &r.Name, &r.Code, &r.Scale, &r.OwnerID, &r.CreatedAt, &r.ActiveStoryID, &memberCount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.MemberCount = memberCount
|
||||
rooms = append(rooms, r)
|
||||
}
|
||||
return rooms, nil
|
||||
}
|
||||
|
||||
// set active story for room
|
||||
func SetActiveStory(roomID, storyID int) error {
|
||||
_, err := DB.Exec("UPDATE rooms SET active_story_id = ? WHERE id = ?", storyID, roomID)
|
||||
return err
|
||||
}
|
||||
|
||||
// add user to room
|
||||
func AddUserToRoom(roomID, userID int) error {
|
||||
_, err := DB.Exec("INSERT OR IGNORE INTO room_members (room_id, user_id, joined_at) VALUES (?, ?, ?)", roomID, userID, time.Now())
|
||||
return err
|
||||
}
|
||||
|
||||
// get room members
|
||||
func GetRoomMembers(roomID int) ([]User, error) {
|
||||
rows, err := DB.Query(`
|
||||
SELECT u.id, u.username, u.password_hash
|
||||
FROM users u
|
||||
JOIN room_members rm ON u.id = rm.user_id
|
||||
WHERE rm.room_id = ?
|
||||
`, roomID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var members []User
|
||||
for rows.Next() {
|
||||
var u User
|
||||
err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
members = append(members, u)
|
||||
}
|
||||
return members, nil
|
||||
}
|
||||
|
||||
// delete room by id
|
||||
func DeleteRoom(id int) error {
|
||||
_, err := DB.Exec("DELETE FROM rooms WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
func GetVotesForStories(storyIDs []int) (map[int][]Vote, error) {
|
||||
votesByStory := make(map[int][]Vote)
|
||||
if len(storyIDs) == 0 {
|
||||
return votesByStory, nil
|
||||
}
|
||||
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(storyIDs)), ",")
|
||||
args := make([]interface{}, 0, len(storyIDs))
|
||||
for _, id := range storyIDs {
|
||||
args = append(args, id)
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("SELECT story_id, user_id, value FROM votes WHERE story_id IN (%s)", placeholders)
|
||||
rows, err := DB.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var v Vote
|
||||
if err := rows.Scan(&v.StoryID, &v.UserID, &v.Value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
votesByStory[v.StoryID] = append(votesByStory[v.StoryID], v)
|
||||
}
|
||||
|
||||
return votesByStory, nil
|
||||
}
|
||||
|
||||
func GetVoteViewsForStories(storyIDs []int) (map[int][]VoteView, error) {
|
||||
voteViewsByStory := make(map[int][]VoteView)
|
||||
if len(storyIDs) == 0 {
|
||||
return voteViewsByStory, nil
|
||||
}
|
||||
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(storyIDs)), ",")
|
||||
args := make([]interface{}, 0, len(storyIDs))
|
||||
for _, id := range storyIDs {
|
||||
args = append(args, id)
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT v.story_id, u.username, v.value
|
||||
FROM votes v
|
||||
JOIN users u ON v.user_id = u.id
|
||||
WHERE v.story_id IN (%s)
|
||||
`, placeholders)
|
||||
rows, err := DB.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
storyID int
|
||||
vv VoteView
|
||||
)
|
||||
if err := rows.Scan(&storyID, &vv.Username, &vv.Value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
voteViewsByStory[storyID] = append(voteViewsByStory[storyID], vv)
|
||||
}
|
||||
|
||||
return voteViewsByStory, nil
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Story struct {
|
||||
ID int
|
||||
RoomID int
|
||||
Title string
|
||||
Points *int
|
||||
Voted bool
|
||||
}
|
||||
|
||||
type Vote struct {
|
||||
ID int
|
||||
StoryID int
|
||||
UserID int
|
||||
Value string
|
||||
}
|
||||
|
||||
// create story
|
||||
func CreateStory(roomID int, title string) (*Story, error) {
|
||||
res, err := DB.Exec("INSERT INTO stories (room_id, title, voted) VALUES (?, ?, 0)", roomID, title)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id64, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Story{
|
||||
ID: int(id64),
|
||||
RoomID: roomID,
|
||||
Title: title,
|
||||
Points: nil,
|
||||
Voted: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// get stories for room
|
||||
func GetStoriesForRoom(roomID int) ([]Story, error) {
|
||||
rows, err := DB.Query("SELECT id, room_id, title, points, voted FROM stories WHERE room_id = ? ORDER BY id", roomID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var stories []Story
|
||||
for rows.Next() {
|
||||
var s Story
|
||||
var points sql.NullInt64
|
||||
var voted int
|
||||
err := rows.Scan(&s.ID, &s.RoomID, &s.Title, &points, &voted)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if points.Valid {
|
||||
p := int(points.Int64)
|
||||
s.Points = &p
|
||||
}
|
||||
s.Voted = voted == 1
|
||||
stories = append(stories, s)
|
||||
}
|
||||
return stories, nil
|
||||
}
|
||||
|
||||
// vote on story
|
||||
func VoteOnStory(storyID, userID int, value string) error {
|
||||
_, err := DB.Exec("INSERT OR REPLACE INTO votes (story_id, user_id, value) VALUES (?, ?, ?)", storyID, userID, value)
|
||||
return err
|
||||
}
|
||||
|
||||
// get votes for story
|
||||
func GetVotesForStory(storyID int) ([]Vote, error) {
|
||||
rows, err := DB.Query("SELECT story_id, user_id, value FROM votes WHERE story_id = ?", storyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var votes []Vote
|
||||
for rows.Next() {
|
||||
var v Vote
|
||||
err := rows.Scan(&v.StoryID, &v.UserID, &v.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
votes = append(votes, v)
|
||||
}
|
||||
return votes, nil
|
||||
}
|
||||
|
||||
// reveal votes — just marks the story as revealed
|
||||
func RevealVotes(storyID int) error {
|
||||
_, err := DB.Exec("UPDATE stories SET voted = 1 WHERE id = ?", storyID)
|
||||
return err
|
||||
}
|
||||
|
||||
type VoteView struct {
|
||||
Username string
|
||||
Value string
|
||||
}
|
||||
|
||||
// get votes with usernames for display
|
||||
func GetVotesWithUsernames(storyID int) ([]VoteView, error) {
|
||||
rows, err := DB.Query(`
|
||||
SELECT u.username, v.value
|
||||
FROM votes v
|
||||
JOIN users u ON v.user_id = u.id
|
||||
WHERE v.story_id = ?
|
||||
`, storyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var views []VoteView
|
||||
for rows.Next() {
|
||||
var vv VoteView
|
||||
err := rows.Scan(&vv.Username, &vv.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
views = append(views, vv)
|
||||
}
|
||||
return views, nil
|
||||
}
|
||||
|
||||
// unreveal story — set voted back to 0 and clear points
|
||||
func UnrevealStory(storyID int) error {
|
||||
_, err := DB.Exec("UPDATE stories SET voted = 0, points = NULL WHERE id = ?", storyID)
|
||||
return err
|
||||
}
|
||||
|
||||
// clear all votes for a story
|
||||
func ClearVotesForStory(storyID int) error {
|
||||
_, err := DB.Exec("DELETE FROM votes WHERE story_id = ?", storyID)
|
||||
return err
|
||||
}
|
||||
|
||||
// rename story
|
||||
func RenameStory(storyID int, title string) error {
|
||||
_, err := DB.Exec("UPDATE stories SET title = ? WHERE id = ?", title, storyID)
|
||||
return err
|
||||
}
|
||||
|
||||
// delete story and its votes
|
||||
func DeleteStory(storyID int) error {
|
||||
_, err := DB.Exec("DELETE FROM stories WHERE id = ?", storyID)
|
||||
return err
|
||||
}
|
||||
|
||||
func tshirtToPoints(s string) float64 {
|
||||
switch s {
|
||||
case "XS":
|
||||
return 1
|
||||
case "S":
|
||||
return 3
|
||||
case "M":
|
||||
return 5
|
||||
case "L":
|
||||
return 8
|
||||
case "XL":
|
||||
return 13
|
||||
case "XXL":
|
||||
return 21
|
||||
case "?":
|
||||
return 0
|
||||
default:
|
||||
n, _ := strconv.Atoi(s)
|
||||
return float64(n)
|
||||
}
|
||||
}
|
||||
|
||||
func GetStoryByID(id int) (*Story, error) {
|
||||
row := DB.QueryRow("SELECT id, room_id, title, points, voted FROM stories WHERE id = ?", id)
|
||||
var s Story
|
||||
var points sql.NullInt64
|
||||
var voted int
|
||||
err := row.Scan(&s.ID, &s.RoomID, &s.Title, &points, &voted)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if points.Valid {
|
||||
p := int(points.Int64)
|
||||
s.Points = &p
|
||||
}
|
||||
s.Voted = voted == 1
|
||||
return &s, nil
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int
|
||||
Username string
|
||||
PasswordHash string
|
||||
}
|
||||
|
||||
// hash password
|
||||
func HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
// check password
|
||||
func CheckPasswordHash(password, hash string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// create new user
|
||||
func CreateUser(username, password string) error {
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = DB.Exec("INSERT INTO users (username, password_hash) VALUES (?, ?)", username, hash)
|
||||
return err
|
||||
}
|
||||
|
||||
// get user by name
|
||||
func GetUserByUsername(username string) (*User, error) {
|
||||
row := DB.QueryRow("SELECT id, username, password_hash FROM users WHERE username = ?", username)
|
||||
u := &User{}
|
||||
err := row.Scan(&u.ID, &u.Username, &u.PasswordHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// create session token
|
||||
func CreateSession(userID int) (string, error) {
|
||||
sessionID := uuid.New().String()
|
||||
expiresAt := time.Now().Add(24 * time.Hour)
|
||||
|
||||
_, err := DB.Exec("INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)", sessionID, userID, expiresAt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return sessionID, nil
|
||||
}
|
||||
|
||||
// get user from session
|
||||
func GetUserFromSession(sessionID string) (*User, error) {
|
||||
row := DB.QueryRow(`
|
||||
SELECT u.id, u.username, u.password_hash
|
||||
FROM users u
|
||||
JOIN sessions s ON u.id = s.user_id
|
||||
WHERE s.id = ? AND s.expires_at > ?
|
||||
`, sessionID, time.Now())
|
||||
|
||||
u := &User{}
|
||||
err := row.Scan(&u.ID, &u.Username, &u.PasswordHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// delete session
|
||||
func DeleteSession(sessionID string) error {
|
||||
_, err := DB.Exec("DELETE FROM sessions WHERE id = ?", sessionID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"embed"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
|
||||
"sprintpadawan/api"
|
||||
"sprintpadawan/lib"
|
||||
)
|
||||
|
||||
type gzipResponseWriter struct {
|
||||
io.Writer
|
||||
http.ResponseWriter
|
||||
}
|
||||
|
||||
var fingerprintPattern = regexp.MustCompile(`[-.][a-f0-9]{8,}\.`)
|
||||
|
||||
func (w gzipResponseWriter) Write(b []byte) (int, error) {
|
||||
return w.Writer.Write(b)
|
||||
}
|
||||
|
||||
func gzipMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
w.Header().Set("Vary", "Accept-Encoding")
|
||||
|
||||
gz := gzip.NewWriter(w)
|
||||
defer gz.Close()
|
||||
|
||||
gzw := gzipResponseWriter{Writer: gz, ResponseWriter: w}
|
||||
next.ServeHTTP(gzw, r)
|
||||
})
|
||||
}
|
||||
|
||||
func cacheMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if fingerprintPattern.MatchString(filepath.Base(r.URL.Path)) {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
//go:embed static templates
|
||||
var embeddedFiles embed.FS
|
||||
|
||||
func main() {
|
||||
// load .env file if it exists
|
||||
_ = godotenv.Load()
|
||||
|
||||
lib.InitDB()
|
||||
api.InitTemplates(embeddedFiles)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// serve static assets
|
||||
staticHandler := http.FileServer(http.FS(embeddedFiles))
|
||||
mux.Handle("/static/", cacheMiddleware(gzipMiddleware(staticHandler)))
|
||||
|
||||
api.SetupRoutes(mux)
|
||||
|
||||
addr := ":8080"
|
||||
log.Printf("Starting SprintPadawan server on http://localhost%s", addr)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,63 @@
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/static/fonts/JetBrainsMono-Regular.ttf) format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(/static/fonts/JetBrainsMono-Medium.ttf) format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Outfit';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(/static/fonts/Outfit-Medium.ttf) format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Outfit';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/static/fonts/Outfit-SemiBold.ttf) format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Outfit';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(/static/fonts/Outfit-Bold.ttf) format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Plus Jakarta Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/static/fonts/PlusJakartaSans-Regular.ttf) format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Plus Jakarta Sans';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(/static/fonts/PlusJakartaSans-Medium.ttf) format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Plus Jakarta Sans';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/static/fonts/PlusJakartaSans-SemiBold.ttf) format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Plus Jakarta Sans';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(/static/fonts/PlusJakartaSans-Bold.ttf) format('truetype');
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,290 @@
|
||||
/*
|
||||
Server Sent Events Extension
|
||||
============================
|
||||
This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
|
||||
|
||||
*/
|
||||
|
||||
(function () {
|
||||
/** @type {import("../htmx").HtmxInternalApi} */
|
||||
var api
|
||||
|
||||
htmx.defineExtension('sse', {
|
||||
|
||||
/**
|
||||
* Init saves the provided reference to the internal HTMX API.
|
||||
*
|
||||
* @param {import("../htmx").HtmxInternalApi} api
|
||||
* @returns void
|
||||
*/
|
||||
init: function (apiRef) {
|
||||
// store a reference to the internal API.
|
||||
api = apiRef
|
||||
|
||||
// set a function in the public API for creating new EventSource objects
|
||||
if (htmx.createEventSource == undefined) {
|
||||
htmx.createEventSource = createEventSource
|
||||
}
|
||||
},
|
||||
|
||||
getSelectors: function () {
|
||||
return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]']
|
||||
},
|
||||
|
||||
/**
|
||||
* onEvent handles all events passed to this extension.
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {Event} evt
|
||||
* @returns void
|
||||
*/
|
||||
onEvent: function (name, evt) {
|
||||
var parent = evt.target || evt.detail.elt
|
||||
switch (name) {
|
||||
case 'htmx:beforeCleanupElement':
|
||||
var internalData = api.getInternalData(parent)
|
||||
// Try to remove remove an EventSource when elements are removed
|
||||
var source = internalData.sseEventSource
|
||||
if (source) {
|
||||
api.triggerEvent(parent, 'htmx:sseClose', {
|
||||
source,
|
||||
type: 'nodeReplaced',
|
||||
})
|
||||
internalData.sseEventSource.close()
|
||||
}
|
||||
|
||||
return
|
||||
|
||||
// Try to create EventSources when elements are processed
|
||||
case 'htmx:afterProcessNode':
|
||||
ensureEventSourceOnElement(parent)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/// ////////////////////////////////////////////
|
||||
// HELPER FUNCTIONS
|
||||
/// ////////////////////////////////////////////
|
||||
|
||||
/**
|
||||
* createEventSource is the default method for creating new EventSource objects.
|
||||
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
|
||||
*
|
||||
* @param {string} url
|
||||
* @returns EventSource
|
||||
*/
|
||||
function createEventSource(url) {
|
||||
return new EventSource(url, { withCredentials: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* registerSSE looks for attributes that can contain sse events, right
|
||||
* now hx-trigger and sse-swap and adds listeners based on these attributes too
|
||||
* the closest event source
|
||||
*
|
||||
* @param {HTMLElement} elt
|
||||
*/
|
||||
function registerSSE(elt) {
|
||||
// Add message handlers for every `sse-swap` attribute
|
||||
if (api.getAttributeValue(elt, 'sse-swap')) {
|
||||
// Find closest existing event source
|
||||
var sourceElement = api.getClosestMatch(elt, hasEventSource)
|
||||
if (sourceElement == null) {
|
||||
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
|
||||
return null // no eventsource in parentage, orphaned element
|
||||
}
|
||||
|
||||
// Set internalData and source
|
||||
var internalData = api.getInternalData(sourceElement)
|
||||
var source = internalData.sseEventSource
|
||||
|
||||
var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap')
|
||||
var sseEventNames = sseSwapAttr.split(',')
|
||||
|
||||
for (var i = 0; i < sseEventNames.length; i++) {
|
||||
const sseEventName = sseEventNames[i].trim()
|
||||
const listener = function (event) {
|
||||
// If the source is missing then close SSE
|
||||
if (maybeCloseSSESource(sourceElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
// If the body no longer contains the element, remove the listener
|
||||
if (!api.bodyContains(elt)) {
|
||||
source.removeEventListener(sseEventName, listener)
|
||||
return
|
||||
}
|
||||
|
||||
// swap the response into the DOM and trigger a notification
|
||||
if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) {
|
||||
return
|
||||
}
|
||||
swap(elt, event.data)
|
||||
api.triggerEvent(elt, 'htmx:sseMessage', event)
|
||||
}
|
||||
|
||||
// Register the new listener
|
||||
api.getInternalData(elt).sseEventListener = listener
|
||||
source.addEventListener(sseEventName, listener)
|
||||
}
|
||||
}
|
||||
|
||||
// Add message handlers for every `hx-trigger="sse:*"` attribute
|
||||
if (api.getAttributeValue(elt, 'hx-trigger')) {
|
||||
// Find closest existing event source
|
||||
var sourceElement = api.getClosestMatch(elt, hasEventSource)
|
||||
if (sourceElement == null) {
|
||||
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
|
||||
return null // no eventsource in parentage, orphaned element
|
||||
}
|
||||
|
||||
// Set internalData and source
|
||||
var internalData = api.getInternalData(sourceElement)
|
||||
var source = internalData.sseEventSource
|
||||
|
||||
var triggerSpecs = api.getTriggerSpecs(elt)
|
||||
triggerSpecs.forEach(function (ts) {
|
||||
if (ts.trigger.slice(0, 4) !== 'sse:') {
|
||||
return
|
||||
}
|
||||
|
||||
var listener = function (event) {
|
||||
if (maybeCloseSSESource(sourceElement)) {
|
||||
return
|
||||
}
|
||||
if (!api.bodyContains(elt)) {
|
||||
source.removeEventListener(ts.trigger.slice(4), listener)
|
||||
}
|
||||
// Trigger events to be handled by the rest of htmx
|
||||
htmx.trigger(elt, ts.trigger, event)
|
||||
htmx.trigger(elt, 'htmx:sseMessage', event)
|
||||
}
|
||||
|
||||
// Register the new listener
|
||||
api.getInternalData(elt).sseEventListener = listener
|
||||
source.addEventListener(ts.trigger.slice(4), listener)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
|
||||
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
|
||||
* is created and stored in the element's internalData.
|
||||
* @param {HTMLElement} elt
|
||||
* @param {number} retryCount
|
||||
* @returns {EventSource | null}
|
||||
*/
|
||||
function ensureEventSourceOnElement(elt, retryCount) {
|
||||
if (elt == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
// handle extension source creation attribute
|
||||
if (api.getAttributeValue(elt, 'sse-connect')) {
|
||||
var sseURL = api.getAttributeValue(elt, 'sse-connect')
|
||||
if (sseURL == null) {
|
||||
return
|
||||
}
|
||||
|
||||
ensureEventSource(elt, sseURL, retryCount)
|
||||
}
|
||||
|
||||
registerSSE(elt)
|
||||
}
|
||||
|
||||
function ensureEventSource(elt, url, retryCount) {
|
||||
var source = htmx.createEventSource(url)
|
||||
|
||||
source.onerror = function (err) {
|
||||
// Log an error event
|
||||
api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source })
|
||||
|
||||
// If parent no longer exists in the document, then clean up this EventSource
|
||||
if (maybeCloseSSESource(elt)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, try to reconnect the EventSource
|
||||
if (source.readyState === EventSource.CLOSED) {
|
||||
retryCount = retryCount || 0
|
||||
retryCount = Math.max(Math.min(retryCount * 2, 128), 1)
|
||||
var timeout = retryCount * 500
|
||||
window.setTimeout(function () {
|
||||
ensureEventSourceOnElement(elt, retryCount)
|
||||
}, timeout)
|
||||
}
|
||||
}
|
||||
|
||||
source.onopen = function (evt) {
|
||||
api.triggerEvent(elt, 'htmx:sseOpen', { source })
|
||||
|
||||
if (retryCount && retryCount > 0) {
|
||||
const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]")
|
||||
for (let i = 0; i < childrenToFix.length; i++) {
|
||||
registerSSE(childrenToFix[i])
|
||||
}
|
||||
// We want to increase the reconnection delay for consecutive failed attempts only
|
||||
retryCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
api.getInternalData(elt).sseEventSource = source
|
||||
|
||||
|
||||
var closeAttribute = api.getAttributeValue(elt, "sse-close");
|
||||
if (closeAttribute) {
|
||||
// close eventsource when this message is received
|
||||
source.addEventListener(closeAttribute, function () {
|
||||
api.triggerEvent(elt, 'htmx:sseClose', {
|
||||
source,
|
||||
type: 'message',
|
||||
})
|
||||
source.close()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* maybeCloseSSESource confirms that the parent element still exists.
|
||||
* If not, then any associated SSE source is closed and the function returns true.
|
||||
*
|
||||
* @param {HTMLElement} elt
|
||||
* @returns boolean
|
||||
*/
|
||||
function maybeCloseSSESource(elt) {
|
||||
if (!api.bodyContains(elt)) {
|
||||
var source = api.getInternalData(elt).sseEventSource
|
||||
if (source != undefined) {
|
||||
api.triggerEvent(elt, 'htmx:sseClose', {
|
||||
source,
|
||||
type: 'nodeMissing',
|
||||
})
|
||||
source.close()
|
||||
// source = null
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} elt
|
||||
* @param {string} content
|
||||
*/
|
||||
function swap(elt, content) {
|
||||
api.withExtensions(elt, function (extension) {
|
||||
content = extension.transformResponse(content, null, elt)
|
||||
})
|
||||
|
||||
var swapSpec = api.getSwapSpecification(elt)
|
||||
var target = api.getTarget(elt)
|
||||
api.swap(target, content, swapSpec)
|
||||
}
|
||||
|
||||
|
||||
function hasEventSource(node) {
|
||||
return api.getInternalData(node).sseEventSource != null
|
||||
}
|
||||
})()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,120 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
{{template "app-head" (dict "Title" "Dashboard" "UseHTMX" true "UseSSE"
|
||||
false)}}
|
||||
<body class="app-body">
|
||||
<div class="app-shell">
|
||||
<div class="main-content">
|
||||
<header class="topbar">
|
||||
<div class="topbar-brand">
|
||||
{{template "brand-mark" .}}
|
||||
<span class="topbar-title">Planning Rooms</span>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<button
|
||||
class="btn-primary topbar-btn"
|
||||
hx-get="/rooms/new"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
Create Room
|
||||
</button>
|
||||
{{template "session-controls" .}}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="page-content">
|
||||
<div class="welcome-hero">
|
||||
<p class="welcome-greeting">
|
||||
Welcome back, <span>{{.Username}}</span>
|
||||
</p>
|
||||
<p class="welcome-sub">
|
||||
Create or join a planning room to start estimating.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{if .Rooms}}
|
||||
<div class="rooms-grid">
|
||||
{{range .Rooms}}
|
||||
<div class="room-card">
|
||||
<a href="/rooms/{{.ID}}" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; z-index: 1;"></a>
|
||||
<div class="room-header" style="position: relative; z-index: 2; pointer-events: none;">
|
||||
<span class="room-name">{{.Name}}</span>
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
pointer-events: auto;
|
||||
"
|
||||
>
|
||||
{{if .IsOwner}}
|
||||
<span
|
||||
class="room-owner-badge"
|
||||
style="
|
||||
position: static;
|
||||
margin: 0;
|
||||
padding: 0.2rem 0.5rem;
|
||||
"
|
||||
>
|
||||
Owner
|
||||
</span>
|
||||
{{end}}
|
||||
<span class="room-code">{{.Code}}</span>
|
||||
{{if .IsOwner}}
|
||||
<button
|
||||
type="button"
|
||||
class="story-action-btn story-action-delete"
|
||||
hx-post="/rooms/{{.ID}}/delete"
|
||||
hx-target="closest .room-card"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Delete this room?"
|
||||
title="Delete Room"
|
||||
aria-label="Delete Room"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4h8v2" />
|
||||
<path d="M19 6l-1 14H6L5 6" />
|
||||
<path d="M10 11v6" />
|
||||
<path d="M14 11v6" />
|
||||
</svg>
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="room-meta" style="position: relative; z-index: 2; pointer-events: none;">
|
||||
<span class="room-scale">{{.Scale}}</span>
|
||||
<span class="room-members"
|
||||
>{{.MemberCount}} members</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<p>No rooms yet. Create one to get started.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="modal-container"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,85 @@
|
||||
{{define "app-head"}}
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{.Title}} — SprintPadawan</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A lightweight real-time sprint planning tool."
|
||||
/>
|
||||
<meta property="og:title" content="{{.Title}} - SprintPadawan" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="A lightweight real-time sprint planning tool."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content="/static/img/logo.png" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="{{.Title}} - SprintPadawan" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="A lightweight real-time sprint planning tool."
|
||||
/>
|
||||
<meta name="twitter:image" content="/static/img/logo.png" />
|
||||
<meta name="theme-color" content="#6366f1" />
|
||||
<link rel="icon" href="/static/img/favicon.ico" sizes="any" />
|
||||
<link rel="icon" type="image/svg+xml" href="/static/img/logo.webp" />
|
||||
<link rel="apple-touch-icon" href="/static/img/logo.png" />
|
||||
<link rel="stylesheet" href="/static/fonts/fonts.css" />
|
||||
<link rel="stylesheet" href="/static/styles/main.css" />
|
||||
{{if .UseHTMX}}
|
||||
<script src="/static/js/htmx.min.js" defer></script>
|
||||
{{end}} {{if .UseSSE}}
|
||||
<script src="/static/js/sse.js" defer></script>
|
||||
{{end}}
|
||||
</head>
|
||||
{{end}} {{define "auth-head"}}
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{.Title}} — SprintPadawan</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A lightweight web application for agile sprint planning."
|
||||
/>
|
||||
<meta property="og:title" content="{{.Title}} — SprintPadawan" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="A lightweight web application for agile sprint planning."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content="/static/img/logo.png" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="{{.Title}} — SprintPadawan" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="A lightweight web application for agile sprint planning."
|
||||
/>
|
||||
<meta name="twitter:image" content="/static/img/logo.png" />
|
||||
<meta name="theme-color" content="#6366f1" />
|
||||
<link rel="icon" href="/static/img/favicon.ico" sizes="any" />
|
||||
<link rel="icon" type="image/svg+xml" href="/static/img/logo.webp" />
|
||||
<link rel="apple-touch-icon" href="/static/img/logo.png" />
|
||||
<link rel="stylesheet" href="/static/fonts/fonts.css" />
|
||||
<link rel="stylesheet" href="/static/styles/main.css" />
|
||||
</head>
|
||||
{{end}} {{define "brand-mark"}}
|
||||
<a href="/" class="brand-mark">
|
||||
<img
|
||||
src="/static/img/logo.webp"
|
||||
alt="SprintPadawan"
|
||||
height="32"
|
||||
width="32"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
/>
|
||||
</a>
|
||||
{{end}} {{define "session-controls"}}
|
||||
<div class="topbar-user">
|
||||
<div class="user-avatar">{{slice .Username 0 1}}</div>
|
||||
<span class="topbar-user-name">{{.Username}}</span>
|
||||
</div>
|
||||
<form method="POST" action="/logout">
|
||||
<button class="topbar-link-btn" type="submit">Sign Out</button>
|
||||
</form>
|
||||
{{end}}
|
||||
@@ -0,0 +1,74 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
{{template "auth-head" (dict "Title" "Sign In")}}
|
||||
<body>
|
||||
<div class="auth-card">
|
||||
<div class="auth-logo">
|
||||
<img
|
||||
src="/static/img/logo.webp"
|
||||
alt="SprintPadawan"
|
||||
height="56"
|
||||
width="56"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<p class="auth-subtitle">Sign in to your account</p>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="form-error">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form class="auth-form" method="POST" action="/login">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="username">Username</label>
|
||||
<input
|
||||
class="form-input"
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
placeholder="your username"
|
||||
autocomplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="password">Password</label>
|
||||
<input
|
||||
class="form-input"
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="••••••••"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button class="btn-primary" type="submit">Sign In</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-footer">
|
||||
No account? <a href="/register">Create one</a>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,91 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
{{template "auth-head" (dict "Title" "Register")}}
|
||||
<body>
|
||||
<div class="auth-card">
|
||||
<div class="auth-logo">
|
||||
<img
|
||||
src="/static/img/logo.webp"
|
||||
alt="SprintPadawan"
|
||||
height="56"
|
||||
width="56"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<p class="auth-subtitle">Create your account</p>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="form-error">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form class="auth-form" method="POST" action="/register">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="username">Username</label>
|
||||
<input
|
||||
class="form-input"
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
placeholder="choose a username"
|
||||
autocomplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="password">Password</label>
|
||||
<input
|
||||
class="form-input"
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="••••••••"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="confirm_password"
|
||||
>Confirm Password</label
|
||||
>
|
||||
<input
|
||||
class="form-input"
|
||||
type="password"
|
||||
id="confirm_password"
|
||||
name="confirm_password"
|
||||
placeholder="••••••••"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button class="btn-primary" type="submit">
|
||||
Create Account
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-footer">
|
||||
Already have an account? <a href="/login">Sign in</a>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,98 @@
|
||||
{{define "room.html"}}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
{{template "app-head" (dict "Title" .Room.Name "UseHTMX" true "UseSSE"
|
||||
true)}}
|
||||
<body class="app-body">
|
||||
<div class="app-shell">
|
||||
<div class="main-content">
|
||||
<header class="topbar">
|
||||
<div class="topbar-brand">
|
||||
{{template "brand-mark" .}}
|
||||
<a href="/" class="back-btn">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="19" y1="12" x2="5" y2="12"></line>
|
||||
<polyline points="12 19 5 12 12 5"></polyline>
|
||||
</svg>
|
||||
</a>
|
||||
<span class="topbar-title room-title"
|
||||
>{{.Room.Name}}</span
|
||||
>
|
||||
<span class="room-code-badge">{{.Room.Code}}</span>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<span class="scale-badge">{{.Room.Scale}}</span>
|
||||
{{if .IsOwner}}
|
||||
<button
|
||||
hx-get="/rooms/{{.Room.ID}}/stories/new"
|
||||
hx-target="#modal-container"
|
||||
hx-swap="innerHTML"
|
||||
class="btn-primary topbar-btn"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
Add Story
|
||||
</button>
|
||||
{{end}} {{template "session-controls" .User}}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="page-content">
|
||||
<div
|
||||
id="room-stream"
|
||||
class="room-stream"
|
||||
hx-ext="sse"
|
||||
sse-connect="/sse/{{.Room.ID}}"
|
||||
>
|
||||
<div
|
||||
hidden
|
||||
hx-get="/rooms/{{.Room.ID}}/partial/stories"
|
||||
hx-trigger="sse:stories"
|
||||
hx-target="#stories-panel"
|
||||
hx-swap="outerHTML"
|
||||
></div>
|
||||
<div
|
||||
hidden
|
||||
hx-get="/rooms/{{.Room.ID}}/partial/members"
|
||||
hx-trigger="sse:members"
|
||||
hx-target="#members-panel"
|
||||
hx-swap="outerHTML"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="room-layout">
|
||||
{{template "stories-panel" .}} {{template
|
||||
"members-panel" .}}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="modal-container"></div>
|
||||
|
||||
{{template "sse-script"}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,31 @@
|
||||
<div class="modal-overlay" onclick="this.remove()">
|
||||
<div class="modal" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<h2>Create Room</h2>
|
||||
<button class="modal-close" onclick="this.closest('.modal-overlay').remove()">×</button>
|
||||
</div>
|
||||
<form method="POST" action="/rooms/create" class="auth-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Room Name</label>
|
||||
<input class="form-input" type="text" name="name" placeholder="Sprint Planning" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Voting Scale</label>
|
||||
<select class="form-input" name="scale">
|
||||
<option value="fibonacci">Fibonacci (1, 2, 3, 5, 8, 13, 21, ?)</option>
|
||||
<option value="tshirt">T-Shirt (XS, S, M, L, XL, XXL, ?)</option>
|
||||
<option value="linear">Linear (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ?)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn-primary" type="submit">Create Room</button>
|
||||
</form>
|
||||
<div class="divider" style="margin: 1.5rem 0"></div>
|
||||
<form method="POST" action="/rooms/join" class="auth-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Or Join Existing</label>
|
||||
<input class="form-input" type="text" name="code" placeholder="Room code" required />
|
||||
</div>
|
||||
<button class="btn-primary" type="submit" style="background: var(--bg-surface); border: 1px solid var(--border);">Join Room</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,316 @@
|
||||
{{define "stories-panel"}}
|
||||
<div class="stories-panel" id="stories-panel">
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
"
|
||||
>
|
||||
<h3 class="panel-title">Stories</h3>
|
||||
<div class="sse-indicator">
|
||||
<span class="sse-dot"></span>
|
||||
<span>Live</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stories-list" id="stories-list">
|
||||
{{template "stories-list" .}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "stories-list"}} {{range .Stories}} {{$isActive := eq (derefInt
|
||||
$.Room.ActiveStoryID) .ID}}
|
||||
<div
|
||||
class="story-card {{if .Voted}}story-revealed{{end}} {{if $isActive}}story-active{{end}}"
|
||||
id="story-{{.ID}}"
|
||||
>
|
||||
<div class="story-header">
|
||||
<span class="story-title">{{.Title}}</span>
|
||||
<div class="story-actions">
|
||||
{{if $isActive}}
|
||||
<span class="story-badge story-badge-active">Active</span>
|
||||
{{end}} {{if $.IsOwner}} {{if .Voted}}
|
||||
<form
|
||||
method="POST"
|
||||
action="/rooms/{{$.Room.ID}}/stories/{{.ID}}/unreveal"
|
||||
hx-post="/rooms/{{$.Room.ID}}/stories/{{.ID}}/unreveal"
|
||||
hx-target="#stories-panel"
|
||||
hx-swap="outerHTML"
|
||||
class="story-action-form"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="story-action-btn story-action-btn-toggle"
|
||||
title="Hide votes"
|
||||
aria-label="Hide votes"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d="M10.733 5.076A10.744 10.744 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.673 2.68"
|
||||
/>
|
||||
<path
|
||||
d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"
|
||||
/>
|
||||
<line x1="2" y1="2" x2="22" y2="22" />
|
||||
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form
|
||||
method="POST"
|
||||
action="/rooms/{{$.Room.ID}}/reveal"
|
||||
hx-post="/rooms/{{$.Room.ID}}/reveal"
|
||||
hx-target="#stories-panel"
|
||||
hx-swap="outerHTML"
|
||||
class="story-action-form"
|
||||
>
|
||||
<input type="hidden" name="story_id" value="{{.ID}}" />
|
||||
<button
|
||||
type="submit"
|
||||
class="story-action-btn story-action-btn-toggle"
|
||||
title="Reveal votes"
|
||||
aria-label="Reveal votes"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
{{end}} {{if not $isActive}}
|
||||
<form
|
||||
method="POST"
|
||||
action="/rooms/{{$.Room.ID}}/active"
|
||||
hx-post="/rooms/{{$.Room.ID}}/active"
|
||||
hx-target="#stories-panel"
|
||||
hx-swap="outerHTML"
|
||||
class="story-action-form"
|
||||
>
|
||||
<input type="hidden" name="story_id" value="{{.ID}}" />
|
||||
<button
|
||||
type="submit"
|
||||
class="story-action-btn story-action-btn-activate"
|
||||
title="Set active"
|
||||
aria-label="Set active"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polygon points="10 8 16 12 10 16 10 8" />
|
||||
<path d="M4 6v12" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
{{end}} {{if $.IsOwner}}
|
||||
<form
|
||||
method="POST"
|
||||
action="/rooms/{{$.Room.ID}}/stories/{{.ID}}/reset"
|
||||
hx-post="/rooms/{{$.Room.ID}}/stories/{{.ID}}/reset"
|
||||
hx-target="#stories-panel"
|
||||
hx-swap="outerHTML"
|
||||
class="story-action-form"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="story-action-btn story-action-btn-reset"
|
||||
title="Reset votes"
|
||||
aria-label="Reset votes"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21 2v6h-6" />
|
||||
<path d="M3 12a9 9 0 0 1 15.55-6.36L21 8" />
|
||||
<path d="M3 22v-6h6" />
|
||||
<path d="M21 12a9 9 0 0 1-15.55 6.36L3 16" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
class="story-action-btn"
|
||||
hx-get="/rooms/{{$.Room.ID}}/stories/{{.ID}}/edit"
|
||||
hx-target="#modal-container"
|
||||
hx-swap="innerHTML"
|
||||
title="Rename"
|
||||
aria-label="Rename story"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 20h9" />
|
||||
<path
|
||||
d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="story-action-btn story-action-delete"
|
||||
hx-post="/rooms/{{$.Room.ID}}/stories/{{.ID}}/delete"
|
||||
hx-target="#stories-panel"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Delete this story?"
|
||||
title="Delete"
|
||||
aria-label="Delete story"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4h8v2" />
|
||||
<path d="M19 6l-1 14H6L5 6" />
|
||||
<path d="M10 11v6" />
|
||||
<path d="M14 11v6" />
|
||||
</svg>
|
||||
</button>
|
||||
{{end}} {{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if $isActive}}
|
||||
<div id="vote-area">
|
||||
{{template "vote-area" (dict "RoomData" $ "Story" .)}}
|
||||
</div>
|
||||
{{end}} {{if .Voted}}
|
||||
<div class="revealed-votes">
|
||||
{{range (index $.StoryVotes .ID)}}
|
||||
<div class="revealed-vote">
|
||||
<span class="revealed-vote-user">{{.Username}}</span>
|
||||
<span class="revealed-vote-value">{{.Value}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<p>No stories yet. Add one to start voting.</p>
|
||||
</div>
|
||||
{{end}} {{end}}
|
||||
|
||||
{{define "vote-area"}} {{$data := .RoomData}} {{$story := .Story}} {{$userVote
|
||||
:= index $data.UserVotes $story.ID}}
|
||||
<div class="vote-form" id="vote-form-{{$story.ID}}">
|
||||
{{range scaleToOptions $data.Room.Scale}}
|
||||
<button
|
||||
type="button"
|
||||
hx-post="/rooms/{{$data.Room.ID}}/vote"
|
||||
hx-target="#vote-form-{{$story.ID}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-vals='{"story_id":"{{$story.ID}}","value":"{{.}}"}'
|
||||
class="vote-option {{if eq . $userVote}}vote-selected{{end}}"
|
||||
>
|
||||
{{.}}
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "members-panel"}}
|
||||
<div class="members-panel" id="members-panel">
|
||||
<h3 class="panel-title">Members ({{len .Members}})</h3>
|
||||
<div class="members-list">
|
||||
{{range .Members}}
|
||||
<div class="member-row">
|
||||
<div
|
||||
class="user-avatar"
|
||||
style="width: 28px; height: 28px; font-size: 0.7rem"
|
||||
>
|
||||
{{slice .Username 0 1}}
|
||||
</div>
|
||||
<span class="member-name">{{.Username}}</span>
|
||||
{{if .HasVoted}}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="var(--success-text)"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
style="margin-left: auto"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "sse-script"}}
|
||||
<script>
|
||||
document.addEventListener("htmx:sseOpen", function() {
|
||||
document.body.classList.add("sse-connected");
|
||||
});
|
||||
|
||||
document.addEventListener("htmx:sseClose", function() {
|
||||
document.body.classList.remove("sse-connected");
|
||||
});
|
||||
|
||||
document.addEventListener("htmx:sseError", function() {
|
||||
document.body.classList.remove("sse-connected");
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,34 @@
|
||||
<div class="modal-overlay" onclick="this.remove()">
|
||||
<div class="modal" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<h2>Rename Story</h2>
|
||||
<button
|
||||
class="modal-close"
|
||||
onclick="this.closest('.modal-overlay').remove()"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<form
|
||||
method="POST"
|
||||
action="/rooms/{{.RoomID}}/stories/{{.Story.ID}}/rename"
|
||||
hx-post="/rooms/{{.RoomID}}/stories/{{.Story.ID}}/rename"
|
||||
hx-target="#stories-panel"
|
||||
hx-swap="outerHTML"
|
||||
hx-on::after-request="if (event.detail.successful) document.getElementById('modal-container').innerHTML = ''"
|
||||
class="auth-form"
|
||||
>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Story Title</label>
|
||||
<input
|
||||
class="form-input"
|
||||
type="text"
|
||||
name="title"
|
||||
value="{{.Story.Title}}"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button class="btn-primary" type="submit">Save Changes</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,34 @@
|
||||
<div class="modal-overlay" onclick="this.remove()">
|
||||
<div class="modal" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<h2>Add Story</h2>
|
||||
<button
|
||||
class="modal-close"
|
||||
onclick="this.closest('.modal-overlay').remove()"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<form
|
||||
method="POST"
|
||||
action="/rooms/{{.ID}}/stories"
|
||||
hx-post="/rooms/{{.ID}}/stories"
|
||||
hx-target="#stories-panel"
|
||||
hx-swap="outerHTML"
|
||||
hx-on::after-request="if (event.detail.successful) document.getElementById('modal-container').innerHTML = ''"
|
||||
class="auth-form"
|
||||
>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Story Title</label>
|
||||
<input
|
||||
class="form-input"
|
||||
type="text"
|
||||
name="title"
|
||||
placeholder="e.g. As a user..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button class="btn-primary" type="submit">Add Story</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
{{$userVote := .UserVote}} {{$roomID := .RoomID}} {{$storyID := .Story.ID}}
|
||||
<div class="vote-form" id="vote-form-{{$storyID}}">
|
||||
{{range scaleToOptions .Scale}}
|
||||
<button
|
||||
type="button"
|
||||
hx-post="/rooms/{{$roomID}}/vote"
|
||||
hx-target="#vote-form-{{$storyID}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-vals='{"story_id":"{{$storyID}}","value":"{{.}}"}'
|
||||
class="vote-option {{if eq . $userVote}}vote-selected{{end}}"
|
||||
>
|
||||
{{.}}
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
Reference in New Issue
Block a user