create and retrieve handlers, basic templates and styling, sessions
This commit is contained in:
		
							parent
							
								
									4fc9bb5c1f
								
							
						
					
					
						commit
						741b304032
					
				
							
								
								
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -23,3 +23,7 @@ go.work | ||||
| 
 | ||||
| # sqlite3 databases | ||||
| *.db | ||||
| 
 | ||||
| # air config | ||||
| .air.toml | ||||
| /tmp | ||||
|  | ||||
| @ -1,30 +1,222 @@ | ||||
| package main | ||||
| 
 | ||||
| import ( | ||||
|     "net/http" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"strings" | ||||
| 	"unicode/utf8" | ||||
| 
 | ||||
| 	"git.32bit.cafe/32bitcafe/guestbook/internal/models" | ||||
| 	"github.com/google/uuid" | ||||
| ) | ||||
| 
 | ||||
| func (app *application) home(w http.ResponseWriter, r *http.Request) { | ||||
|     app.render(w, r, http.StatusOK, "home.tmpl.html", templateData{}) | ||||
| } | ||||
| 
 | ||||
| func getUserRegister(w http.ResponseWriter, r *http.Request) { | ||||
| func (app *application) getUserRegister(w http.ResponseWriter, r *http.Request) { | ||||
|     app.render(w, r, http.StatusOK, "usercreate.view.tmpl.html", templateData{}) | ||||
| } | ||||
| 
 | ||||
| func postUserRegister(w http.ResponseWriter, r *http.Request) { | ||||
| func (app *application) postUserRegister(w http.ResponseWriter, r *http.Request) { | ||||
|     err := r.ParseForm() | ||||
|     if err != nil { | ||||
|         app.serverError(w, r, err) | ||||
|     } | ||||
|     username := r.Form.Get("username") | ||||
|     email := r.Form.Get("email") | ||||
|     rawid, err := app.users.Insert(username, email) | ||||
|     if err != nil { | ||||
|         app.serverError(w, r, err) | ||||
|         return | ||||
|     } | ||||
|     id, err := encodeIdB64(rawid) | ||||
|     if err != nil { | ||||
|         app.serverError(w, r, err) | ||||
|         return | ||||
|     } | ||||
|     http.Redirect(w, r, fmt.Sprintf("/users/%s", id), http.StatusSeeOther) | ||||
| } | ||||
| 
 | ||||
| func getUsersList(w http.ResponseWriter, r *http.Request) { | ||||
| func (app *application) getUsersList(w http.ResponseWriter, r *http.Request) { | ||||
|     users, err := app.users.GetAll() | ||||
|     if err != nil { | ||||
|         app.serverError(w, r, err) | ||||
|         return | ||||
|     } | ||||
|     app.render(w, r, http.StatusOK, "userlist.view.tmpl.html", templateData{ | ||||
|         Users: users, | ||||
|     }) | ||||
| } | ||||
| 
 | ||||
| func getUser(w http.ResponseWriter, r *http.Request) { | ||||
| func (app *application) getUser(w http.ResponseWriter, r *http.Request) { | ||||
|     rawid := r.PathValue("id")  | ||||
|     id, err := decodeIdB64(rawid) | ||||
|     if err != nil { | ||||
|         app.serverError(w, r, err) | ||||
|         return | ||||
|     } | ||||
|     user, err := app.users.Get(id) | ||||
|     if err != nil { | ||||
|         if errors.Is(err, models.ErrNoRecord) { | ||||
|             http.NotFound(w, r) | ||||
|         } else { | ||||
|             app.serverError(w, r, err) | ||||
|         } | ||||
|         return | ||||
|     } | ||||
|     app.render(w, r, http.StatusOK, "user.view.tmpl.html", templateData{ | ||||
|         User: user, | ||||
|     }) | ||||
| } | ||||
| 
 | ||||
| func postGuestbooksCreate(w http.ResponseWriter, r* http.Request) { | ||||
| func (app *application) getGuestbookCreate(w http.ResponseWriter, r* http.Request) { | ||||
|     app.render(w, r, http.StatusOK, "guestbookcreate.view.tmpl.html", templateData{}) | ||||
| } | ||||
| 
 | ||||
| func getGuestbooksList(w http.ResponseWriter, r *http.Request) { | ||||
| func (app *application) postGuestbookCreate(w http.ResponseWriter, r* http.Request) { | ||||
|     err := r.ParseForm() | ||||
|     if err != nil { | ||||
|         app.serverError(w, r, err) | ||||
|         return | ||||
|     } | ||||
|     siteUrl := r.Form.Get("siteurl") | ||||
|     app.logger.Debug("creating guestbook for site", "siteurl", siteUrl) | ||||
|     userId := getUserId() | ||||
|     rawid, err := app.guestbooks.Insert(siteUrl, userId) | ||||
|     if err != nil { | ||||
|         app.serverError(w, r, err) | ||||
|         return | ||||
|     } | ||||
|     id, err := encodeIdB64(rawid) | ||||
|     if err != nil { | ||||
|         app.serverError(w, r, err) | ||||
|         return | ||||
|     } | ||||
|     app.sessionManager.Put(r.Context(), "flash", "Guestbook successfully created!") | ||||
|     http.Redirect(w, r, fmt.Sprintf("/guestbooks/%s", id), http.StatusSeeOther) | ||||
| } | ||||
| 
 | ||||
| func getGuestbook(w http.ResponseWriter, r *http.Request) { | ||||
| func (app *application) getGuestbookList(w http.ResponseWriter, r *http.Request) { | ||||
|     userId := getUserId() | ||||
|     guestbooks, err := app.guestbooks.GetAll(userId) | ||||
|     if err != nil { | ||||
|         app.serverError(w, r, err) | ||||
|         return | ||||
|     } | ||||
|     app.render(w, r, http.StatusOK, "guestbooklist.view.tmpl.html", templateData{ | ||||
|         Guestbooks: guestbooks, | ||||
|     }) | ||||
| } | ||||
| 
 | ||||
| func (app *application) getGuestbook(w http.ResponseWriter, r *http.Request) { | ||||
|     rawId := r.PathValue("id") | ||||
|     id, err := decodeIdB64(rawId) | ||||
|     if err != nil { | ||||
|         app.serverError(w, r, err) | ||||
|         return | ||||
|     } | ||||
|     guestbook, err := app.guestbooks.Get(id) | ||||
|     if err != nil { | ||||
|         if errors.Is(err, models.ErrNoRecord) { | ||||
|             http.NotFound(w, r) | ||||
|         } else { | ||||
|             app.serverError(w, r, err) | ||||
|         } | ||||
|         return | ||||
|     } | ||||
|     comments, err := app.guestbookComments.GetAll(id) | ||||
|     if err != nil { | ||||
|         app.serverError(w, r, err) | ||||
|         return | ||||
|     } | ||||
|     data := app.newTemplateData(r) | ||||
|     data.Guestbook = guestbook | ||||
|     data.Comments = comments | ||||
|     app.render(w, r, http.StatusOK, "guestbook.view.tmpl.html", data) | ||||
| } | ||||
| 
 | ||||
| func (app *application) getGuestbookComments(w http.ResponseWriter, r *http.Request) { | ||||
|     rawId := r.PathValue("id") | ||||
|     id, err := decodeIdB64(rawId) | ||||
|     if err != nil { | ||||
|         app.serverError(w, r, err) | ||||
|         return | ||||
|     } | ||||
|     guestbook, err := app.guestbooks.Get(id) | ||||
|     if err != nil { | ||||
|         if errors.Is(err, models.ErrNoRecord) { | ||||
|             http.NotFound(w, r) | ||||
|         } else { | ||||
|             app.serverError(w, r, err) | ||||
|         } | ||||
|         return | ||||
|     } | ||||
|     comments, err := app.guestbookComments.GetAll(id) | ||||
|     if err != nil { | ||||
|         app.serverError(w, r, err) | ||||
|         return | ||||
|     } | ||||
|     app.render(w, r, http.StatusOK, "commentlist.view.tmpl.html", templateData{ | ||||
|         Guestbook: guestbook, | ||||
|         Comments: comments, | ||||
|     }) | ||||
| } | ||||
| 
 | ||||
| func (app *application) getGuestbookCommentCreate(w http.ResponseWriter, r *http.Request) { | ||||
|     rawId := r.PathValue("id") | ||||
|     id, err := decodeIdB64(rawId) | ||||
|     if err != nil { | ||||
|         http.NotFound(w, r) | ||||
|     } | ||||
|     app.render(w, r, http.StatusOK, "commentcreate.view.tmpl.html", templateData{ | ||||
|         Guestbook: models.Guestbook{ | ||||
|             ID: id, | ||||
|         }, | ||||
|     }) | ||||
| } | ||||
| 
 | ||||
| func (app *application) postGuestbookCommentCreate(w http.ResponseWriter, r *http.Request) { | ||||
|     rawGbId := r.PathValue("id") | ||||
|     gbId, err := decodeIdB64(rawGbId) | ||||
|     if err != nil { | ||||
|         app.serverError(w, r, err) | ||||
|         return | ||||
|     } | ||||
|     err = r.ParseForm() | ||||
|     if err != nil { | ||||
|         app.clientError(w, http.StatusBadRequest) | ||||
|         return | ||||
|     } | ||||
|     authorName := r.PostForm.Get("authorname") | ||||
|     authorEmail := r.PostForm.Get("authoremail") | ||||
|     authorSite := r.PostForm.Get("authorsite") | ||||
|     content := r.PostForm.Get("content") | ||||
| 
 | ||||
|     fieldErrors := make(map[string]string) | ||||
|     if strings.TrimSpace(authorName) == "" { | ||||
|         fieldErrors["title"] = "This field cannot be blank" | ||||
|     } else if utf8.RuneCountInString(authorName) > 256 { | ||||
|         fieldErrors["title"] = "This field cannot be more than 256 characters long" | ||||
|     } | ||||
|     if strings.TrimSpace(content) == "" { | ||||
|         fieldErrors["content"] = "This field cannot be blank" | ||||
|     } | ||||
|     if len(fieldErrors) > 0 { | ||||
|         fmt.Fprint(w, fieldErrors) | ||||
|         return | ||||
|     } | ||||
|      | ||||
|     commentId, err := app.guestbookComments.Insert(gbId, uuid.UUID{}, authorName, authorEmail, authorSite, content, "", true) | ||||
|     if err != nil { | ||||
|         app.serverError(w, r, err) | ||||
|         return | ||||
|     } | ||||
|     _, err = encodeIdB64(commentId) | ||||
|     if err != nil { | ||||
|         app.serverError(w, r, err) | ||||
|         return | ||||
|     } | ||||
|     http.Redirect(w, r, fmt.Sprintf("/guestbooks/%s", rawGbId), http.StatusSeeOther) | ||||
| } | ||||
|  | ||||
| @ -1,8 +1,11 @@ | ||||
| package main | ||||
| 
 | ||||
| import ( | ||||
|     "net/http" | ||||
|     "fmt" | ||||
| 	"encoding/base64" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 
 | ||||
| 	"github.com/google/uuid" | ||||
| ) | ||||
| 
 | ||||
| func (app *application) serverError(w http.ResponseWriter, r *http.Request, err error) { | ||||
| @ -33,3 +36,25 @@ func (app *application) render(w http.ResponseWriter, r *http.Request, status in | ||||
|         app.serverError(w, r, err) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| func encodeIdB64 (id uuid.UUID) (string, error) { | ||||
|     b, err := id.MarshalBinary() | ||||
|     if err != nil { | ||||
|         return "", err | ||||
|     } | ||||
|     s := base64.RawURLEncoding.EncodeToString(b) | ||||
|     return s, nil | ||||
| } | ||||
| 
 | ||||
| func decodeIdB64 (id string) (uuid.UUID, error) { | ||||
|     b, err := base64.RawURLEncoding.DecodeString(id) | ||||
|     var u uuid.UUID | ||||
|     if err != nil { | ||||
|         return u, err | ||||
|     } | ||||
|     err = u.UnmarshalBinary(b) | ||||
|     if err != nil { | ||||
|         return u, err | ||||
|     } | ||||
|     return u, nil | ||||
| } | ||||
|  | ||||
| @ -7,9 +7,13 @@ import ( | ||||
| 	"net/http" | ||||
| 	"os" | ||||
| 	"text/template" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"git.32bit.cafe/32bitcafe/guestbook/internal/models" | ||||
| 	_ "modernc.org/sqlite" | ||||
| 	"github.com/alexedwards/scs/sqlite3store" | ||||
| 	"github.com/alexedwards/scs/v2" | ||||
| 	"github.com/google/uuid" | ||||
| 	_ "github.com/mattn/go-sqlite3" | ||||
| ) | ||||
| 
 | ||||
| type application struct { | ||||
| @ -18,6 +22,7 @@ type application struct { | ||||
|     guestbooks *models.GuestbookModel | ||||
|     users *models.UserModel | ||||
|     guestbookComments *models.GuestbookCommentModel | ||||
|     sessionManager *scs.SessionManager | ||||
| } | ||||
| 
 | ||||
| func main() { | ||||
| @ -25,7 +30,7 @@ func main() { | ||||
|     dsn := flag.String("dsn", "guestbook.db", "data source name") | ||||
|     flag.Parse() | ||||
| 
 | ||||
|     logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) | ||||
|     logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) | ||||
| 
 | ||||
|     db, err := openDB(*dsn) | ||||
|     if err != nil { | ||||
| @ -40,15 +45,20 @@ func main() { | ||||
|         os.Exit(1) | ||||
|     } | ||||
| 
 | ||||
|     sessionManager := scs.New() | ||||
|     sessionManager.Store = sqlite3store.New(db) | ||||
|     sessionManager.Lifetime = 12 * time.Hour | ||||
| 
 | ||||
|     app := &application{ | ||||
|         templateCache: templateCache, | ||||
|         logger: logger, | ||||
|         sessionManager: sessionManager, | ||||
|         guestbooks: &models.GuestbookModel{DB: db}, | ||||
|         users: &models.UserModel{DB: db}, | ||||
|         guestbookComments: &models.GuestbookCommentModel{DB: db}, | ||||
|     } | ||||
| 
 | ||||
|     logger.Info("Starting server on %s", slog.Any("addr", ":4000")) | ||||
|     logger.Info("Starting server", slog.Any("addr", *addr)) | ||||
| 
 | ||||
|     err = http.ListenAndServe(*addr, app.routes()); | ||||
|     logger.Error(err.Error()) | ||||
| @ -56,7 +66,7 @@ func main() { | ||||
| } | ||||
| 
 | ||||
| func openDB(dsn string) (*sql.DB, error) { | ||||
|     db, err := sql.Open("sqlite", dsn) | ||||
|     db, err := sql.Open("sqlite3", dsn) | ||||
|     if err != nil { | ||||
|         return nil, err | ||||
|     } | ||||
| @ -65,3 +75,8 @@ func openDB(dsn string) (*sql.DB, error) { | ||||
|     } | ||||
|     return db, nil | ||||
| } | ||||
| 
 | ||||
| func getUserId() uuid.UUID { | ||||
|     userId, _ := decodeIdB64("laINnbnkTtyN5SYoCfSbXw") | ||||
|     return userId | ||||
| } | ||||
|  | ||||
							
								
								
									
										43
									
								
								cmd/web/middleware.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								cmd/web/middleware.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,43 @@ | ||||
| package main | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| ) | ||||
| 
 | ||||
| func (app *application) logRequest (next http.Handler) http.Handler { | ||||
|     return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||||
|         var ( | ||||
|             ip = r.RemoteAddr | ||||
|             proto = r.Proto | ||||
|             method = r.Method | ||||
|             uri = r.URL.RequestURI() | ||||
|         ) | ||||
|         app.logger.Info("received request", "ip", ip, "proto", proto, "method", method, "uri", uri) | ||||
|         next.ServeHTTP(w, r) | ||||
|     }) | ||||
| } | ||||
| 
 | ||||
| func commonHeaders (next http.Handler) http.Handler { | ||||
|     return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||||
|         w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' fonts.googleapis.com; font-src fonts.gstatic.com") | ||||
|         w.Header().Set("Referrer-Policy", "origin-when-cross-origin") | ||||
|         w.Header().Set("X-Content-Type-Options", "nosniff") | ||||
|         w.Header().Set("X-Frame-Options", "deny") | ||||
|         w.Header().Set("X-XSS-Protection", "0") | ||||
|         next.ServeHTTP(w, r) | ||||
|     }) | ||||
| } | ||||
| 
 | ||||
| func (app *application) recoverPanic(next http.Handler) http.Handler { | ||||
|     return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||||
|         defer func() { | ||||
|             if err := recover(); err != nil { | ||||
|                 w.Header().Set("Connection", "close") | ||||
|                 app.serverError(w, r, fmt.Errorf("%s", err)) | ||||
|             } | ||||
|         }() | ||||
| 
 | ||||
|         next.ServeHTTP(w, r) | ||||
|     }) | ||||
| } | ||||
| @ -4,9 +4,22 @@ import ( | ||||
|     "net/http" | ||||
| ) | ||||
| 
 | ||||
| func (app *application) routes() *http.ServeMux { | ||||
| func (app *application) routes() http.Handler { | ||||
|     mux := http.NewServeMux() | ||||
|     mux.HandleFunc("/", app.home); | ||||
|     return mux | ||||
|     fileServer := http.FileServer(http.Dir("./ui/static")) | ||||
|     mux.Handle("GET /static/", http.StripPrefix("/static", fileServer)) | ||||
| 
 | ||||
|     mux.Handle("/{$}", app.sessionManager.LoadAndSave(http.HandlerFunc(app.home))) | ||||
|     mux.Handle("GET /users", app.sessionManager.LoadAndSave(http.HandlerFunc(app.getUsersList))) | ||||
|     mux.Handle("GET /users/{id}", app.sessionManager.LoadAndSave(http.HandlerFunc(app.getUser))) | ||||
|     mux.Handle("GET /users/register", app.sessionManager.LoadAndSave(http.HandlerFunc(app.getUserRegister))) | ||||
|     mux.Handle("POST /users/register", app.sessionManager.LoadAndSave(http.HandlerFunc(app.postUserRegister))) | ||||
|     mux.Handle("GET /guestbooks", app.sessionManager.LoadAndSave(http.HandlerFunc(app.getGuestbookList))) | ||||
|     mux.Handle("GET /guestbooks/{id}", app.sessionManager.LoadAndSave(http.HandlerFunc(app.getGuestbook))) | ||||
|     mux.Handle("GET /guestbooks/create", app.sessionManager.LoadAndSave(http.HandlerFunc(app.getGuestbookCreate))) | ||||
|     mux.Handle("POST /guestbooks/create", app.sessionManager.LoadAndSave(http.HandlerFunc(app.postGuestbookCreate))) | ||||
|     mux.Handle("GET /guestbooks/{id}/comments/create", app.sessionManager.LoadAndSave(http.HandlerFunc(app.getGuestbookCommentCreate))) | ||||
|     mux.Handle("POST /guestbooks/{id}/comments/create", app.sessionManager.LoadAndSave(http.HandlerFunc(app.postGuestbookCommentCreate))) | ||||
|     return app.recoverPanic(app.logRequest(commonHeaders(mux))) | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -1,14 +1,23 @@ | ||||
| package main | ||||
| 
 | ||||
| import ( | ||||
|     "net/http" | ||||
|     "path/filepath" | ||||
|     "text/template" | ||||
|     "time" | ||||
| 	"net/http" | ||||
| 	"path/filepath" | ||||
| 	"text/template" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"git.32bit.cafe/32bitcafe/guestbook/internal/models" | ||||
| ) | ||||
| 
 | ||||
| type templateData struct { | ||||
|     CurrentYear int | ||||
|     User models.User | ||||
|     Users []models.User | ||||
|     Guestbook models.Guestbook | ||||
|     Guestbooks []models.Guestbook | ||||
|     Comment models.GuestbookComment | ||||
|     Comments []models.GuestbookComment | ||||
|     Flash string | ||||
| } | ||||
| 
 | ||||
| func humanDate(t time.Time) string { | ||||
| @ -17,6 +26,8 @@ func humanDate(t time.Time) string { | ||||
| 
 | ||||
| var functions = template.FuncMap { | ||||
|     "humanDate": humanDate, | ||||
|     "encodeId": encodeIdB64, | ||||
|     "decodeId": decodeIdB64, | ||||
| } | ||||
| 
 | ||||
| func newTemplateCache() (map[string]*template.Template, error) { | ||||
| @ -44,8 +55,9 @@ func newTemplateCache() (map[string]*template.Template, error) { | ||||
|     return cache, nil | ||||
| } | ||||
| 
 | ||||
| func (app *application) newTemplateData(r *http.Request) *templateData { | ||||
|     return &templateData { | ||||
| func (app *application) newTemplateData(r *http.Request) templateData { | ||||
|     return templateData { | ||||
|         CurrentYear: time.Now().Year(), | ||||
|         Flash: app.sessionManager.PopString(r.Context(), "flash"), | ||||
|     } | ||||
| } | ||||
|  | ||||
							
								
								
									
										5
									
								
								db/create-session-table-sqlite.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								db/create-session-table-sqlite.sql
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | ||||
| CREATE TABLE sessions ( | ||||
|     token CHAR(43) primary key, | ||||
|     data BLOB NOT NULL, | ||||
|     expiry TEXT NOT NULL | ||||
| ); | ||||
							
								
								
									
										18
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										18
									
								
								go.mod
									
									
									
									
									
								
							| @ -3,18 +3,8 @@ module git.32bit.cafe/32bitcafe/guestbook | ||||
| go 1.23.1 | ||||
| 
 | ||||
| require ( | ||||
| 	github.com/dustin/go-humanize v1.0.1 // indirect | ||||
| 	github.com/google/uuid v1.6.0 // indirect | ||||
| 	github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect | ||||
| 	github.com/mattn/go-isatty v0.0.20 // indirect | ||||
| 	github.com/ncruces/go-strftime v0.1.9 // indirect | ||||
| 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect | ||||
| 	golang.org/x/sys v0.22.0 // indirect | ||||
| 	modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect | ||||
| 	modernc.org/libc v1.55.3 // indirect | ||||
| 	modernc.org/mathutil v1.6.0 // indirect | ||||
| 	modernc.org/memory v1.8.0 // indirect | ||||
| 	modernc.org/sqlite v1.33.1 // indirect | ||||
| 	modernc.org/strutil v1.2.0 // indirect | ||||
| 	modernc.org/token v1.1.0 // indirect | ||||
| 	github.com/alexedwards/scs/sqlite3store v0.0.0-20240316134038-7e11d57e8885 | ||||
| 	github.com/alexedwards/scs/v2 v2.8.0 | ||||
| 	github.com/google/uuid v1.6.0 | ||||
| 	github.com/mattn/go-sqlite3 v1.14.6 | ||||
| ) | ||||
|  | ||||
							
								
								
									
										33
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										33
									
								
								go.sum
									
									
									
									
									
								
							| @ -1,29 +1,8 @@ | ||||
| github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= | ||||
| github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= | ||||
| github.com/alexedwards/scs/sqlite3store v0.0.0-20240316134038-7e11d57e8885 h1:+DCxWg/ojncqS+TGAuRUoV7OfG/S4doh0pcpAwEcow0= | ||||
| github.com/alexedwards/scs/sqlite3store v0.0.0-20240316134038-7e11d57e8885/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss= | ||||
| github.com/alexedwards/scs/v2 v2.8.0 h1:h31yUYoycPuL0zt14c0gd+oqxfRwIj6SOjHdKRZxhEw= | ||||
| github.com/alexedwards/scs/v2 v2.8.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8= | ||||
| github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | ||||
| github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | ||||
| github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= | ||||
| github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= | ||||
| github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= | ||||
| github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= | ||||
| github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= | ||||
| github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= | ||||
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= | ||||
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= | ||||
| golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= | ||||
| golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= | ||||
| modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= | ||||
| modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= | ||||
| modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= | ||||
| modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= | ||||
| modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= | ||||
| modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= | ||||
| modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= | ||||
| modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= | ||||
| modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM= | ||||
| modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= | ||||
| modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= | ||||
| modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= | ||||
| modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= | ||||
| modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= | ||||
| github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= | ||||
| github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= | ||||
|  | ||||
							
								
								
									
										5
									
								
								internal/models/errors.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								internal/models/errors.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | ||||
| package models | ||||
| 
 | ||||
| import "errors" | ||||
| 
 | ||||
| var ErrNoRecord = errors.New("models: no matching record found") | ||||
| @ -21,7 +21,7 @@ type GuestbookModel struct { | ||||
| func (m *GuestbookModel) Insert(siteUrl string, userId uuid.UUID) (uuid.UUID, error) { | ||||
|     id := uuid.New() | ||||
|     stmt := `INSERT INTO guestbooks (Id, SiteUrl, UserId, IsDeleted, IsActive) | ||||
|     VALUES(?, ?, FALSE, TRUE)` | ||||
|     VALUES(?, ?, ?, FALSE, TRUE)` | ||||
|     _, err := m.DB.Exec(stmt, id, siteUrl, userId) | ||||
|     if err != nil { | ||||
|         return uuid.UUID{}, err | ||||
| @ -43,5 +43,23 @@ func (m *GuestbookModel) Get(id uuid.UUID) (Guestbook, error) { | ||||
| } | ||||
| 
 | ||||
| func (m *GuestbookModel) GetAll(userId uuid.UUID) ([]Guestbook, error) { | ||||
|     return []Guestbook{}, nil | ||||
|     stmt := `SELECT Id, SiteUrl, UserId, IsDeleted, IsActive FROM guestbooks | ||||
|     WHERE UserId = ?` | ||||
|     rows, err := m.DB.Query(stmt, userId) | ||||
|     if err != nil { | ||||
|         return nil, err | ||||
|     } | ||||
|     var guestbooks []Guestbook | ||||
|     for rows.Next() { | ||||
|         var g Guestbook | ||||
|         err = rows.Scan(&g.ID, &g.SiteUrl, &g.UserId, &g.IsDeleted, &g.IsActive) | ||||
|         if err != nil { | ||||
|             return nil, err | ||||
|         } | ||||
|         guestbooks = append(guestbooks, g) | ||||
|     } | ||||
|     if err = rows.Err(); err != nil { | ||||
|         return nil, err | ||||
|     } | ||||
|     return guestbooks, nil | ||||
| } | ||||
|  | ||||
| @ -49,5 +49,24 @@ func (m *GuestbookCommentModel) Get(id uuid.UUID) (GuestbookComment, error) { | ||||
|     return c, nil | ||||
| } | ||||
| 
 | ||||
| func (m *GuestbookCommentModel) GetAll(guestbookId *uuid.UUID) { | ||||
| func (m *GuestbookCommentModel) GetAll(guestbookId uuid.UUID) ([]GuestbookComment, error) { | ||||
|     stmt := `SELECT Id, GuestbookId, ParentId, AuthorName, AuthorEmail, AuthorSite, | ||||
|     CommentText, PageUrl, IsPublished, IsDeleted FROM guestbook_comments WHERE GuestbookId = ?` | ||||
|     rows, err := m.DB.Query(stmt, guestbookId) | ||||
|     if err != nil { | ||||
| 	return nil, err | ||||
|     } | ||||
|     var comments []GuestbookComment | ||||
|     for rows.Next() { | ||||
| 	var c GuestbookComment | ||||
| 	err = rows.Scan(&c.ID, &c.GuestbookId, &c.ParentId, &c.AuthorName, &c.AuthorEmail, &c.AuthorSite, &c.CommentText, &c.PageUrl, &c.IsPublished, &c.IsDeleted) | ||||
| 	if err != nil { | ||||
| 	    return nil, err | ||||
| 	} | ||||
| 	comments = append(comments, c) | ||||
|     } | ||||
|     if err = rows.Err(); err != nil { | ||||
| 	return nil, err | ||||
|     } | ||||
|     return comments, nil | ||||
| } | ||||
|  | ||||
| @ -1,9 +1,10 @@ | ||||
| package models | ||||
| 
 | ||||
| import ( | ||||
| 	"database/sql" | ||||
|     "database/sql" | ||||
|     "errors" | ||||
| 
 | ||||
| 	"github.com/google/uuid" | ||||
|     "github.com/google/uuid" | ||||
| ) | ||||
| 
 | ||||
| type User struct { | ||||
| @ -34,7 +35,28 @@ func (m *UserModel) Get(id uuid.UUID) (User, error) { | ||||
|     var u User | ||||
|     err := row.Scan(&u.ID, &u.Username, &u.Email, &u.IsDeleted) | ||||
|     if err != nil { | ||||
| 	if errors.Is(err, sql.ErrNoRows) { | ||||
| 	    return User{}, ErrNoRecord | ||||
| 	} | ||||
| 	return User{}, err | ||||
|     } | ||||
|     return u, nil | ||||
| } | ||||
| 
 | ||||
| func (m *UserModel) GetAll() ([]User, error) { | ||||
|     stmt := `SELECT Id, Username, Email, IsDeleted FROM users` | ||||
|     rows, err := m.DB.Query(stmt) | ||||
|     var users []User | ||||
|     for rows.Next() { | ||||
| 	var u User | ||||
| 	err = rows.Scan(&u.ID, &u.Username, &u.Email, &u.IsDeleted) | ||||
| 	if err != nil { | ||||
| 	    return nil, err | ||||
| 	} | ||||
| 	users = append(users, u) | ||||
|     } | ||||
|     if err = rows.Err(); err != nil { | ||||
| 	return nil, err | ||||
|     } | ||||
|     return users, nil | ||||
| } | ||||
|  | ||||
							
								
								
									
										42
									
								
								internal/validator/validator.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								internal/validator/validator.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,42 @@ | ||||
| package validator | ||||
| 
 | ||||
| import ( | ||||
| 	"slices" | ||||
| 	"strings" | ||||
| 	"unicode/utf8" | ||||
| ) | ||||
| 
 | ||||
| type Validator struct { | ||||
|     FieldErrors map[string]string | ||||
| } | ||||
| 
 | ||||
| func (v *Validator) Valid() bool { | ||||
|     return len(v.FieldErrors) == 0 | ||||
| } | ||||
| 
 | ||||
| func (v *Validator) AddFieldError(key, message string) { | ||||
|     if v.FieldErrors == nil { | ||||
|         v.FieldErrors = make(map[string]string) | ||||
|     } | ||||
|     if _, exists := v.FieldErrors[key]; !exists { | ||||
|         v.FieldErrors[key] = message | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| func (v *Validator) CheckField(ok bool, key, message string) { | ||||
|     if !ok { | ||||
|         v.AddFieldError(key, message) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| func NotBlank(value string) bool { | ||||
|     return strings.TrimSpace(value) != "" | ||||
| } | ||||
| 
 | ||||
| func MaxChars(value string, n int) bool { | ||||
|     return utf8.RuneCountInString(value) <= n | ||||
| } | ||||
| 
 | ||||
| func PermittedValue[T comparable](value T, permittedValues ...T) bool { | ||||
|     return slices.Contains(permittedValues, value) | ||||
| } | ||||
| @ -5,7 +5,7 @@ | ||||
|         <title>{{ template "title" }} - Guestbook</title> | ||||
|         <meta charset="UTF-8"> | ||||
|         <meta name="viewport" content="width=device-width, initial-scale=1"> | ||||
|         <link href="css/style.css" rel="stylesheet"> | ||||
|         <link href="/static/css/style.css" rel="stylesheet"> | ||||
|     </head> | ||||
|     <body> | ||||
|         <header> | ||||
| @ -13,6 +13,9 @@ | ||||
|         </header> | ||||
|         {{ template "nav" . }} | ||||
|         <main> | ||||
|             {{ with .Flash }} | ||||
|                 <div class="flash">{{ . }}</div> | ||||
|             {{ end }} | ||||
|             {{ template "main" . }} | ||||
|         </main> | ||||
|         <footer> | ||||
|  | ||||
							
								
								
									
										14
									
								
								ui/html/pages/commentcreate.view.tmpl.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								ui/html/pages/commentcreate.view.tmpl.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,14 @@ | ||||
| {{ define "title" }}New Comment{{ end }} | ||||
| {{ define "main" }} | ||||
| <form action="/guestbooks/{{ encodeId .Guestbook.ID }}/comments/create" method="post"> | ||||
|     <label for="authorname">Name: </label> | ||||
|     <input type="text" name="authorname" id="authorname" > | ||||
|     <label for="authoremail">Email: </label> | ||||
|     <input type="text" name="authoremail" id="authoremail" > | ||||
|     <label for="authorsite">Site Url: </label> | ||||
|     <input type="text" name="authortext" id="authortext" > | ||||
|     <label for="content">Comment: </label> | ||||
|     <textarea name="content" id="content"></textarea> | ||||
|     <input type="submit" value="Post"> | ||||
| </form> | ||||
| {{ end }} | ||||
							
								
								
									
										12
									
								
								ui/html/pages/guestbook.view.tmpl.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								ui/html/pages/guestbook.view.tmpl.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | ||||
| {{ define "title" }} Guestbook View {{ end }} | ||||
| {{ define "main" }} | ||||
| <h1>Guestbook for {{ .Guestbook.SiteUrl }}</h1> | ||||
| <a href="/guestbooks/{{ encodeId .Guestbook.ID }}/comments/create">New Comment</a> | ||||
| <ul> | ||||
| {{ range .Comments }} | ||||
| <li> {{ .CommentText }} </li> | ||||
| {{ else }} | ||||
| </ul> | ||||
| <p>No comments yet!</p> | ||||
| {{ end }} | ||||
| {{ end }} | ||||
							
								
								
									
										8
									
								
								ui/html/pages/guestbookcreate.view.tmpl.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								ui/html/pages/guestbookcreate.view.tmpl.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,8 @@ | ||||
| {{ define "title" }}Create a Guestbook{{ end }} | ||||
| {{ define "main" }} | ||||
| <form action="/guestbooks/create" method="post"> | ||||
|     <label for="siteurl">Site URL: </label> | ||||
|     <input type="text" name="siteurl" id="siteurl" required /> | ||||
|     <input type="submit" /> | ||||
| </form> | ||||
| {{ end }} | ||||
							
								
								
									
										11
									
								
								ui/html/pages/guestbooklist.view.tmpl.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								ui/html/pages/guestbooklist.view.tmpl.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,11 @@ | ||||
| {{ define "title" }} Guestbooks  {{ end }} | ||||
| {{ define "main" }} | ||||
| <h1>Guestbooks run by ---</h1> | ||||
| <ul> | ||||
|     {{ range .Guestbooks }} | ||||
|     <li><a href="/guestbooks/{{ encodeId .ID }}">{{ with .SiteUrl }}{{ . }}{{ else }}Untitled{{ end }}</a></li> | ||||
|     {{ else }} | ||||
|     <p>No Guestbooks yet</p> | ||||
|     {{ end }} | ||||
| </ul> | ||||
| {{ end }} | ||||
| @ -1,5 +1,10 @@ | ||||
| {{ define "title" }}Home{{ end }} | ||||
| {{ define "main" }} | ||||
| <h2>Latest Guestbooks</h2> | ||||
| <p>There's nothing here yet</p> | ||||
| <p> | ||||
|     <a href="/guestbooks">View Guestbooks</a> | ||||
| </p> | ||||
| <p> | ||||
|     <a href="/guestbooks/create">Create a Guestbook</a> | ||||
| </p> | ||||
| {{ end }} | ||||
|  | ||||
							
								
								
									
										5
									
								
								ui/html/pages/user.view.tmpl.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								ui/html/pages/user.view.tmpl.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | ||||
| {{ define "title" }}{{ .User.Username }}{{ end }} | ||||
| {{ define "main" }} | ||||
| <h1>{{ .User.Username }}</h1> | ||||
| <p>{{ .User.Email }}</p> | ||||
| {{ end }} | ||||
							
								
								
									
										10
									
								
								ui/html/pages/usercreate.view.tmpl.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								ui/html/pages/usercreate.view.tmpl.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,10 @@ | ||||
| {{ define "title" }}User Registration{{ end }} | ||||
| {{ define "main" }} | ||||
| <form action="/users/register" method="post"> | ||||
|     <label for="username">Username: </label> | ||||
|     <input type="text" name="username" id="username" required /> | ||||
|     <label for="email">Email: </label> | ||||
|     <input type="text" name="email" id="email" required /> | ||||
|     <input type="submit" /> | ||||
| </form> | ||||
| {{ end }} | ||||
							
								
								
									
										9
									
								
								ui/html/pages/userlist.view.tmpl.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								ui/html/pages/userlist.view.tmpl.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,9 @@ | ||||
| {{ define "title" }}Users{{ end }} | ||||
| {{ define "main" }} | ||||
|     <h1>Users</h1> | ||||
|     {{ range .Users }} | ||||
|         <p> | ||||
|         <a href="/users/{{ encodeId .ID }}">{{ .Username }}</a> | ||||
|         </p> | ||||
|     {{ end }} | ||||
| {{ end }} | ||||
| @ -1,5 +1,7 @@ | ||||
| {{ define "nav" }} | ||||
| <nav> | ||||
|     <a href="/">Home</a> | ||||
|     <a href="/users/register">Register</a> | ||||
|     <a href="/users">Users</a> | ||||
| </nav> | ||||
| {{ end }} | ||||
|  | ||||
							
								
								
									
										511
									
								
								ui/static/css/style.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										511
									
								
								ui/static/css/style.css
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,511 @@ | ||||
| /* Set the global variables for everything. Change these to use your own fonts and colours. */ | ||||
| :root { | ||||
|   /* Set sans-serif & mono fonts */ | ||||
|   --sans-font: -apple-system, BlinkMacSystemFont, "Avenir Next", Avenir, | ||||
|     "Nimbus Sans L", Roboto, Noto, "Segoe UI", Arial, Helvetica, | ||||
|     "Helvetica Neue", sans-serif; | ||||
|   --mono-font: Consolas, Menlo, Monaco, "Andale Mono", "Ubuntu Mono", monospace; | ||||
| 
 | ||||
|   /* Body font size. By default, effectively 18.4px, based on 16px as 'root em' */ | ||||
|   --base-fontsize: 1.15rem; | ||||
| 
 | ||||
|   /* Major third scale progression - see https://type-scale.com/ */ | ||||
|   --header-scale: 1.25; | ||||
| 
 | ||||
|   /* Line height is set to the "Golden ratio" for optimal legibility */ | ||||
|   --line-height: 1.618; | ||||
| 
 | ||||
|   /* Default (light) theme */ | ||||
|   --bg: #fff; | ||||
|   --accent-bg: #f5f7ff; | ||||
|   --text: #212121; | ||||
|   --text-light: #585858; | ||||
|   --border: #d8dae1; | ||||
|   --accent: #0d47a1; | ||||
|   --accent-light: #90caf9; | ||||
|   --code: #d81b60; | ||||
|   --preformatted: #444; | ||||
|   --marked: #ffdd33; | ||||
|   --disabled: #efefef; | ||||
| } | ||||
| 
 | ||||
| /* Dark theme */ | ||||
| @media (prefers-color-scheme: dark) { | ||||
|   :root { | ||||
|     --bg: #212121; | ||||
|     --accent-bg: #2b2b2b; | ||||
|     --text: #dcdcdc; | ||||
|     --text-light: #ababab; | ||||
|     --border: #666; | ||||
|     --accent: #ffb300; | ||||
|     --accent-light: #ffecb3; | ||||
|     --code: #f06292; | ||||
|     --preformatted: #ccc; | ||||
|     --disabled: #111; | ||||
|   } | ||||
| 
 | ||||
|   img, | ||||
|   video { | ||||
|     opacity: 0.6; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| html { | ||||
|   /* Set the font globally */ | ||||
|   font-family: var(--sans-font); | ||||
| } | ||||
| 
 | ||||
| /* Make the body a nice central block */ | ||||
| body { | ||||
|   color: var(--text); | ||||
|   background: var(--bg); | ||||
|   font-size: var(--base-fontsize); | ||||
|   line-height: var(--line-height); | ||||
|   display: flex; | ||||
|   min-height: 100vh; | ||||
|   flex-direction: column; | ||||
|   flex: 1; | ||||
|   margin: 0 auto; | ||||
|   max-width: 45rem; | ||||
|   padding: 0 0.5rem; | ||||
|   overflow-x: hidden; | ||||
|   word-break: break-word; | ||||
|   overflow-wrap: break-word; | ||||
| } | ||||
| 
 | ||||
| /* Make the header bg full width, but the content inline with body */ | ||||
| header { | ||||
|   background: var(--accent-bg); | ||||
|   border-bottom: 1px solid var(--border); | ||||
|   text-align: center; | ||||
|   padding: 2rem 0.5rem; | ||||
|   width: 100vw; | ||||
|   position: relative; | ||||
|   box-sizing: border-box; | ||||
|   left: 50%; | ||||
|   right: 50%; | ||||
|   margin-left: -50vw; | ||||
|   margin-right: -50vw; | ||||
| } | ||||
| 
 | ||||
| /* Remove margins for header text */ | ||||
| header h1, | ||||
| header p { | ||||
|   margin: 0; | ||||
| } | ||||
| 
 | ||||
| /* Add a little padding to ensure spacing is correct between content and nav */ | ||||
| main { | ||||
|   padding-top: 1.5rem; | ||||
| } | ||||
| 
 | ||||
| /* Fix line height when title wraps */ | ||||
| h1, | ||||
| h2, | ||||
| h3 { | ||||
|   line-height: 1.1; | ||||
| } | ||||
| 
 | ||||
| /* Format navigation */ | ||||
| nav { | ||||
|   font-size: 1rem; | ||||
|   line-height: 2; | ||||
|   padding: 1rem 0; | ||||
| } | ||||
| 
 | ||||
| nav a { | ||||
|   margin: 1rem 1rem 0 0; | ||||
|   border: 1px solid var(--border); | ||||
|   border-radius: 5px; | ||||
|   color: var(--text) !important; | ||||
|   display: inline-block; | ||||
|   padding: 0.1rem 1rem; | ||||
|   text-decoration: none; | ||||
|   transition: 0.4s; | ||||
| } | ||||
| 
 | ||||
| nav a:hover { | ||||
|   color: var(--accent) !important; | ||||
|   border-color: var(--accent); | ||||
| } | ||||
| 
 | ||||
| nav a.current:hover { | ||||
|   text-decoration: none; | ||||
| } | ||||
| 
 | ||||
| footer { | ||||
|   margin-top: 4rem; | ||||
|   padding: 2rem 1rem 1.5rem 1rem; | ||||
|   color: var(--text-light); | ||||
|   font-size: 0.9rem; | ||||
|   text-align: center; | ||||
|   border-top: 1px solid var(--border); | ||||
| } | ||||
| 
 | ||||
| /* Format headers */ | ||||
| h1 { | ||||
|   font-size: calc( | ||||
|     var(--base-fontsize) * var(--header-scale) * var(--header-scale) * | ||||
|       var(--header-scale) * var(--header-scale) | ||||
|   ); | ||||
|   margin-top: calc(var(--line-height) * 1.5rem); | ||||
| } | ||||
| 
 | ||||
| h2 { | ||||
|   font-size: calc( | ||||
|     var(--base-fontsize) * var(--header-scale) * var(--header-scale) * | ||||
|       var(--header-scale) | ||||
|   ); | ||||
|   margin-top: calc(var(--line-height) * 1.5rem); | ||||
| } | ||||
| 
 | ||||
| h3 { | ||||
|   font-size: calc( | ||||
|     var(--base-fontsize) * var(--header-scale) * var(--header-scale) | ||||
|   ); | ||||
|   margin-top: calc(var(--line-height) * 1.5rem); | ||||
| } | ||||
| 
 | ||||
| h4 { | ||||
|   font-size: calc(var(--base-fontsize) * var(--header-scale)); | ||||
|   margin-top: calc(var(--line-height) * 1.5rem); | ||||
| } | ||||
| 
 | ||||
| h5 { | ||||
|   font-size: var(--base-fontsize); | ||||
|   margin-top: calc(var(--line-height) * 1.5rem); | ||||
| } | ||||
| 
 | ||||
| h6 { | ||||
|   font-size: calc(var(--base-fontsize) / var(--header-scale)); | ||||
|   margin-top: calc(var(--line-height) * 1.5rem); | ||||
| } | ||||
| 
 | ||||
| /* Format links & buttons */ | ||||
| a, | ||||
| a:visited { | ||||
|   color: var(--accent); | ||||
| } | ||||
| 
 | ||||
| a:hover { | ||||
|   text-decoration: none; | ||||
| } | ||||
| 
 | ||||
| a button, | ||||
| button, | ||||
| [role="button"], | ||||
| input[type="submit"], | ||||
| input[type="reset"], | ||||
| input[type="button"] { | ||||
|   border: none; | ||||
|   border-radius: 5px; | ||||
|   background: var(--accent); | ||||
|   font-size: 1rem; | ||||
|   color: var(--bg); | ||||
|   padding: 0.7rem 0.9rem; | ||||
|   margin: 0.5rem 0; | ||||
|   transition: 0.4s; | ||||
| } | ||||
| 
 | ||||
| a button[disabled], | ||||
| button[disabled], | ||||
| [role="button"][aria-disabled="true"], | ||||
| input[type="submit"][disabled], | ||||
| input[type="reset"][disabled], | ||||
| input[type="button"][disabled], | ||||
| input[type="checkbox"][disabled], | ||||
| input[type="radio"][disabled], | ||||
| select[disabled] { | ||||
|   cursor: default; | ||||
|   opacity: 0.5; | ||||
|   cursor: not-allowed; | ||||
| } | ||||
| 
 | ||||
| input:disabled, | ||||
| textarea:disabled, | ||||
| select:disabled { | ||||
|   cursor: not-allowed; | ||||
|   background-color: var(--disabled); | ||||
| } | ||||
| 
 | ||||
| input[type="range"] { | ||||
|   padding: 0; | ||||
| } | ||||
| 
 | ||||
| /* Set the cursor to '?' while hovering over an abbreviation */ | ||||
| abbr { | ||||
|   cursor: help; | ||||
| } | ||||
| 
 | ||||
| button:focus, | ||||
| button:enabled:hover, | ||||
| [role="button"]:focus, | ||||
| [role="button"]:not([aria-disabled="true"]):hover, | ||||
| input[type="submit"]:focus, | ||||
| input[type="submit"]:enabled:hover, | ||||
| input[type="reset"]:focus, | ||||
| input[type="reset"]:enabled:hover, | ||||
| input[type="button"]:focus, | ||||
| input[type="button"]:enabled:hover, | ||||
| input[type="checkbox"]:focus, | ||||
| input[type="checkbox"]:enabled:hover, | ||||
| input[type="radio"]:focus, | ||||
| input[type="radio"]:enabled:hover { | ||||
|   filter: brightness(1.4); | ||||
|   cursor: pointer; | ||||
| } | ||||
| 
 | ||||
| /* Format the expanding box */ | ||||
| details { | ||||
|   background: var(--accent-bg); | ||||
|   border: 1px solid var(--border); | ||||
|   border-radius: 5px; | ||||
|   margin-bottom: 1rem; | ||||
| } | ||||
| 
 | ||||
| summary { | ||||
|   cursor: pointer; | ||||
|   font-weight: bold; | ||||
|   padding: 0.6rem 1rem; | ||||
| } | ||||
| 
 | ||||
| details[open] { | ||||
|   padding: 0.6rem 1rem 0.75rem 1rem; | ||||
| } | ||||
| 
 | ||||
| details[open] summary { | ||||
|   margin-bottom: 0.5rem; | ||||
|   padding: 0; | ||||
| } | ||||
| 
 | ||||
| details[open] > *:last-child { | ||||
|   margin-bottom: 0; | ||||
| } | ||||
| 
 | ||||
| /* Format tables */ | ||||
| table { | ||||
|   border-collapse: collapse; | ||||
|   width: 100%; | ||||
|   margin: 1.5rem 0; | ||||
| } | ||||
| 
 | ||||
| td, | ||||
| th { | ||||
|   border: 1px solid var(--border); | ||||
|   text-align: left; | ||||
|   padding: 0.5rem; | ||||
| } | ||||
| 
 | ||||
| th { | ||||
|   background: var(--accent-bg); | ||||
|   font-weight: bold; | ||||
| } | ||||
| 
 | ||||
| tr:nth-child(even) { | ||||
|   /* Set every other cell slightly darker. Improves readability. */ | ||||
|   background: var(--accent-bg); | ||||
| } | ||||
| 
 | ||||
| table caption { | ||||
|   font-weight: bold; | ||||
|   margin-bottom: 0.5rem; | ||||
| } | ||||
| 
 | ||||
| /* Lists */ | ||||
| ol, | ||||
| ul { | ||||
|   padding-left: 3rem; | ||||
| } | ||||
| 
 | ||||
| /* Format forms */ | ||||
| textarea, | ||||
| select, | ||||
| input { | ||||
|   font-size: inherit; | ||||
|   font-family: inherit; | ||||
|   padding: 0.5rem; | ||||
|   margin-bottom: 0.5rem; | ||||
|   color: var(--text); | ||||
|   background: var(--bg); | ||||
|   border: 1px solid var(--border); | ||||
|   border-radius: 5px; | ||||
|   box-shadow: none; | ||||
|   box-sizing: border-box; | ||||
|   width: 60%; | ||||
|   -moz-appearance: none; | ||||
|   -webkit-appearance: none; | ||||
|   appearance: none; | ||||
| } | ||||
| 
 | ||||
| /* Add arrow to  */ | ||||
| select { | ||||
|   background-image: linear-gradient(45deg, transparent 49%, var(--text) 51%), | ||||
|     linear-gradient(135deg, var(--text) 51%, transparent 49%); | ||||
|   background-position: calc(100% - 20px), calc(100% - 15px); | ||||
|   background-size: 5px 5px, 5px 5px; | ||||
|   background-repeat: no-repeat; | ||||
| } | ||||
| 
 | ||||
| select[multiple] { | ||||
|   background-image: none !important; | ||||
| } | ||||
| 
 | ||||
| /* checkbox and radio button style */ | ||||
| input[type="checkbox"], | ||||
| input[type="radio"] { | ||||
|   vertical-align: bottom; | ||||
|   position: relative; | ||||
| } | ||||
| 
 | ||||
| input[type="radio"] { | ||||
|   border-radius: 100%; | ||||
| } | ||||
| 
 | ||||
| input[type="checkbox"]:checked, | ||||
| input[type="radio"]:checked { | ||||
|   background: var(--accent); | ||||
| } | ||||
| 
 | ||||
| input[type="checkbox"]:checked::after { | ||||
|   /* Creates a rectangle with colored right and bottom borders which is rotated to look like a check mark */ | ||||
|   content: " "; | ||||
|   width: 0.1em; | ||||
|   height: 0.25em; | ||||
|   border-radius: 0; | ||||
|   position: absolute; | ||||
|   top: 0.05em; | ||||
|   left: 0.18em; | ||||
|   background: transparent; | ||||
|   border-right: solid var(--bg) 0.08em; | ||||
|   border-bottom: solid var(--bg) 0.08em; | ||||
|   font-size: 1.8em; | ||||
|   transform: rotate(45deg); | ||||
| } | ||||
| input[type="radio"]:checked::after { | ||||
|   /* creates a colored circle for the checked radio button  */ | ||||
|   content: " "; | ||||
|   width: 0.25em; | ||||
|   height: 0.25em; | ||||
|   border-radius: 100%; | ||||
|   position: absolute; | ||||
|   top: 0.125em; | ||||
|   background: var(--bg); | ||||
|   left: 0.125em; | ||||
|   font-size: 32px; | ||||
| } | ||||
| 
 | ||||
| /* Make the textarea wider than other inputs */ | ||||
| textarea { | ||||
|   width: 80%; | ||||
| } | ||||
| 
 | ||||
| /* Makes input fields wider on smaller screens */ | ||||
| @media only screen and (max-width: 720px) { | ||||
|   textarea, | ||||
|   select, | ||||
|   input { | ||||
|     width: 100%; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /* Ensures the checkbox and radio inputs do not have a set width like other input fields */ | ||||
| input[type="checkbox"], | ||||
| input[type="radio"] { | ||||
|   width: auto; | ||||
| } | ||||
| 
 | ||||
| /* do not show border around file selector button */ | ||||
| input[type="file"] { | ||||
|   border: 0; | ||||
| } | ||||
| 
 | ||||
| /* Without this any HTML using <fieldset> shows ugly borders and has additional padding/margin. (Issue #3) */ | ||||
| fieldset { | ||||
|   border: 0; | ||||
|   padding: 0; | ||||
|   margin: 0; | ||||
| } | ||||
| 
 | ||||
| /* Misc body elements */ | ||||
| 
 | ||||
| hr { | ||||
|   color: var(--border); | ||||
|   border-top: 1px; | ||||
|   margin: 1rem auto; | ||||
| } | ||||
| 
 | ||||
| mark { | ||||
|   padding: 2px 5px; | ||||
|   border-radius: 4px; | ||||
|   background: var(--marked); | ||||
| } | ||||
| 
 | ||||
| main img, | ||||
| main video { | ||||
|   max-width: 100%; | ||||
|   height: auto; | ||||
|   border-radius: 5px; | ||||
| } | ||||
| 
 | ||||
| figure { | ||||
|   margin: 0; | ||||
| } | ||||
| 
 | ||||
| figcaption { | ||||
|   font-size: 0.9rem; | ||||
|   color: var(--text-light); | ||||
|   text-align: center; | ||||
|   margin-bottom: 1rem; | ||||
| } | ||||
| 
 | ||||
| blockquote { | ||||
|   margin: 2rem 0 2rem 2rem; | ||||
|   padding: 0.4rem 0.8rem; | ||||
|   border-left: 0.35rem solid var(--accent); | ||||
|   opacity: 0.8; | ||||
|   font-style: italic; | ||||
| } | ||||
| 
 | ||||
| cite { | ||||
|   font-size: 0.9rem; | ||||
|   color: var(--text-light); | ||||
|   font-style: normal; | ||||
| } | ||||
| 
 | ||||
| /* Use mono font for code like elements */ | ||||
| code, | ||||
| pre, | ||||
| pre span, | ||||
| kbd, | ||||
| samp { | ||||
|   font-size: 1.075rem; | ||||
|   font-family: var(--mono-font); | ||||
|   color: var(--code); | ||||
| } | ||||
| 
 | ||||
| kbd { | ||||
|   color: var(--preformatted); | ||||
|   border: 1px solid var(--preformatted); | ||||
|   border-bottom: 3px solid var(--preformatted); | ||||
|   border-radius: 5px; | ||||
|   padding: 0.1rem; | ||||
| } | ||||
| 
 | ||||
| pre { | ||||
|   padding: 1rem 1.4rem; | ||||
|   max-width: 100%; | ||||
|   overflow: auto; | ||||
|   overflow-x: auto; | ||||
|   color: var(--preformatted); | ||||
|   background: var(--accent-bg); | ||||
|   border: 1px solid var(--border); | ||||
|   border-radius: 5px; | ||||
| } | ||||
| 
 | ||||
| /* Fix embedded code within pre */ | ||||
| pre code { | ||||
|   color: var(--preformatted); | ||||
|   background: none; | ||||
|   margin: 0; | ||||
|   padding: 0; | ||||
| } | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user