Compare commits
10 Commits
4e715ab1e1
...
main
Author | SHA1 | Date | |
---|---|---|---|
3637edb816 | |||
a55cfe8205 | |||
df3068728a | |||
baad757371 | |||
6e3c40ccb5 | |||
e7800492c3 | |||
db00bfb79a | |||
cac8554b0b | |||
6dbad4dbc8 | |||
13a85f4218 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,2 +1,4 @@
|
||||
*.exe
|
||||
tmp-build
|
||||
view/*_templ.go
|
||||
view/*_templ.txt
|
||||
|
4
build.bat
Normal file
4
build.bat
Normal file
@ -0,0 +1,4 @@
|
||||
templ generate
|
||||
tailwindcss -i css\input.css -o static\style.css --minify
|
||||
go run .
|
||||
|
3
css/input.css
Normal file
3
css/input.css
Normal file
@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
107
database.go
Normal file
107
database.go
Normal file
@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"stevenlr.com/locker/model"
|
||||
)
|
||||
|
||||
func initializeDatabaseV1(db *sql.DB) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.Exec(`PRAGMA user_version = 1`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
CREATE TABLE Lock (
|
||||
Id BLOB NOT NULL UNIQUE,
|
||||
Name TEXT NOT NULL,
|
||||
StartTime TEXT NOT NULL,
|
||||
EndTime TEXT NOT NULL,
|
||||
Owner BLOB NOT NULL,
|
||||
Token TEXT NOT NULL UNIQUE,
|
||||
PRIMARY KEY (Id)
|
||||
)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
CREATE TABLE User (
|
||||
Id BLOB NOT NULL UNIQUE,
|
||||
Name TEXT NOT NULL,
|
||||
Password BLOB NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userName := "admin"
|
||||
userPassword := "admin"
|
||||
|
||||
password, err := bcrypt.GenerateFromPassword([]byte(userPassword), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`INSERT INTO User VALUES ($1, $2, $3)`, model.MakeUUID(), userName, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func migrateDatabaseV2(db *sql.DB) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.Exec(`PRAGMA user_version = 2`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec("CREATE INDEX LockTokenIndex ON Lock(Token)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func InitializeDatabase(db *sql.DB) error {
|
||||
initialVersion := 0
|
||||
row := db.QueryRow("PRAGMA user_version")
|
||||
row.Scan(&initialVersion)
|
||||
|
||||
if initialVersion < 1 {
|
||||
log.Println("Initializing DB V1")
|
||||
err := initializeDatabaseV1(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if initialVersion < 2 {
|
||||
log.Println("Migrating DB to V2")
|
||||
err := migrateDatabaseV2(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
Binary file not shown.
323
locker.go
Normal file
323
locker.go
Normal file
@ -0,0 +1,323 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
"stevenlr.com/locker/model"
|
||||
"stevenlr.com/locker/utils"
|
||||
"stevenlr.com/locker/view"
|
||||
)
|
||||
|
||||
type LockServer struct {
|
||||
db *sql.DB
|
||||
sessions Sessions
|
||||
}
|
||||
|
||||
func (server *LockServer) findCurrentUser(w http.ResponseWriter, r *http.Request) *model.User {
|
||||
return server.sessions.FindCurrentUser(server.db, w, r)
|
||||
}
|
||||
|
||||
func (server *LockServer) handleNotFound(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
view.Error404().Render(context.Background(), w)
|
||||
}
|
||||
|
||||
func (server *LockServer) handleMain(w http.ResponseWriter, r *http.Request) {
|
||||
currentUser := server.findCurrentUser(w, r)
|
||||
if r.URL.Path == "/" {
|
||||
locks := make([]model.Lock, 0)
|
||||
if currentUser != nil {
|
||||
locks = model.GetLocksForUser(server.db, currentUser.Id)
|
||||
}
|
||||
view.Main(view.LocksList(locks, currentUser != nil), currentUser).Render(context.Background(), w)
|
||||
} else {
|
||||
server.handleNotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (server *LockServer) handleLock(w http.ResponseWriter, r *http.Request) {
|
||||
currentUser := server.findCurrentUser(w, r)
|
||||
if currentUser == nil {
|
||||
server.handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
var id model.UUID
|
||||
if err := id.Scan(r.PathValue("lockId")); err != nil {
|
||||
server.handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
lock := model.GetLockForUser(server.db, id, currentUser.Id)
|
||||
if lock != nil && lock.Owner == currentUser.Id {
|
||||
view.Main(view.LockView(*lock), currentUser).Render(context.Background(), w)
|
||||
} else {
|
||||
server.handleNotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (server *LockServer) handleLockAddTimeCommon(w http.ResponseWriter, r *http.Request, lock *model.Lock) bool {
|
||||
if lock.IsFinished() {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("Lock already finished"))
|
||||
return false
|
||||
}
|
||||
|
||||
duration, err := utils.ParseDuration(r.FormValue("timeToAdd"))
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(err.Error()))
|
||||
return false
|
||||
}
|
||||
|
||||
lock.EndTime.Add(duration)
|
||||
res := model.UpdateLockEndTime(server.db, lock.Id, lock.EndTime, lock.Owner)
|
||||
if !res {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (server *LockServer) handleLockAddTime(w http.ResponseWriter, r *http.Request) {
|
||||
currentUser := server.findCurrentUser(w, r)
|
||||
if currentUser == nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var id model.UUID
|
||||
if err := id.Scan(r.PathValue("lockId")); err != nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
lock := model.GetLockForUser(server.db, id, currentUser.Id)
|
||||
if lock == nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if !server.handleLockAddTimeCommon(w, r, lock) {
|
||||
return
|
||||
}
|
||||
|
||||
view.LockInfo(*lock).Render(context.Background(), w)
|
||||
}
|
||||
|
||||
func (server *LockServer) handleApiLockAddTime(w http.ResponseWriter, r *http.Request) {
|
||||
var id model.UUID
|
||||
if err := id.Scan(r.PathValue("lockId")); err != nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
lock := model.GetLockWithToken(server.db, id, r.FormValue("token"))
|
||||
if lock == nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if !server.handleLockAddTimeCommon(w, r, lock) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (server *LockServer) handleGetLockToken(w http.ResponseWriter, r *http.Request) {
|
||||
currentUser := server.findCurrentUser(w, r)
|
||||
if currentUser == nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var id model.UUID
|
||||
if err := id.Scan(r.PathValue("lockId")); err != nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
lock := model.GetLockForUser(server.db, id, currentUser.Id)
|
||||
if lock == nil {
|
||||
server.handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Write([]byte(fmt.Sprint("<code>", lock.Token, "</code>")))
|
||||
}
|
||||
|
||||
func (server *LockServer) handleResetLockToken(w http.ResponseWriter, r *http.Request) {
|
||||
currentUser := server.findCurrentUser(w, r)
|
||||
if currentUser == nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var id model.UUID
|
||||
if err := id.Scan(r.PathValue("lockId")); err != nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
lock := model.GetLockForUser(server.db, id, currentUser.Id)
|
||||
if lock == nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
res := model.RegenerateLockToken(server.db, lock.Id, currentUser.Id)
|
||||
if !res {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
view.LockTokenForm(*lock).Render(context.Background(), w)
|
||||
}
|
||||
|
||||
func (server *LockServer) handleDeleteLock(w http.ResponseWriter, r *http.Request) {
|
||||
user := server.findCurrentUser(w, r)
|
||||
if user == nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var id model.UUID
|
||||
if err := id.Scan(r.PathValue("lockId")); err != nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
success := model.DeleteLock(server.db, id, user.Id)
|
||||
if !success {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func (server *LockServer) handleCreateLock(w http.ResponseWriter, r *http.Request) {
|
||||
lockName := strings.TrimSpace(r.FormValue("lockName"))
|
||||
|
||||
user := server.findCurrentUser(w, r)
|
||||
if user == nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
view.LockCreateForm(lockName, "You are not signed in").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
days, err := utils.ParseNumber(r.FormValue("days"))
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
view.LockCreateForm(lockName, "Error parsing days").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
hours, err := utils.ParseNumber(r.FormValue("hours"))
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
view.LockCreateForm(lockName, "Error parsing hours").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := server.db.Begin()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
view.LockCreateForm(lockName, "Internal server error").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if lockName == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
view.LockCreateForm("", "Lock name cannot be empty").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
err = model.InsertLock(tx, lockName, int(((max(days, 0)*24)+max(hours, 0))*3600), user.Id)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
view.LockCreateForm(lockName, "Internal server error").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
|
||||
locks := model.GetLocksForUser(server.db, user.Id)
|
||||
view.LocksList(locks, user != nil).Render(context.Background(), w)
|
||||
}
|
||||
|
||||
func (server *LockServer) handlePostLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if server.findCurrentUser(w, r) != nil {
|
||||
utils.HtmxRedirect(w, "/")
|
||||
return
|
||||
}
|
||||
|
||||
userName := r.FormValue("user")
|
||||
userPass := r.FormValue("password")
|
||||
|
||||
user := model.GetUserByName(server.db, userName)
|
||||
if user == nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
view.LoginFormError(nil, "Incorrect credentials").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
err := bcrypt.CompareHashAndPassword(user.Password, []byte(userPass))
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
view.LoginFormError(nil, "Incorrect credentials").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
if err := server.sessions.StartSession(user.Id, w); err == nil {
|
||||
utils.HtmxRedirect(w, "/")
|
||||
} else {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
view.LoginFormError(nil, "Internal server error").Render(context.Background(), w)
|
||||
}
|
||||
}
|
||||
|
||||
func (server *LockServer) handlePostLogout(w http.ResponseWriter, r *http.Request) {
|
||||
server.sessions.EndSession(w, r)
|
||||
utils.HtmxRedirect(w, "/")
|
||||
}
|
||||
|
||||
func main() {
|
||||
log.Println("Starting...")
|
||||
|
||||
db, err := sql.Open("sqlite3", "file:lock.db")
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := InitializeDatabase(db); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
myServer := LockServer{db: db, sessions: MakeSessions()}
|
||||
|
||||
fs := http.FileServer(http.Dir("static/"))
|
||||
http.Handle("GET /static/", http.StripPrefix("/static/", fs))
|
||||
|
||||
http.HandleFunc("POST /login", myServer.handlePostLogin)
|
||||
http.HandleFunc("POST /logout", myServer.handlePostLogout)
|
||||
http.HandleFunc("GET /lock/{lockId}", myServer.handleLock)
|
||||
http.HandleFunc("POST /lock/{lockId}/addTime", myServer.handleLockAddTime)
|
||||
http.HandleFunc("POST /api/lock/{lockId}/addTime", myServer.handleApiLockAddTime)
|
||||
http.HandleFunc("DELETE /lock/{lockId}", myServer.handleDeleteLock)
|
||||
http.HandleFunc("POST /lock/{lockId}/resetToken", myServer.handleResetLockToken)
|
||||
http.HandleFunc("GET /lock/{lockId}/token", myServer.handleGetLockToken)
|
||||
http.HandleFunc("PUT /lock", myServer.handleCreateLock)
|
||||
http.HandleFunc("GET /", myServer.handleMain)
|
||||
|
||||
log.Println("Started!")
|
||||
http.ListenAndServe("0.0.0.0:8080", nil)
|
||||
}
|
110
model/lock.go
Normal file
110
model/lock.go
Normal file
@ -0,0 +1,110 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"stevenlr.com/locker/utils"
|
||||
)
|
||||
|
||||
func GenerateLockToken() (string, error) {
|
||||
return utils.GenerateRandomString(66)
|
||||
}
|
||||
|
||||
type Lock struct {
|
||||
Id UUID
|
||||
Name string
|
||||
StartTime Time
|
||||
EndTime Time
|
||||
Owner UUID
|
||||
Token string
|
||||
}
|
||||
|
||||
func (self Lock) IsFinished() bool {
|
||||
return MakeTimeNow().Compare(self.EndTime) >= 0
|
||||
}
|
||||
|
||||
func InsertLock(tx *sql.Tx, name string, seconds int, ownerId UUID) error {
|
||||
now := MakeTimeNow()
|
||||
end := Time(time.Time(now).Add(time.Duration(seconds) * time.Second))
|
||||
id := MakeUUID()
|
||||
token, _ := GenerateLockToken()
|
||||
_, err := tx.Exec(`
|
||||
INSERT INTO Lock VALUES ($1, $2, $3, $4, $5, $6)`, id, name, now, end, ownerId, token)
|
||||
return err
|
||||
}
|
||||
|
||||
func GetLocksForUser(db *sql.DB, owner UUID) []Lock {
|
||||
rows, err := db.Query("SELECT Id, Name FROM Lock WHERE Owner=$1", owner)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
locks := []Lock{}
|
||||
for rows.Next() {
|
||||
var t Lock
|
||||
if err := rows.Scan(&t.Id, &t.Name); err == nil {
|
||||
locks = append(locks, t)
|
||||
}
|
||||
}
|
||||
|
||||
return locks
|
||||
}
|
||||
|
||||
func GetLockForUser(db *sql.DB, id UUID, userId UUID) *Lock {
|
||||
row := db.QueryRow("SELECT Id, Name, StartTime, EndTime, Owner, Token FROM Lock WHERE Id=$1 AND Owner=$2", id, userId)
|
||||
|
||||
var t Lock
|
||||
if err := row.Scan(&t.Id, &t.Name, &t.StartTime, &t.EndTime, &t.Owner, &t.Token); err == nil {
|
||||
return &t
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetLockWithToken(db *sql.DB, id UUID, token string) *Lock {
|
||||
row := db.QueryRow("SELECT Id, Name, StartTime, EndTime, Owner, Token FROM Lock WHERE Id=$1 AND Token=$2", id, token)
|
||||
|
||||
var t Lock
|
||||
if err := row.Scan(&t.Id, &t.Name, &t.StartTime, &t.EndTime, &t.Owner, &t.Token); err == nil {
|
||||
return &t
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteLock(db *sql.DB, id UUID, userId UUID) bool {
|
||||
res, err := db.Exec("DELETE FROM Lock WHERE Id=$1 AND Owner=$2", id, userId)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
affected, err := res.RowsAffected()
|
||||
return err == nil && affected == 1
|
||||
}
|
||||
|
||||
func UpdateLockEndTime(db *sql.DB, id UUID, endTime Time, userId UUID) bool {
|
||||
res, err := db.Exec("UPDATE Lock SET EndTime=$1 WHERE Id=$2 AND Owner=$3", endTime, id, userId)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
affected, err := res.RowsAffected()
|
||||
return err == nil && affected == 1
|
||||
}
|
||||
|
||||
func RegenerateLockToken(db *sql.DB, id UUID, userId UUID) bool {
|
||||
newToken, err := GenerateLockToken()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
res, err := db.Exec("UPDATE Lock SET Token=$1 WHERE Id=$2 AND Owner=$3", newToken, id, userId)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
affected, err := res.RowsAffected()
|
||||
return err == nil && affected == 1
|
||||
}
|
@ -1,14 +0,0 @@
|
||||
package model
|
||||
|
||||
type Timer struct {
|
||||
Id UUID
|
||||
Name string
|
||||
StartTime Time
|
||||
EndTime Time
|
||||
Owner UUID
|
||||
Token string
|
||||
}
|
||||
|
||||
func (self Timer) IsFinished() bool {
|
||||
return MakeTimeNow().Compare(self.EndTime) >= 0
|
||||
}
|
@ -1,8 +1,35 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
Id UUID
|
||||
Name string
|
||||
Salt string
|
||||
Password []byte
|
||||
}
|
||||
|
||||
func GetUserByName(db *sql.DB, name string) *User {
|
||||
row := db.QueryRow("SELECT Id, Name, Password FROM User WHERE Name=$1", name)
|
||||
if row == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var user User
|
||||
row.Scan(&user.Id, &user.Name, &user.Password)
|
||||
|
||||
return &user
|
||||
}
|
||||
|
||||
func GetUserById(db *sql.DB, id UUID) *User {
|
||||
row := db.QueryRow("SELECT Id, Name, Password FROM User WHERE Id=$1", id)
|
||||
if row == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var user User
|
||||
row.Scan(&user.Id, &user.Name, &user.Password)
|
||||
|
||||
return &user
|
||||
}
|
||||
|
84
session.go
Normal file
84
session.go
Normal file
@ -0,0 +1,84 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"stevenlr.com/locker/model"
|
||||
"stevenlr.com/locker/utils"
|
||||
)
|
||||
|
||||
func generateSessionId() (string, error) {
|
||||
return utils.GenerateRandomString(66)
|
||||
}
|
||||
|
||||
type Sessions struct {
|
||||
sessions map[string]Session
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
UserId model.UUID
|
||||
}
|
||||
|
||||
const sessionCookieName = "LockerSession"
|
||||
|
||||
func removeCookie(cookieName string, w http.ResponseWriter) {
|
||||
cookie := http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: "",
|
||||
MaxAge: -1,
|
||||
}
|
||||
http.SetCookie(w, &cookie)
|
||||
}
|
||||
|
||||
func MakeSessions() Sessions {
|
||||
return Sessions{
|
||||
sessions: make(map[string]Session),
|
||||
}
|
||||
}
|
||||
|
||||
func (sessions *Sessions) FindCurrentUser(db *sql.DB, w http.ResponseWriter, r *http.Request) *model.User {
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
userId, ok := sessions.sessions[cookie.Value]
|
||||
if !ok {
|
||||
removeCookie(sessionCookieName, w)
|
||||
return nil
|
||||
}
|
||||
|
||||
user := model.GetUserById(db, userId.UserId)
|
||||
if user == nil {
|
||||
removeCookie(sessionCookieName, w)
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
func (sessions *Sessions) StartSession(user model.UUID, w http.ResponseWriter) error {
|
||||
sessionId, err := generateSessionId()
|
||||
if err != nil {
|
||||
return errors.New("Couldn't generate session ID")
|
||||
}
|
||||
|
||||
cookie := http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: sessionId,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
}
|
||||
|
||||
sessions.sessions[sessionId] = Session{UserId: user}
|
||||
http.SetCookie(w, &cookie)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sessions *Sessions) EndSession(w http.ResponseWriter, r *http.Request) {
|
||||
if cookie, err := r.Cookie(sessionCookieName); err == nil {
|
||||
delete(sessions.sessions, cookie.Value)
|
||||
removeCookie(sessionCookieName, w)
|
||||
}
|
||||
}
|
981
static/style.css
981
static/style.css
@ -1,12 +1,971 @@
|
||||
body {
|
||||
font-size: 16px;
|
||||
/*
|
||||
! tailwindcss v3.4.3 | MIT License | https://tailwindcss.com
|
||||
*/
|
||||
|
||||
/*
|
||||
1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
|
||||
2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
|
||||
*/
|
||||
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
box-sizing: border-box;
|
||||
/* 1 */
|
||||
border-width: 0;
|
||||
/* 2 */
|
||||
border-style: solid;
|
||||
/* 2 */
|
||||
border-color: #e5e7eb;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
::before,
|
||||
::after {
|
||||
--tw-content: '';
|
||||
}
|
||||
|
||||
/*
|
||||
1. Use a consistent sensible line-height in all browsers.
|
||||
2. Prevent adjustments of font size after orientation changes in iOS.
|
||||
3. Use a more readable tab size.
|
||||
4. Use the user's configured `sans` font-family by default.
|
||||
5. Use the user's configured `sans` font-feature-settings by default.
|
||||
6. Use the user's configured `sans` font-variation-settings by default.
|
||||
7. Disable tap highlights on iOS
|
||||
*/
|
||||
|
||||
html,
|
||||
:host {
|
||||
line-height: 1.5;
|
||||
/* 1 */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
/* 2 */
|
||||
-moz-tab-size: 4;
|
||||
/* 3 */
|
||||
-o-tab-size: 4;
|
||||
tab-size: 4;
|
||||
/* 3 */
|
||||
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
/* 4 */
|
||||
font-feature-settings: normal;
|
||||
/* 5 */
|
||||
font-variation-settings: normal;
|
||||
/* 6 */
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
/* 7 */
|
||||
}
|
||||
|
||||
/*
|
||||
1. Remove the margin in all browsers.
|
||||
2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
|
||||
*/
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
/* 1 */
|
||||
line-height: inherit;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
1. Add the correct height in Firefox.
|
||||
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
|
||||
3. Ensure horizontal rules are visible by default.
|
||||
*/
|
||||
|
||||
hr {
|
||||
height: 0;
|
||||
/* 1 */
|
||||
color: inherit;
|
||||
/* 2 */
|
||||
border-top-width: 1px;
|
||||
/* 3 */
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct text decoration in Chrome, Edge, and Safari.
|
||||
*/
|
||||
|
||||
abbr:where([title]) {
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
|
||||
/*
|
||||
Remove the default font size and weight for headings.
|
||||
*/
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
/*
|
||||
Reset links to optimize for opt-in styling instead of opt-out.
|
||||
*/
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct font weight in Edge and Safari.
|
||||
*/
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Use the user's configured `mono` font-family by default.
|
||||
2. Use the user's configured `mono` font-feature-settings by default.
|
||||
3. Use the user's configured `mono` font-variation-settings by default.
|
||||
4. Correct the odd `em` font sizing in all browsers.
|
||||
*/
|
||||
|
||||
code,
|
||||
kbd,
|
||||
samp,
|
||||
pre {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
/* 1 */
|
||||
font-feature-settings: normal;
|
||||
/* 2 */
|
||||
font-variation-settings: normal;
|
||||
/* 3 */
|
||||
font-size: 1em;
|
||||
/* 4 */
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct font size in all browsers.
|
||||
*/
|
||||
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
/*
|
||||
Prevent `sub` and `sup` elements from affecting the line height in all browsers.
|
||||
*/
|
||||
|
||||
sub,
|
||||
sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
|
||||
2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
|
||||
3. Remove gaps between table borders by default.
|
||||
*/
|
||||
|
||||
table {
|
||||
text-indent: 0;
|
||||
/* 1 */
|
||||
border-color: inherit;
|
||||
/* 2 */
|
||||
border-collapse: collapse;
|
||||
/* 3 */
|
||||
}
|
||||
|
||||
/*
|
||||
1. Change the font styles in all browsers.
|
||||
2. Remove the margin in Firefox and Safari.
|
||||
3. Remove default padding in all browsers.
|
||||
*/
|
||||
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
/* 1 */
|
||||
font-feature-settings: inherit;
|
||||
/* 1 */
|
||||
font-variation-settings: inherit;
|
||||
/* 1 */
|
||||
font-size: 100%;
|
||||
/* 1 */
|
||||
font-weight: inherit;
|
||||
/* 1 */
|
||||
line-height: inherit;
|
||||
/* 1 */
|
||||
letter-spacing: inherit;
|
||||
/* 1 */
|
||||
color: inherit;
|
||||
/* 1 */
|
||||
margin: 0;
|
||||
/* 2 */
|
||||
padding: 0;
|
||||
/* 3 */
|
||||
}
|
||||
|
||||
/*
|
||||
Remove the inheritance of text transform in Edge and Firefox.
|
||||
*/
|
||||
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Correct the inability to style clickable types in iOS and Safari.
|
||||
2. Remove default button styles.
|
||||
*/
|
||||
|
||||
button,
|
||||
input:where([type='button']),
|
||||
input:where([type='reset']),
|
||||
input:where([type='submit']) {
|
||||
-webkit-appearance: button;
|
||||
/* 1 */
|
||||
background-color: transparent;
|
||||
/* 2 */
|
||||
background-image: none;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Use the modern Firefox focus style for all focusable elements.
|
||||
*/
|
||||
|
||||
:-moz-focusring {
|
||||
outline: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
|
||||
*/
|
||||
|
||||
:-moz-ui-invalid {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct vertical alignment in Chrome and Firefox.
|
||||
*/
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
/*
|
||||
Correct the cursor style of increment and decrement buttons in Safari.
|
||||
*/
|
||||
|
||||
::-webkit-inner-spin-button,
|
||||
::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Correct the odd appearance in Chrome and Safari.
|
||||
2. Correct the outline style in Safari.
|
||||
*/
|
||||
|
||||
[type='search'] {
|
||||
-webkit-appearance: textfield;
|
||||
/* 1 */
|
||||
outline-offset: -2px;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Remove the inner padding in Chrome and Safari on macOS.
|
||||
*/
|
||||
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Correct the inability to style clickable types in iOS and Safari.
|
||||
2. Change font properties to `inherit` in Safari.
|
||||
*/
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
-webkit-appearance: button;
|
||||
/* 1 */
|
||||
font: inherit;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct display in Chrome and Safari.
|
||||
*/
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
/*
|
||||
Removes the default spacing and border for appropriate elements.
|
||||
*/
|
||||
|
||||
blockquote,
|
||||
dl,
|
||||
dd,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
hr,
|
||||
figure,
|
||||
p,
|
||||
pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
legend {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul,
|
||||
menu {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
Reset default styling for dialogs.
|
||||
*/
|
||||
|
||||
dialog {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
Prevent resizing textareas horizontally by default.
|
||||
*/
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
|
||||
2. Set the default placeholder color to the user's configured gray 400 color.
|
||||
*/
|
||||
|
||||
input::-moz-placeholder, textarea::-moz-placeholder {
|
||||
opacity: 1;
|
||||
/* 1 */
|
||||
color: #9ca3af;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
opacity: 1;
|
||||
/* 1 */
|
||||
color: #9ca3af;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Set the default cursor for buttons.
|
||||
*/
|
||||
|
||||
button,
|
||||
[role="button"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/*
|
||||
Make sure disabled buttons don't get the pointer cursor.
|
||||
*/
|
||||
|
||||
:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
|
||||
2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
|
||||
This can trigger a poorly considered lint error in some tools but is included by design.
|
||||
*/
|
||||
|
||||
img,
|
||||
svg,
|
||||
video,
|
||||
canvas,
|
||||
audio,
|
||||
iframe,
|
||||
embed,
|
||||
object {
|
||||
display: block;
|
||||
/* 1 */
|
||||
vertical-align: middle;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
|
||||
*/
|
||||
|
||||
img,
|
||||
video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Make elements with the HTML hidden attribute stay hidden by default */
|
||||
|
||||
[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
*, ::before, ::after {
|
||||
--tw-border-spacing-x: 0;
|
||||
--tw-border-spacing-y: 0;
|
||||
--tw-translate-x: 0;
|
||||
--tw-translate-y: 0;
|
||||
--tw-rotate: 0;
|
||||
--tw-skew-x: 0;
|
||||
--tw-skew-y: 0;
|
||||
--tw-scale-x: 1;
|
||||
--tw-scale-y: 1;
|
||||
--tw-pan-x: ;
|
||||
--tw-pan-y: ;
|
||||
--tw-pinch-zoom: ;
|
||||
--tw-scroll-snap-strictness: proximity;
|
||||
--tw-gradient-from-position: ;
|
||||
--tw-gradient-via-position: ;
|
||||
--tw-gradient-to-position: ;
|
||||
--tw-ordinal: ;
|
||||
--tw-slashed-zero: ;
|
||||
--tw-numeric-figure: ;
|
||||
--tw-numeric-spacing: ;
|
||||
--tw-numeric-fraction: ;
|
||||
--tw-ring-inset: ;
|
||||
--tw-ring-offset-width: 0px;
|
||||
--tw-ring-offset-color: #fff;
|
||||
--tw-ring-color: rgb(59 130 246 / 0.5);
|
||||
--tw-ring-offset-shadow: 0 0 #0000;
|
||||
--tw-ring-shadow: 0 0 #0000;
|
||||
--tw-shadow: 0 0 #0000;
|
||||
--tw-shadow-colored: 0 0 #0000;
|
||||
--tw-blur: ;
|
||||
--tw-brightness: ;
|
||||
--tw-contrast: ;
|
||||
--tw-grayscale: ;
|
||||
--tw-hue-rotate: ;
|
||||
--tw-invert: ;
|
||||
--tw-saturate: ;
|
||||
--tw-sepia: ;
|
||||
--tw-drop-shadow: ;
|
||||
--tw-backdrop-blur: ;
|
||||
--tw-backdrop-brightness: ;
|
||||
--tw-backdrop-contrast: ;
|
||||
--tw-backdrop-grayscale: ;
|
||||
--tw-backdrop-hue-rotate: ;
|
||||
--tw-backdrop-invert: ;
|
||||
--tw-backdrop-opacity: ;
|
||||
--tw-backdrop-saturate: ;
|
||||
--tw-backdrop-sepia: ;
|
||||
--tw-contain-size: ;
|
||||
--tw-contain-layout: ;
|
||||
--tw-contain-paint: ;
|
||||
--tw-contain-style: ;
|
||||
}
|
||||
|
||||
::backdrop {
|
||||
--tw-border-spacing-x: 0;
|
||||
--tw-border-spacing-y: 0;
|
||||
--tw-translate-x: 0;
|
||||
--tw-translate-y: 0;
|
||||
--tw-rotate: 0;
|
||||
--tw-skew-x: 0;
|
||||
--tw-skew-y: 0;
|
||||
--tw-scale-x: 1;
|
||||
--tw-scale-y: 1;
|
||||
--tw-pan-x: ;
|
||||
--tw-pan-y: ;
|
||||
--tw-pinch-zoom: ;
|
||||
--tw-scroll-snap-strictness: proximity;
|
||||
--tw-gradient-from-position: ;
|
||||
--tw-gradient-via-position: ;
|
||||
--tw-gradient-to-position: ;
|
||||
--tw-ordinal: ;
|
||||
--tw-slashed-zero: ;
|
||||
--tw-numeric-figure: ;
|
||||
--tw-numeric-spacing: ;
|
||||
--tw-numeric-fraction: ;
|
||||
--tw-ring-inset: ;
|
||||
--tw-ring-offset-width: 0px;
|
||||
--tw-ring-offset-color: #fff;
|
||||
--tw-ring-color: rgb(59 130 246 / 0.5);
|
||||
--tw-ring-offset-shadow: 0 0 #0000;
|
||||
--tw-ring-shadow: 0 0 #0000;
|
||||
--tw-shadow: 0 0 #0000;
|
||||
--tw-shadow-colored: 0 0 #0000;
|
||||
--tw-blur: ;
|
||||
--tw-brightness: ;
|
||||
--tw-contrast: ;
|
||||
--tw-grayscale: ;
|
||||
--tw-hue-rotate: ;
|
||||
--tw-invert: ;
|
||||
--tw-saturate: ;
|
||||
--tw-sepia: ;
|
||||
--tw-drop-shadow: ;
|
||||
--tw-backdrop-blur: ;
|
||||
--tw-backdrop-brightness: ;
|
||||
--tw-backdrop-contrast: ;
|
||||
--tw-backdrop-grayscale: ;
|
||||
--tw-backdrop-hue-rotate: ;
|
||||
--tw-backdrop-invert: ;
|
||||
--tw-backdrop-opacity: ;
|
||||
--tw-backdrop-saturate: ;
|
||||
--tw-backdrop-sepia: ;
|
||||
--tw-contain-size: ;
|
||||
--tw-contain-layout: ;
|
||||
--tw-contain-paint: ;
|
||||
--tw-contain-style: ;
|
||||
}
|
||||
|
||||
.mx-auto {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.contents {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.w-96 {
|
||||
width: 24rem;
|
||||
}
|
||||
|
||||
.w-80 {
|
||||
width: 20rem;
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.items-baseline {
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.justify-start {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.justify-end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.gap-4 {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.gap-6 {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.rounded-sm {
|
||||
border-radius: 0.125rem;
|
||||
}
|
||||
|
||||
.rounded-lg {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.rounded-md {
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.rounded {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.rounded-xl {
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.rounded-full {
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.border {
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.border-b {
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
|
||||
.border-t {
|
||||
border-top-width: 1px;
|
||||
}
|
||||
|
||||
.border-slate-500 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(100 116 139 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.border-slate-400 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(148 163 184 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.border-slate-100 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(241 245 249 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.border-slate-800 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(30 41 59 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.border-slate-600 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(71 85 105 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.border-slate-100\/50 {
|
||||
border-color: rgb(241 245 249 / 0.5);
|
||||
}
|
||||
|
||||
.border-t-blue-100 {
|
||||
--tw-border-opacity: 1;
|
||||
border-top-color: rgb(219 234 254 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.border-t-blue-400 {
|
||||
--tw-border-opacity: 1;
|
||||
border-top-color: rgb(96 165 250 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.border-b-blue-700 {
|
||||
--tw-border-opacity: 1;
|
||||
border-bottom-color: rgb(29 78 216 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.border-t-blue-300 {
|
||||
--tw-border-opacity: 1;
|
||||
border-top-color: rgb(147 197 253 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.border-b-blue-900 {
|
||||
--tw-border-opacity: 1;
|
||||
border-bottom-color: rgb(30 58 138 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.border-b-slate-800 {
|
||||
--tw-border-opacity: 1;
|
||||
border-bottom-color: rgb(30 41 59 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.border-b-slate-100 {
|
||||
--tw-border-opacity: 1;
|
||||
border-bottom-color: rgb(241 245 249 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.border-b-slate-500 {
|
||||
--tw-border-opacity: 1;
|
||||
border-bottom-color: rgb(100 116 139 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.border-b-slate-400 {
|
||||
--tw-border-opacity: 1;
|
||||
border-bottom-color: rgb(148 163 184 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.border-t-slate-700 {
|
||||
--tw-border-opacity: 1;
|
||||
border-top-color: rgb(51 65 85 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.bg-slate-900 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(15 23 42 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-slate-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(100 116 139 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-slate-600 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(71 85 105 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-slate-700 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(51 65 85 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-slate-800 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(30 41 59 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-sky-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(14 165 233 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-blue-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(59 130 246 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-transparent {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.p-4 {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.p-2 {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.py-6 {
|
||||
padding-top: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.px-2 {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.py-1 {
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.px-4 {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.py-2 {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.px-6 {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
.px-5 {
|
||||
padding-left: 1.25rem;
|
||||
padding-right: 1.25rem;
|
||||
}
|
||||
|
||||
.text-xl {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
|
||||
.text-2xl {
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
}
|
||||
|
||||
.text-4xl {
|
||||
font-size: 2.25rem;
|
||||
line-height: 2.5rem;
|
||||
}
|
||||
|
||||
.text-6xl {
|
||||
font-size: 3.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.font-bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-slate-200 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(226 232 240 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-slate-100 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(241 245 249 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-slate-900 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(15 23 42 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-red-400 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(248 113 113 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-slate-400 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(148 163 184 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-slate-300 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-slate-100\/50 {
|
||||
color: rgb(241 245 249 / 0.5);
|
||||
}
|
||||
|
||||
.text-slate-100\/20 {
|
||||
color: rgb(241 245 249 / 0.2);
|
||||
}
|
||||
|
||||
.placeholder-slate-300::-moz-placeholder {
|
||||
--tw-placeholder-opacity: 1;
|
||||
color: rgb(203 213 225 / var(--tw-placeholder-opacity));
|
||||
}
|
||||
|
||||
.placeholder-slate-300::placeholder {
|
||||
--tw-placeholder-opacity: 1;
|
||||
color: rgb(203 213 225 / var(--tw-placeholder-opacity));
|
||||
}
|
||||
|
||||
.placeholder-slate-400::-moz-placeholder {
|
||||
--tw-placeholder-opacity: 1;
|
||||
color: rgb(148 163 184 / var(--tw-placeholder-opacity));
|
||||
}
|
||||
|
||||
.placeholder-slate-400::placeholder {
|
||||
--tw-placeholder-opacity: 1;
|
||||
color: rgb(148 163 184 / var(--tw-placeholder-opacity));
|
||||
}
|
||||
|
||||
.placeholder-slate-500::-moz-placeholder {
|
||||
--tw-placeholder-opacity: 1;
|
||||
color: rgb(100 116 139 / var(--tw-placeholder-opacity));
|
||||
}
|
||||
|
||||
.placeholder-slate-500::placeholder {
|
||||
--tw-placeholder-opacity: 1;
|
||||
color: rgb(100 116 139 / var(--tw-placeholder-opacity));
|
||||
}
|
||||
|
||||
.shadow-inner {
|
||||
--tw-shadow: inset 0 2px 4px 0 rgb(0 0 0 / 0.05);
|
||||
--tw-shadow-colored: inset 0 2px 4px 0 var(--tw-shadow-color);
|
||||
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
||||
}
|
||||
|
||||
.hover\:bg-blue-400:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(96 165 250 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.hover\:bg-slate-100\/20:hover {
|
||||
background-color: rgb(241 245 249 / 0.2);
|
||||
}
|
||||
|
||||
.hover\:bg-slate-300\/10:hover {
|
||||
background-color: rgb(203 213 225 / 0.1);
|
||||
}
|
||||
|
||||
.hover\:text-slate-100\/20:hover {
|
||||
color: rgb(241 245 249 / 0.2);
|
||||
}
|
||||
|
||||
.focus\:bg-slate-700:focus {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(51 65 85 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.active\:bg-slate-700:active {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(51 65 85 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.active\:bg-blue-600:active {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.active\:bg-slate-300\/20:active {
|
||||
background-color: rgb(203 213 225 / 0.2);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.sm\:max-w-screen-sm {
|
||||
max-width: 640px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.md\:max-w-screen-md {
|
||||
max-width: 768px;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: red;
|
||||
}
|
||||
|
||||
|
8
tailwind.config.js
Normal file
8
tailwind.config.js
Normal file
@ -0,0 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./view/*.templ"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
513
timer.go
513
timer.go
@ -1,513 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
"stevenlr.com/timer/model"
|
||||
"stevenlr.com/timer/view"
|
||||
)
|
||||
|
||||
func generateRandomString(len int) (string, error) {
|
||||
bin := make([]byte, len)
|
||||
_, err := rand.Read(bin)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(bin), nil
|
||||
}
|
||||
|
||||
func generateSessionId() (string, error) {
|
||||
return generateRandomString(66)
|
||||
}
|
||||
|
||||
func generateTimerToken() (string, error) {
|
||||
return generateRandomString(66)
|
||||
}
|
||||
|
||||
func insertTimer(tx *sql.Tx, name string, seconds int, ownerId model.UUID) error {
|
||||
now := model.MakeTimeNow()
|
||||
end := model.Time(time.Time(now).Add(time.Duration(seconds) * time.Second))
|
||||
id := model.MakeUUID()
|
||||
token, _ := generateTimerToken()
|
||||
_, err := tx.Exec(`
|
||||
INSERT INTO Timer VALUES ($1, $2, $3, $4, $5, $6)`, id, name, now, end, ownerId, token)
|
||||
return err
|
||||
}
|
||||
|
||||
func initializeDatabaseV1(db *sql.DB) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.Exec(`PRAGMA user_version = 1`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
CREATE TABLE Timer (
|
||||
Id BLOB NOT NULL UNIQUE,
|
||||
Name TEXT NOT NULL,
|
||||
StartTime TEXT NOT NULL,
|
||||
EndTime TEXT NOT NULL,
|
||||
Owner BLOB NOT NULL,
|
||||
Token TEXT NOT NULL UNIQUE,
|
||||
PRIMARY KEY (Id)
|
||||
)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
CREATE TABLE User (
|
||||
Id BLOB NOT NULL UNIQUE,
|
||||
Name TEXT NOT NULL,
|
||||
Salt TEXT NOT NULL,
|
||||
Password BLOB NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userName := "admin"
|
||||
userPassword := "admin"
|
||||
salt, err := generateRandomString(33)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
password, err := bcrypt.GenerateFromPassword([]byte(salt+userPassword), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`INSERT INTO User VALUES ($1, $2, $3, $4)`, model.MakeUUID(), userName, salt, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func migrateDatabaseV2(db *sql.DB) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.Exec(`PRAGMA user_version = 2`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec("CREATE INDEX TimerTokenIndex ON Timer(Token)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func initializeDatabase(db *sql.DB) error {
|
||||
initialVersion := 0
|
||||
row := db.QueryRow("PRAGMA user_version")
|
||||
row.Scan(&initialVersion)
|
||||
|
||||
if initialVersion < 1 {
|
||||
log.Println("Initializing DB V1")
|
||||
err := initializeDatabaseV1(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if initialVersion < 2 {
|
||||
log.Println("Migrating DB to V2")
|
||||
err := migrateDatabaseV2(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func queryAllTimers(db *sql.DB, owner model.UUID) []model.Timer {
|
||||
rows, err := db.Query("SELECT Id, Name FROM Timer WHERE Owner=$1", owner)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
timers := []model.Timer{}
|
||||
for rows.Next() {
|
||||
var t model.Timer
|
||||
if err := rows.Scan(&t.Id, &t.Name); err == nil {
|
||||
timers = append(timers, t)
|
||||
}
|
||||
}
|
||||
|
||||
return timers
|
||||
}
|
||||
|
||||
func queryUserByName(db *sql.DB, name string) *model.User {
|
||||
row := db.QueryRow("SELECT Id, Name, Salt, Password FROM User WHERE Name=$1", name)
|
||||
if row == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var user model.User
|
||||
row.Scan(&user.Id, &user.Name, &user.Salt, &user.Password)
|
||||
|
||||
return &user
|
||||
}
|
||||
|
||||
func queryUserById(db *sql.DB, id model.UUID) *model.User {
|
||||
row := db.QueryRow("SELECT Id, Name, Salt, Password FROM User WHERE Id=$1", id)
|
||||
if row == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var user model.User
|
||||
row.Scan(&user.Id, &user.Name, &user.Salt, &user.Password)
|
||||
|
||||
return &user
|
||||
}
|
||||
|
||||
func queryTimer(db *sql.DB, idStr string, userId model.UUID) *model.Timer {
|
||||
var id model.UUID
|
||||
if err := id.Scan(idStr); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
row := db.QueryRow("SELECT Id, Name, StartTime, EndTime, Owner, Token FROM Timer WHERE Id=$1 AND Owner=$2", id, userId)
|
||||
|
||||
var t model.Timer
|
||||
if err := row.Scan(&t.Id, &t.Name, &t.StartTime, &t.EndTime, &t.Owner, &t.Token); err == nil {
|
||||
return &t
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteTimer(db *sql.DB, idStr string, userId model.UUID) bool {
|
||||
var id model.UUID
|
||||
if err := id.Scan(idStr); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
res, err := db.Exec("DELETE FROM Timer WHERE Id=$1 AND Owner=$2", id, userId)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
affected, err := res.RowsAffected()
|
||||
return err == nil && affected == 1
|
||||
}
|
||||
|
||||
func updateTimerEndTime(db *sql.DB, id model.UUID, endTime model.Time, userId model.UUID) bool {
|
||||
res, err := db.Exec("UPDATE Timer SET EndTime=$1 WHERE Id=$2 AND Owner=$3", endTime, id, userId)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
affected, err := res.RowsAffected()
|
||||
return err == nil && affected == 1
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
UserId model.UUID
|
||||
}
|
||||
|
||||
type MyServer struct {
|
||||
db *sql.DB
|
||||
sessions map[string]Session
|
||||
}
|
||||
|
||||
const SessionCookieName = "timerSession"
|
||||
|
||||
func removeCookie(cookieName string, w http.ResponseWriter) {
|
||||
cookie := http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: "",
|
||||
MaxAge: -1,
|
||||
}
|
||||
http.SetCookie(w, &cookie)
|
||||
}
|
||||
|
||||
func (server *MyServer) findCurrentUser(w http.ResponseWriter, r *http.Request) *model.User {
|
||||
cookie, err := r.Cookie(SessionCookieName)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
userId, ok := server.sessions[cookie.Value]
|
||||
if !ok {
|
||||
removeCookie(SessionCookieName, w)
|
||||
return nil
|
||||
}
|
||||
|
||||
user := queryUserById(server.db, userId.UserId)
|
||||
if user == nil {
|
||||
removeCookie(SessionCookieName, w)
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
func (server *MyServer) handleNotFound(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
view.Error404().Render(context.Background(), w)
|
||||
}
|
||||
|
||||
func (server *MyServer) handleMain(w http.ResponseWriter, r *http.Request) {
|
||||
currentUser := server.findCurrentUser(w, r)
|
||||
if r.URL.Path == "/" {
|
||||
timers := make([]model.Timer, 0)
|
||||
if currentUser != nil {
|
||||
timers = queryAllTimers(server.db, currentUser.Id)
|
||||
}
|
||||
view.Main(view.TimersList(timers, currentUser != nil), currentUser).Render(context.Background(), w)
|
||||
} else {
|
||||
server.handleNotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (server *MyServer) handleTimer(w http.ResponseWriter, r *http.Request) {
|
||||
currentUser := server.findCurrentUser(w, r)
|
||||
if currentUser == nil {
|
||||
server.handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
timer := queryTimer(server.db, r.PathValue("timerId"), currentUser.Id)
|
||||
if timer != nil && timer.Owner == currentUser.Id {
|
||||
view.Main(view.TimerView(*timer), currentUser).Render(context.Background(), w)
|
||||
} else {
|
||||
server.handleNotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func parseDuration(value string) (time.Duration, error) {
|
||||
const nullDuration = time.Duration(0)
|
||||
if len(value) == 0 {
|
||||
return nullDuration, errors.New("Empty duration string")
|
||||
}
|
||||
|
||||
var unit time.Duration
|
||||
switch value[len(value)-1] {
|
||||
case 's':
|
||||
unit = time.Second
|
||||
case 'm':
|
||||
unit = time.Minute
|
||||
case 'h':
|
||||
unit = time.Hour
|
||||
case 'd':
|
||||
unit = time.Duration(24) * time.Hour
|
||||
case 'w':
|
||||
unit = time.Duration(24*7) * time.Hour
|
||||
default:
|
||||
return nullDuration, errors.New("Invalid duration format")
|
||||
}
|
||||
|
||||
amount, err := strconv.ParseInt(value[0:len(value)-1], 10, 64)
|
||||
if err != nil || amount < 0 {
|
||||
return nullDuration, errors.New("Invalid duration value")
|
||||
}
|
||||
|
||||
return time.Duration(amount) * unit, nil
|
||||
}
|
||||
|
||||
func (server *MyServer) handleTimerAddTime(w http.ResponseWriter, r *http.Request) {
|
||||
currentUser := server.findCurrentUser(w, r)
|
||||
if currentUser == nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
timer := queryTimer(server.db, r.PathValue("timerId"), currentUser.Id)
|
||||
if timer == nil {
|
||||
server.handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if timer.IsFinished() {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
duration, err := parseDuration(r.PathValue("timeToAdd"))
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
timer.EndTime.Add(duration)
|
||||
res := updateTimerEndTime(server.db, timer.Id, timer.EndTime, currentUser.Id)
|
||||
if !res {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
view.TimerView(*timer).Render(context.Background(), w)
|
||||
}
|
||||
|
||||
func (server *MyServer) handleDeleteTimer(w http.ResponseWriter, r *http.Request) {
|
||||
user := server.findCurrentUser(w, r)
|
||||
if user == nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
success := deleteTimer(server.db, r.PathValue("timerId"), user.Id)
|
||||
if !success {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func (server *MyServer) handlePutTimer(w http.ResponseWriter, r *http.Request) {
|
||||
timerName := strings.TrimSpace(r.FormValue("timerName"))
|
||||
|
||||
user := server.findCurrentUser(w, r)
|
||||
if user == nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
view.TimerCreateForm(timerName, "You are not signed in").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
days, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("days")), 10, 32)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
view.TimerCreateForm(timerName, "Error parsing days").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
hours, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("hours")), 10, 32)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
view.TimerCreateForm(timerName, "Error parsing hours").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := server.db.Begin()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
view.TimerCreateForm(timerName, "Internal server error").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if timerName == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
view.TimerCreateForm("", "Timer name cannot be empty").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
err = insertTimer(tx, timerName, int(((max(days, 0)*24)+max(hours, 0))*3600), user.Id)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
view.TimerCreateForm(timerName, "Internal server error").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
|
||||
timers := queryAllTimers(server.db, user.Id)
|
||||
view.TimersList(timers, user != nil).Render(context.Background(), w)
|
||||
}
|
||||
|
||||
func (server *MyServer) handlePostLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if server.findCurrentUser(w, r) != nil {
|
||||
w.Header().Add("HX-Redirect", "/")
|
||||
return
|
||||
}
|
||||
|
||||
userName := r.FormValue("user")
|
||||
userPass := r.FormValue("password")
|
||||
|
||||
user := queryUserByName(server.db, userName)
|
||||
if user == nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
view.LoginFormError(nil, "Incorrect credentials").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
err := bcrypt.CompareHashAndPassword(user.Password, []byte(user.Salt+userPass))
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
view.LoginFormError(nil, "Incorrect credentials").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
sessionId, err := generateSessionId()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
view.LoginFormError(nil, "Internal server error").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
cookie := http.Cookie{
|
||||
Name: SessionCookieName,
|
||||
Value: sessionId,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
}
|
||||
server.sessions[sessionId] = Session{UserId: user.Id}
|
||||
http.SetCookie(w, &cookie)
|
||||
w.Header().Add("HX-Redirect", "/")
|
||||
}
|
||||
|
||||
func (server *MyServer) handlePostLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if cookie, err := r.Cookie(SessionCookieName); err == nil {
|
||||
delete(server.sessions, cookie.Value)
|
||||
removeCookie(SessionCookieName, w)
|
||||
}
|
||||
w.Header().Add("HX-Redirect", "/")
|
||||
}
|
||||
|
||||
func main() {
|
||||
log.Println("Starting...")
|
||||
|
||||
db, err := sql.Open("sqlite3", "file:timer.db")
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := initializeDatabase(db); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
myServer := MyServer{db: db, sessions: make(map[string]Session)}
|
||||
|
||||
fs := http.FileServer(http.Dir("static/"))
|
||||
http.Handle("GET /static/", http.StripPrefix("/static/", fs))
|
||||
|
||||
http.HandleFunc("POST /login", myServer.handlePostLogin)
|
||||
http.HandleFunc("POST /logout", myServer.handlePostLogout)
|
||||
http.HandleFunc("GET /timer/{timerId}", myServer.handleTimer)
|
||||
http.HandleFunc("POST /timer/{timerId}/addTime/{timeToAdd}", myServer.handleTimerAddTime)
|
||||
http.HandleFunc("DELETE /timer/{timerId}", myServer.handleDeleteTimer)
|
||||
http.HandleFunc("PUT /timer", myServer.handlePutTimer)
|
||||
http.HandleFunc("GET /", myServer.handleMain)
|
||||
|
||||
log.Println("Started!")
|
||||
http.ListenAndServe("0.0.0.0:80", nil)
|
||||
}
|
9
utils/htmx.go
Normal file
9
utils/htmx.go
Normal file
@ -0,0 +1,9 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func HtmxRedirect(w http.ResponseWriter, url string) {
|
||||
w.Header().Add("HX-Redirect", "/")
|
||||
}
|
58
utils/utils.go
Normal file
58
utils/utils.go
Normal file
@ -0,0 +1,58 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func GenerateRandomString(len int) (string, error) {
|
||||
bin := make([]byte, len)
|
||||
_, err := rand.Read(bin)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(bin), nil
|
||||
}
|
||||
|
||||
func ParseNumber(s string) (int64, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) == 0 {
|
||||
s = "0"
|
||||
}
|
||||
|
||||
return strconv.ParseInt(s, 10, 64)
|
||||
}
|
||||
|
||||
func ParseDuration(value string) (time.Duration, error) {
|
||||
const nullDuration = time.Duration(0)
|
||||
if len(value) == 0 {
|
||||
return nullDuration, errors.New("Empty duration string")
|
||||
}
|
||||
|
||||
var unit time.Duration
|
||||
switch value[len(value)-1] {
|
||||
case 's':
|
||||
unit = time.Second
|
||||
case 'm':
|
||||
unit = time.Minute
|
||||
case 'h':
|
||||
unit = time.Hour
|
||||
case 'd':
|
||||
unit = time.Duration(24) * time.Hour
|
||||
case 'w':
|
||||
unit = time.Duration(24*7) * time.Hour
|
||||
default:
|
||||
return nullDuration, errors.New("Invalid duration format")
|
||||
}
|
||||
|
||||
amount, err := strconv.ParseInt(value[0:len(value)-1], 10, 64)
|
||||
if err != nil || amount < 0 {
|
||||
return nullDuration, errors.New("Invalid duration value")
|
||||
}
|
||||
|
||||
return time.Duration(amount) * unit, nil
|
||||
}
|
51
view/components.templ
Normal file
51
view/components.templ
Normal file
@ -0,0 +1,51 @@
|
||||
package view
|
||||
|
||||
templ textLikeField(fieldType string, name string, placeholder string) {
|
||||
<input
|
||||
type={ fieldType }
|
||||
name={ name }
|
||||
placeholder={ placeholder }
|
||||
class="
|
||||
bg-slate-800 focus:bg-slate-700
|
||||
text-slate-200 placeholder-slate-500
|
||||
p-2
|
||||
rounded-md border border-slate-600 border-b-slate-500
|
||||
focus:bg-slate-700
|
||||
shadow-inner
|
||||
"
|
||||
/>
|
||||
}
|
||||
|
||||
templ TextField(name string, placeholder string) {
|
||||
@textLikeField("text", name, placeholder)
|
||||
}
|
||||
|
||||
templ PasswordField(name string, placeholder string) {
|
||||
@textLikeField("password", name, placeholder)
|
||||
}
|
||||
|
||||
templ Button(fieldType string, name string, attrs templ.Attributes) {
|
||||
<button
|
||||
type={ fieldType }
|
||||
class="
|
||||
text-slate-200
|
||||
bg-blue-500 hover:bg-blue-400 active:bg-blue-600
|
||||
py-2 px-6
|
||||
rounded-full border-t border-t-blue-400
|
||||
"
|
||||
{ attrs... }
|
||||
>{ name }</button>
|
||||
}
|
||||
|
||||
templ SecondaryButton(fieldType string, name string, attrs templ.Attributes) {
|
||||
<button
|
||||
type={ fieldType }
|
||||
class="
|
||||
text-slate-100/50
|
||||
bg-transparent hover:bg-slate-300/10 active:bg-slate-300/20
|
||||
py-2 px-6
|
||||
rounded-full border border-slate-100/50
|
||||
"
|
||||
{ attrs... }
|
||||
>{ name }</button>
|
||||
}
|
@ -2,5 +2,6 @@ package view
|
||||
|
||||
templ Error404() {
|
||||
<h1>Not found</h1>
|
||||
<p><a href="/">Back to index</a></p>
|
||||
}
|
||||
|
||||
|
@ -1,35 +0,0 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.2.648
|
||||
package view
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import "context"
|
||||
import "io"
|
||||
import "bytes"
|
||||
|
||||
func Error404() templ.Component {
|
||||
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
templ_7745c5c3_Buffer = templ.GetBuffer()
|
||||
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<h1>Not found</h1>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
|
||||
}
|
||||
return templ_7745c5c3_Err
|
||||
})
|
||||
}
|
80
view/lock.templ
Normal file
80
view/lock.templ
Normal file
@ -0,0 +1,80 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"stevenlr.com/locker/model"
|
||||
)
|
||||
|
||||
templ LockTokenForm(lock model.Lock) {
|
||||
<p class="token-form">
|
||||
<button
|
||||
type="button"
|
||||
hx-post={ fmt.Sprint("/lock/", lock.Id, "/resetToken") }
|
||||
hx-target="closest .token-form"
|
||||
hx-confirm="Are you sure you want to reset the token for this lock?"
|
||||
>Reset token</button>
|
||||
<button
|
||||
type="button"
|
||||
hx-get={ fmt.Sprint("/lock/", lock.Id, "/token") }
|
||||
hx-swap="outerHTML"
|
||||
>Show token</button>
|
||||
</p>
|
||||
}
|
||||
|
||||
func renderTimeString(value int, unit string) string {
|
||||
s := ""
|
||||
if value > 1 { s = "s" }
|
||||
return fmt.Sprint(value, " ", unit, s)
|
||||
}
|
||||
|
||||
templ timeButton(id model.UUID, value int, unit string) {
|
||||
<button
|
||||
hx-target=".lock-info"
|
||||
hx-post={ fmt.Sprint("/lock/", id, "/addTime") }
|
||||
hx-include="next input"
|
||||
>{ renderTimeString(value, unit) }</button>
|
||||
<input type="hidden" name="timeToAdd" value={ fmt.Sprint(value, "", unit[:1]) } />
|
||||
}
|
||||
|
||||
templ LockInfo(lock model.Lock) {
|
||||
<h1>Lock "{ lock.Name }"</h1>
|
||||
<p>Start time: <local-date>{ lock.StartTime.AsUTCString() }</local-date></p>
|
||||
<p>End time: <local-date>{ lock.EndTime.AsUTCString() }</local-date></p>
|
||||
<p>
|
||||
Total time:
|
||||
<lock-countdown
|
||||
start={ lock.StartTime.AsUTCString() }
|
||||
end={ lock.EndTime.AsUTCString() }
|
||||
></lock-countdown>
|
||||
</p>
|
||||
<p>
|
||||
Remaining time:
|
||||
<lock-countdown
|
||||
end={ lock.EndTime.AsUTCString() }
|
||||
></lock-countdown>
|
||||
</p>
|
||||
}
|
||||
|
||||
templ LockView(lock model.Lock) {
|
||||
<p><a href="/">Back to list</a></p>
|
||||
<div class="lock-info">
|
||||
@LockInfo(lock)
|
||||
</div>
|
||||
if !lock.IsFinished() {
|
||||
<h3>Add time</h3>
|
||||
<p>
|
||||
@timeButton(lock.Id, 15, "minute")
|
||||
@timeButton(lock.Id, 30, "minute")
|
||||
@timeButton(lock.Id, 1, "hour")
|
||||
@timeButton(lock.Id, 2, "hour")
|
||||
@timeButton(lock.Id, 6, "hour")
|
||||
@timeButton(lock.Id, 12, "hour")
|
||||
@timeButton(lock.Id, 1, "day")
|
||||
@timeButton(lock.Id, 1, "week")
|
||||
@timeButton(lock.Id, 4, "week")
|
||||
</p>
|
||||
}
|
||||
<h3>API token</h3>
|
||||
@LockTokenForm(lock)
|
||||
}
|
||||
|
51
view/locks_list.templ
Normal file
51
view/locks_list.templ
Normal file
@ -0,0 +1,51 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"stevenlr.com/locker/model"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
templ lock(t model.Lock) {
|
||||
<p class="lock-row">
|
||||
<a href={ templ.URL(fmt.Sprint("/lock/", t.Id)) }>{ t.Name }</a>
|
||||
-
|
||||
<a
|
||||
href="javascript:void(0);"
|
||||
hx-delete={ fmt.Sprint("/lock/", t.Id) }
|
||||
hx-target="closest .lock-row"
|
||||
hx-confirm={ fmt.Sprint("Are you sure you want to delete lock \"", t.Name , "\"?") }
|
||||
>Delete</a>
|
||||
</p>
|
||||
}
|
||||
|
||||
templ LockCreateForm(lockName string, err string) {
|
||||
<form
|
||||
hx-put="/lock"
|
||||
hx-target="closest .locks-list"
|
||||
hx-target-error="this"
|
||||
>
|
||||
<p>
|
||||
<input type="text" name="lockName" value={ lockName } placeholder="Name" />
|
||||
<input type="number" name="days" placeholder="Days" />
|
||||
<input type="number" name="hours" placeholder="Hours" />
|
||||
<button type="submit">Create</button>
|
||||
</p>
|
||||
if err != "" {
|
||||
<p class="error">{ err }</p>
|
||||
}
|
||||
</form>
|
||||
}
|
||||
|
||||
templ LocksList(locks []model.Lock, isSignedIn bool) {
|
||||
<div class="locks-list">
|
||||
if isSignedIn {
|
||||
<h1>Locks</h1>
|
||||
for _, t := range locks {
|
||||
@lock(t)
|
||||
}
|
||||
<h4>Create lock</h4>
|
||||
@LockCreateForm("", "")
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
@ -1,29 +1,56 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"stevenlr.com/timer/model"
|
||||
"stevenlr.com/locker/model"
|
||||
)
|
||||
|
||||
templ LoginFormError(currentUser *model.User, err string) {
|
||||
<div class="login-form">
|
||||
if currentUser == nil {
|
||||
<form hx-post="/login" hx-target-error="closest .login-form">
|
||||
<p>
|
||||
<input type="text" name="user" placeholder="User" />
|
||||
<input type="password" name="password" placeholder="Password" />
|
||||
<button type="submit">Sign in</button>
|
||||
if err != "" {
|
||||
<span class="error">{ err }</span>
|
||||
}
|
||||
</p>
|
||||
</form>
|
||||
<div class="login-form">
|
||||
<form
|
||||
hx-post="/login"
|
||||
hx-target-error="closest .login-form"
|
||||
class="
|
||||
w-80
|
||||
mx-auto
|
||||
flex flex-col
|
||||
gap-4
|
||||
"
|
||||
>
|
||||
@TextField("user", "User name")
|
||||
@PasswordField("password", "Password")
|
||||
@Button("submit", "Sign in", templ.Attributes{})
|
||||
if err != "" {
|
||||
<span class="text-red-400">{ err }</span>
|
||||
}
|
||||
</form>
|
||||
</div>
|
||||
} else {
|
||||
<p>Signed in as { currentUser.Name } <button type="button" hx-post="/logout" hx-refresh>Sign out</button></p>
|
||||
<div class="
|
||||
flex flex-row items-baseline justify-start
|
||||
"
|
||||
>
|
||||
<h1
|
||||
class="
|
||||
grow
|
||||
text-slate-300 text-4xl font-semibold
|
||||
"
|
||||
>Prout prout</h1>
|
||||
<div
|
||||
class="
|
||||
flex flex-row justify-end items-center gap-4
|
||||
"
|
||||
>
|
||||
<p>Signed in as <b>{ currentUser.Name }</b></p>
|
||||
@SecondaryButton("button", "Sign out", templ.Attributes{
|
||||
"hx-post": "/logout",
|
||||
"hx-refresh": true,
|
||||
})
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ LoginForm(currentUser *model.User) {
|
||||
@LoginFormError(currentUser, "")
|
||||
}
|
||||
|
||||
|
@ -1,114 +0,0 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.2.648
|
||||
package view
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import "context"
|
||||
import "io"
|
||||
import "bytes"
|
||||
|
||||
import (
|
||||
"stevenlr.com/timer/model"
|
||||
)
|
||||
|
||||
func LoginFormError(currentUser *model.User, err string) templ.Component {
|
||||
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
templ_7745c5c3_Buffer = templ.GetBuffer()
|
||||
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"login-form\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if currentUser == nil {
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<form hx-post=\"/login\" hx-target-error=\"closest .login-form\"><p><input type=\"text\" name=\"user\" placeholder=\"User\"> <input type=\"password\" name=\"password\" placeholder=\"Password\"> <button type=\"submit\">Sign in</button> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if err != "" {
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<span class=\"error\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(err)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view\login.templ`, Line: 16, Col: 45}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</p></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<p>Signed in as ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(currentUser.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view\login.templ`, Line: 21, Col: 42}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" <button type=\"button\" hx-post=\"/logout\" hx-refresh>Sign out</button></p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
|
||||
}
|
||||
return templ_7745c5c3_Err
|
||||
})
|
||||
}
|
||||
|
||||
func LoginForm(currentUser *model.User) templ.Component {
|
||||
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
templ_7745c5c3_Buffer = templ.GetBuffer()
|
||||
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var4 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var4 == nil {
|
||||
templ_7745c5c3_Var4 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = LoginFormError(currentUser, "").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
|
||||
}
|
||||
return templ_7745c5c3_Err
|
||||
})
|
||||
}
|
@ -1,20 +1,29 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"stevenlr.com/timer/model"
|
||||
"stevenlr.com/locker/model"
|
||||
)
|
||||
|
||||
templ Main(contents templ.Component, currentUser *model.User) {
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Cool timer app</title>
|
||||
<title>Cool locker app</title>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
<script type="module" src="/static/ui-components.js"></script>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/response-targets.js"></script>
|
||||
</head>
|
||||
<body hx-boost="true" hx-ext="response-targets">
|
||||
<body
|
||||
hx-boost="true"
|
||||
hx-ext="response-targets"
|
||||
class="
|
||||
w-full sm:max-w-screen-sm md:max-w-screen-md
|
||||
mx-auto py-6
|
||||
bg-slate-900
|
||||
text-slate-400
|
||||
"
|
||||
>
|
||||
@LoginForm(currentUser)
|
||||
@contents
|
||||
</body>
|
||||
|
@ -1,51 +0,0 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.2.648
|
||||
package view
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import "context"
|
||||
import "io"
|
||||
import "bytes"
|
||||
|
||||
import (
|
||||
"stevenlr.com/timer/model"
|
||||
)
|
||||
|
||||
func Main(contents templ.Component, currentUser *model.User) templ.Component {
|
||||
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
templ_7745c5c3_Buffer = templ.GetBuffer()
|
||||
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<!doctype html><html><head><title>Cool timer app</title><link rel=\"stylesheet\" href=\"/static/style.css\"><script type=\"module\" src=\"/static/ui-components.js\"></script><script src=\"/static/htmx.min.js\"></script><script src=\"/static/response-targets.js\"></script></head><body hx-boost=\"true\" hx-ext=\"response-targets\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = LoginForm(currentUser).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = contents.Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
|
||||
}
|
||||
return templ_7745c5c3_Err
|
||||
})
|
||||
}
|
@ -1,43 +0,0 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"stevenlr.com/timer/model"
|
||||
)
|
||||
|
||||
templ TimerView(timer model.Timer) {
|
||||
<div class="timer">
|
||||
<h1>Timer "{ timer.Name }"</h1>
|
||||
<p><a href="/">Back to list</a></p>
|
||||
<p>Start time: <local-date>{ timer.StartTime.AsUTCString() }</local-date></p>
|
||||
<p>End time: <local-date>{ timer.EndTime.AsUTCString() }</local-date></p>
|
||||
<p>
|
||||
Total time:
|
||||
<timer-countdown
|
||||
start={ timer.StartTime.AsUTCString() }
|
||||
end={ timer.EndTime.AsUTCString() }
|
||||
></timer-countdown>
|
||||
</p>
|
||||
<p>
|
||||
Remaining time:
|
||||
<timer-countdown
|
||||
end={ timer.EndTime.AsUTCString() }
|
||||
></timer-countdown>
|
||||
</p>
|
||||
if !timer.IsFinished() {
|
||||
<h3>Add time</h3>
|
||||
<p>
|
||||
<button hx-target="closest .timer" hx-post={ fmt.Sprint("/timer/", timer.Id, "/addTime/15m") }>15 minutes</button>
|
||||
<button hx-target="closest .timer" hx-post={ fmt.Sprint("/timer/", timer.Id, "/addTime/30m") }>30 minutes</button>
|
||||
<button hx-target="closest .timer" hx-post={ fmt.Sprint("/timer/", timer.Id, "/addTime/1h") }>1 hour</button>
|
||||
<button hx-target="closest .timer" hx-post={ fmt.Sprint("/timer/", timer.Id, "/addTime/2h") }>2 hours</button>
|
||||
<button hx-target="closest .timer" hx-post={ fmt.Sprint("/timer/", timer.Id, "/addTime/6h") }>6 hours</button>
|
||||
<button hx-target="closest .timer" hx-post={ fmt.Sprint("/timer/", timer.Id, "/addTime/12h") }>12 hours</button>
|
||||
<button hx-target="closest .timer" hx-post={ fmt.Sprint("/timer/", timer.Id, "/addTime/1d") }>1 day</button>
|
||||
<button hx-target="closest .timer" hx-post={ fmt.Sprint("/timer/", timer.Id, "/addTime/1w") }>1 week</button>
|
||||
<button hx-target="closest .timer" hx-post={ fmt.Sprint("/timer/", timer.Id, "/addTime/4w") }>4 weeks</button>
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
@ -1,245 +0,0 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.2.648
|
||||
package view
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import "context"
|
||||
import "io"
|
||||
import "bytes"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"stevenlr.com/timer/model"
|
||||
)
|
||||
|
||||
func TimerView(timer model.Timer) templ.Component {
|
||||
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
templ_7745c5c3_Buffer = templ.GetBuffer()
|
||||
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"timer\"><h1>Timer \"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(timer.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view\timer.templ`, Line: 10, Col: 25}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"</h1><p><a href=\"/\">Back to list</a></p><p>Start time: <local-date>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(timer.StartTime.AsUTCString())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view\timer.templ`, Line: 12, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</local-date></p><p>End time: <local-date>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(timer.EndTime.AsUTCString())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view\timer.templ`, Line: 13, Col: 56}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</local-date></p><p>Total time:\r <timer-countdown start=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(timer.StartTime.AsUTCString())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view\timer.templ`, Line: 17, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" end=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(timer.EndTime.AsUTCString())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view\timer.templ`, Line: 18, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"></timer-countdown></p><p>Remaining time:\r <timer-countdown end=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(timer.EndTime.AsUTCString())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view\timer.templ`, Line: 24, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"></timer-countdown></p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !timer.IsFinished() {
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<h3>Add time</h3><p><button hx-target=\"closest .timer\" hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprint("/timer/", timer.Id, "/addTime/15m"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view\timer.templ`, Line: 30, Col: 96}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">15 minutes</button> <button hx-target=\"closest .timer\" hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprint("/timer/", timer.Id, "/addTime/30m"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view\timer.templ`, Line: 31, Col: 96}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">30 minutes</button> <button hx-target=\"closest .timer\" hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprint("/timer/", timer.Id, "/addTime/1h"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view\timer.templ`, Line: 32, Col: 95}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">1 hour</button> <button hx-target=\"closest .timer\" hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprint("/timer/", timer.Id, "/addTime/2h"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view\timer.templ`, Line: 33, Col: 95}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">2 hours</button> <button hx-target=\"closest .timer\" hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprint("/timer/", timer.Id, "/addTime/6h"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view\timer.templ`, Line: 34, Col: 95}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">6 hours</button> <button hx-target=\"closest .timer\" hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprint("/timer/", timer.Id, "/addTime/12h"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view\timer.templ`, Line: 35, Col: 96}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">12 hours</button> <button hx-target=\"closest .timer\" hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprint("/timer/", timer.Id, "/addTime/1d"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view\timer.templ`, Line: 36, Col: 95}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">1 day</button> <button hx-target=\"closest .timer\" hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprint("/timer/", timer.Id, "/addTime/1w"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view\timer.templ`, Line: 37, Col: 95}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">1 week</button> <button hx-target=\"closest .timer\" hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprint("/timer/", timer.Id, "/addTime/4w"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view\timer.templ`, Line: 38, Col: 95}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">4 weeks</button></p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
|
||||
}
|
||||
return templ_7745c5c3_Err
|
||||
})
|
||||
}
|
@ -1,50 +0,0 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"stevenlr.com/timer/model"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
templ timer(t model.Timer) {
|
||||
<p class="timer-row">
|
||||
<a href={ templ.URL(fmt.Sprint("/timer/", t.Id)) }>{ t.Name }</a>
|
||||
-
|
||||
<a
|
||||
href="javascript:void(0);"
|
||||
hx-delete={ fmt.Sprint("/timer/", t.Id) }
|
||||
hx-target="closest .timer-row"
|
||||
>Delete</a>
|
||||
</p>
|
||||
}
|
||||
|
||||
templ TimerCreateForm(timerName string, err string) {
|
||||
<form
|
||||
hx-put="/timer"
|
||||
hx-target="closest .timers-list"
|
||||
hx-target-error="this"
|
||||
>
|
||||
<p>
|
||||
<input type="text" name="timerName" value={ timerName } placeholder="Name" />
|
||||
<input type="number" name="days" placeholder="Days" style="width: 5em;" />
|
||||
<input type="number" name="hours" placeholder="Hours" style="width: 5em;" />
|
||||
<button type="submit">Create</button>
|
||||
</p>
|
||||
if err != "" {
|
||||
<p class="error">{ err }</p>
|
||||
}
|
||||
</form>
|
||||
}
|
||||
|
||||
templ TimersList(timers []model.Timer, isSignedIn bool) {
|
||||
<div class="timers-list">
|
||||
if isSignedIn {
|
||||
<h1>Timers</h1>
|
||||
for _, t := range timers {
|
||||
@timer(t)
|
||||
}
|
||||
<h4>Create timer</h4>
|
||||
@TimerCreateForm("", "")
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
@ -1,183 +0,0 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.2.648
|
||||
package view
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import "context"
|
||||
import "io"
|
||||
import "bytes"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"stevenlr.com/timer/model"
|
||||
)
|
||||
|
||||
func timer(t model.Timer) templ.Component {
|
||||
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
templ_7745c5c3_Buffer = templ.GetBuffer()
|
||||
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<p class=\"timer-row\"><a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 templ.SafeURL = templ.URL(fmt.Sprint("/timer/", t.Id))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var2)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(t.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view\timers_list.templ`, Line: 10, Col: 67}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a> -\r <a href=\"javascript:void(0);\" hx-delete=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprint("/timer/", t.Id))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view\timers_list.templ`, Line: 14, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" hx-target=\"closest .timer-row\">Delete</a></p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
|
||||
}
|
||||
return templ_7745c5c3_Err
|
||||
})
|
||||
}
|
||||
|
||||
func TimerCreateForm(timerName string, err string) templ.Component {
|
||||
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
templ_7745c5c3_Buffer = templ.GetBuffer()
|
||||
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var5 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var5 == nil {
|
||||
templ_7745c5c3_Var5 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<form hx-put=\"/timer\" hx-target=\"closest .timers-list\" hx-target-error=\"this\"><p><input type=\"text\" name=\"timerName\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(timerName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view\timers_list.templ`, Line: 27, Col: 65}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" placeholder=\"Name\"> <input type=\"number\" name=\"days\" placeholder=\"Days\" style=\"width: 5em;\"> <input type=\"number\" name=\"hours\" placeholder=\"Hours\" style=\"width: 5em;\"> <button type=\"submit\">Create</button></p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if err != "" {
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<p class=\"error\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(err)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view\timers_list.templ`, Line: 33, Col: 34}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
|
||||
}
|
||||
return templ_7745c5c3_Err
|
||||
})
|
||||
}
|
||||
|
||||
func TimersList(timers []model.Timer, isSignedIn bool) templ.Component {
|
||||
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
templ_7745c5c3_Buffer = templ.GetBuffer()
|
||||
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var8 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var8 == nil {
|
||||
templ_7745c5c3_Var8 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"timers-list\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if isSignedIn {
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<h1>Timers</h1>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, t := range timers {
|
||||
templ_7745c5c3_Err = timer(t).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" <h4>Create timer</h4>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = TimerCreateForm("", "").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
|
||||
}
|
||||
return templ_7745c5c3_Err
|
||||
})
|
||||
}
|
Reference in New Issue
Block a user