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 | # sqlite3 databases | ||||||
| *.db | *.db | ||||||
|  | 
 | ||||||
|  | # air config | ||||||
|  | .air.toml | ||||||
|  | /tmp | ||||||
|  | |||||||
| @ -1,30 +1,222 @@ | |||||||
| package main | package main | ||||||
| 
 | 
 | ||||||
| import ( | 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) { | func (app *application) home(w http.ResponseWriter, r *http.Request) { | ||||||
|     app.render(w, r, http.StatusOK, "home.tmpl.html", templateData{}) |     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 | package main | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
|     "net/http" | 	"encoding/base64" | ||||||
|     "fmt" | 	"fmt" | ||||||
|  | 	"net/http" | ||||||
|  | 
 | ||||||
|  | 	"github.com/google/uuid" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| func (app *application) serverError(w http.ResponseWriter, r *http.Request, err error) { | 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) |         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" | 	"net/http" | ||||||
| 	"os" | 	"os" | ||||||
| 	"text/template" | 	"text/template" | ||||||
|  | 	"time" | ||||||
| 
 | 
 | ||||||
| 	"git.32bit.cafe/32bitcafe/guestbook/internal/models" | 	"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 { | type application struct { | ||||||
| @ -18,6 +22,7 @@ type application struct { | |||||||
|     guestbooks *models.GuestbookModel |     guestbooks *models.GuestbookModel | ||||||
|     users *models.UserModel |     users *models.UserModel | ||||||
|     guestbookComments *models.GuestbookCommentModel |     guestbookComments *models.GuestbookCommentModel | ||||||
|  |     sessionManager *scs.SessionManager | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func main() { | func main() { | ||||||
| @ -25,7 +30,7 @@ func main() { | |||||||
|     dsn := flag.String("dsn", "guestbook.db", "data source name") |     dsn := flag.String("dsn", "guestbook.db", "data source name") | ||||||
|     flag.Parse() |     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) |     db, err := openDB(*dsn) | ||||||
|     if err != nil { |     if err != nil { | ||||||
| @ -40,15 +45,20 @@ func main() { | |||||||
|         os.Exit(1) |         os.Exit(1) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     sessionManager := scs.New() | ||||||
|  |     sessionManager.Store = sqlite3store.New(db) | ||||||
|  |     sessionManager.Lifetime = 12 * time.Hour | ||||||
|  | 
 | ||||||
|     app := &application{ |     app := &application{ | ||||||
|         templateCache: templateCache, |         templateCache: templateCache, | ||||||
|         logger: logger, |         logger: logger, | ||||||
|  |         sessionManager: sessionManager, | ||||||
|         guestbooks: &models.GuestbookModel{DB: db}, |         guestbooks: &models.GuestbookModel{DB: db}, | ||||||
|         users: &models.UserModel{DB: db}, |         users: &models.UserModel{DB: db}, | ||||||
|         guestbookComments: &models.GuestbookCommentModel{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()); |     err = http.ListenAndServe(*addr, app.routes()); | ||||||
|     logger.Error(err.Error()) |     logger.Error(err.Error()) | ||||||
| @ -56,7 +66,7 @@ func main() { | |||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func openDB(dsn string) (*sql.DB, error) { | func openDB(dsn string) (*sql.DB, error) { | ||||||
|     db, err := sql.Open("sqlite", dsn) |     db, err := sql.Open("sqlite3", dsn) | ||||||
|     if err != nil { |     if err != nil { | ||||||
|         return nil, err |         return nil, err | ||||||
|     } |     } | ||||||
| @ -65,3 +75,8 @@ func openDB(dsn string) (*sql.DB, error) { | |||||||
|     } |     } | ||||||
|     return db, nil |     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" |     "net/http" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| func (app *application) routes() *http.ServeMux { | func (app *application) routes() http.Handler { | ||||||
|     mux := http.NewServeMux() |     mux := http.NewServeMux() | ||||||
|     mux.HandleFunc("/", app.home); |     fileServer := http.FileServer(http.Dir("./ui/static")) | ||||||
|     return mux |     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 | package main | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
|     "net/http" | 	"net/http" | ||||||
|     "path/filepath" | 	"path/filepath" | ||||||
|     "text/template" | 	"text/template" | ||||||
|     "time" | 	"time" | ||||||
|  | 
 | ||||||
|  | 	"git.32bit.cafe/32bitcafe/guestbook/internal/models" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| type templateData struct { | type templateData struct { | ||||||
|     CurrentYear int |     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 { | func humanDate(t time.Time) string { | ||||||
| @ -17,6 +26,8 @@ func humanDate(t time.Time) string { | |||||||
| 
 | 
 | ||||||
| var functions = template.FuncMap { | var functions = template.FuncMap { | ||||||
|     "humanDate": humanDate, |     "humanDate": humanDate, | ||||||
|  |     "encodeId": encodeIdB64, | ||||||
|  |     "decodeId": decodeIdB64, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func newTemplateCache() (map[string]*template.Template, error) { | func newTemplateCache() (map[string]*template.Template, error) { | ||||||
| @ -44,8 +55,9 @@ func newTemplateCache() (map[string]*template.Template, error) { | |||||||
|     return cache, nil |     return cache, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (app *application) newTemplateData(r *http.Request) *templateData { | func (app *application) newTemplateData(r *http.Request) templateData { | ||||||
|     return &templateData { |     return templateData { | ||||||
|         CurrentYear: time.Now().Year(), |         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 | go 1.23.1 | ||||||
| 
 | 
 | ||||||
| require ( | require ( | ||||||
| 	github.com/dustin/go-humanize v1.0.1 // indirect | 	github.com/alexedwards/scs/sqlite3store v0.0.0-20240316134038-7e11d57e8885 | ||||||
| 	github.com/google/uuid v1.6.0 // indirect | 	github.com/alexedwards/scs/v2 v2.8.0 | ||||||
| 	github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect | 	github.com/google/uuid v1.6.0 | ||||||
| 	github.com/mattn/go-isatty v0.0.20 // indirect | 	github.com/mattn/go-sqlite3 v1.14.6 | ||||||
| 	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 |  | ||||||
| ) | ) | ||||||
|  | |||||||
							
								
								
									
										33
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										33
									
								
								go.sum
									
									
									
									
									
								
							| @ -1,29 +1,8 @@ | |||||||
| github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= | github.com/alexedwards/scs/sqlite3store v0.0.0-20240316134038-7e11d57e8885 h1:+DCxWg/ojncqS+TGAuRUoV7OfG/S4doh0pcpAwEcow0= | ||||||
| github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= | 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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | ||||||
| github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | 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/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= | ||||||
| github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= | github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= | ||||||
| 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= |  | ||||||
|  | |||||||
							
								
								
									
										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) { | func (m *GuestbookModel) Insert(siteUrl string, userId uuid.UUID) (uuid.UUID, error) { | ||||||
|     id := uuid.New() |     id := uuid.New() | ||||||
|     stmt := `INSERT INTO guestbooks (Id, SiteUrl, UserId, IsDeleted, IsActive) |     stmt := `INSERT INTO guestbooks (Id, SiteUrl, UserId, IsDeleted, IsActive) | ||||||
|     VALUES(?, ?, FALSE, TRUE)` |     VALUES(?, ?, ?, FALSE, TRUE)` | ||||||
|     _, err := m.DB.Exec(stmt, id, siteUrl, userId) |     _, err := m.DB.Exec(stmt, id, siteUrl, userId) | ||||||
|     if err != nil { |     if err != nil { | ||||||
|         return uuid.UUID{}, err |         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) { | 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 |     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 | package models | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"database/sql" |     "database/sql" | ||||||
|  |     "errors" | ||||||
| 
 | 
 | ||||||
| 	"github.com/google/uuid" |     "github.com/google/uuid" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| type User struct { | type User struct { | ||||||
| @ -34,7 +35,28 @@ func (m *UserModel) Get(id uuid.UUID) (User, error) { | |||||||
|     var u User |     var u User | ||||||
|     err := row.Scan(&u.ID, &u.Username, &u.Email, &u.IsDeleted) |     err := row.Scan(&u.ID, &u.Username, &u.Email, &u.IsDeleted) | ||||||
|     if err != nil { |     if err != nil { | ||||||
|  | 	if errors.Is(err, sql.ErrNoRows) { | ||||||
|  | 	    return User{}, ErrNoRecord | ||||||
|  | 	} | ||||||
| 	return User{}, err | 	return User{}, err | ||||||
|     } |     } | ||||||
|     return u, nil |     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> |         <title>{{ template "title" }} - Guestbook</title> | ||||||
|         <meta charset="UTF-8"> |         <meta charset="UTF-8"> | ||||||
|         <meta name="viewport" content="width=device-width, initial-scale=1"> |         <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> |     </head> | ||||||
|     <body> |     <body> | ||||||
|         <header> |         <header> | ||||||
| @ -13,6 +13,9 @@ | |||||||
|         </header> |         </header> | ||||||
|         {{ template "nav" . }} |         {{ template "nav" . }} | ||||||
|         <main> |         <main> | ||||||
|  |             {{ with .Flash }} | ||||||
|  |                 <div class="flash">{{ . }}</div> | ||||||
|  |             {{ end }} | ||||||
|             {{ template "main" . }} |             {{ template "main" . }} | ||||||
|         </main> |         </main> | ||||||
|         <footer> |         <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 "title" }}Home{{ end }} | ||||||
| {{ define "main" }} | {{ define "main" }} | ||||||
| <h2>Latest Guestbooks</h2> | <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 }} | {{ 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" }} | {{ define "nav" }} | ||||||
| <nav> | <nav> | ||||||
|     <a href="/">Home</a> |     <a href="/">Home</a> | ||||||
|  |     <a href="/users/register">Register</a> | ||||||
|  |     <a href="/users">Users</a> | ||||||
| </nav> | </nav> | ||||||
| {{ end }} | {{ 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