Compare commits

...

10 Commits

29 changed files with 1944 additions and 1279 deletions

2
.gitignore vendored
View File

@ -1,2 +1,4 @@
*.exe
tmp-build
view/*_templ.go
view/*_templ.txt

4
build.bat Normal file
View 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
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

107
database.go Normal file
View 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
}

2
go.mod
View File

@ -1,4 +1,4 @@
module stevenlr.com/timer
module stevenlr.com/locker
go 1.22.2

Binary file not shown.

323
locker.go Normal file
View 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
View 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
}

View File

@ -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
}

View File

@ -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
View 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)
}
}

View File

@ -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
View File

@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./view/*.templ"],
theme: {
extend: {},
},
plugins: [],
}

513
timer.go
View File

@ -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
View 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
View 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
View 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>
}

View File

@ -2,5 +2,6 @@ package view
templ Error404() {
<h1>Not found</h1>
<p><a href="/">Back to index</a></p>
}

View File

@ -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
View 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
View 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>
}

View File

@ -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, "")
}

View File

@ -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
})
}

View File

@ -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>

View File

@ -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
})
}

View File

@ -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>
}

View File

@ -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
})
}

View File

@ -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>
}

View File

@ -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
})
}