├── pages ├── page.go └── about │ └── page.go ├── .gitignore ├── html ├── components │ └── hello-world.html └── templates │ └── base.html ├── static ├── css │ ├── index.css │ ├── input.css │ └── output.css ├── js │ └── index.js └── img │ └── logo.svg ├── tailwind.sh ├── go.mod ├── internal ├── httpcontext │ └── httpcontext.go ├── web │ └── web.go ├── util │ └── util.go ├── appform │ └── appform.go ├── database │ └── database.go ├── handler │ └── handler.go ├── validinput │ └── validinput.go ├── middleware │ └── middleware.go ├── route │ └── route.go ├── templates │ └── templates.go └── component │ └── component.go ├── tailwind.config.js ├── go.sum ├── main.go └── README.md /pages/page.go: -------------------------------------------------------------------------------- 1 | package pages 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | main.db 3 | tmp/ -------------------------------------------------------------------------------- /pages/about/page.go: -------------------------------------------------------------------------------- 1 | package pages 2 | -------------------------------------------------------------------------------- /html/components/hello-world.html: -------------------------------------------------------------------------------- 1 |

Hello, World

-------------------------------------------------------------------------------- /static/css/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: aquamarine; 3 | } 4 | 5 | 6 | -------------------------------------------------------------------------------- /static/css/input.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /tailwind.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | tailwindcss -i './static/css/input.css' -o './static/css/output.css' --watch -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go-quickstart 2 | 3 | go 1.22.1 4 | 5 | require ( 6 | github.com/jmoiron/sqlx v1.4.0 // indirect 7 | github.com/joho/godotenv v1.5.1 // indirect 8 | github.com/mattn/go-sqlite3 v1.14.22 // indirect 9 | ) 10 | -------------------------------------------------------------------------------- /internal/httpcontext/httpcontext.go: -------------------------------------------------------------------------------- 1 | package httpcontext 2 | 3 | import ( 4 | "context" 5 | "html/template" 6 | 7 | "time" 8 | ) 9 | 10 | type Context struct { 11 | context.Context 12 | Templates *template.Template 13 | StartTime time.Time 14 | } 15 | -------------------------------------------------------------------------------- /internal/web/web.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "html" 5 | "strings" 6 | ) 7 | 8 | // Scrub sanitizes user input to prevent XSS and other potential attacks. 9 | func Scrub(input string) string { 10 | input = strings.TrimSpace(input) 11 | input = html.EscapeString(input) 12 | return input 13 | } 14 | -------------------------------------------------------------------------------- /internal/util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "math/rand" 4 | 5 | const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" 6 | 7 | func RandStr(n int) string { 8 | b := make([]byte, n) 9 | for i := range b { 10 | b[i] = letterBytes[rand.Intn(len(letterBytes))] 11 | } 12 | return string(b) 13 | } 14 | -------------------------------------------------------------------------------- /html/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{ .Title }} 9 | 10 | 11 | {{ .Content }} 12 | 13 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './internal/**/*{go,html,js}', 5 | './static/**/*{go,html,js}', 6 | ], 7 | theme: { 8 | extend: { 9 | colors: { 10 | 'primary': '#E51636', 11 | 'gray': { 12 | 100: '#F7F7F7', 13 | 500: '#999999', 14 | 900: '#333333', 15 | } 16 | }, 17 | }, 18 | }, 19 | plugins: [], 20 | }; 21 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 2 | github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= 3 | github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= 4 | github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= 5 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 6 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 7 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 8 | github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 9 | github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 10 | -------------------------------------------------------------------------------- /internal/appform/appform.go: -------------------------------------------------------------------------------- 1 | package appform 2 | 3 | import ( 4 | "fmt" 5 | "go-quickstart/internal/httpcontext" 6 | "go-quickstart/internal/web" 7 | "net/http" 8 | "os" 9 | "time" 10 | ) 11 | 12 | func Login(httpContext *httpcontext.Context, w http.ResponseWriter, r *http.Request) { 13 | path := func(email string, password string, loginErr string) string { 14 | return fmt.Sprintf(`/?loginErr=%s&email=%s&password=%s`, loginErr, email, password) 15 | } 16 | email := web.Scrub(r.FormValue("email")) 17 | password := web.Scrub(r.FormValue("password")) 18 | if email != os.Getenv("ADMIN_EMAIL") || password != os.Getenv("ADMIN_PASSWORD") { 19 | http.Redirect(w, r, path(email, password, "invalid credentials"), http.StatusSeeOther) 20 | return 21 | } 22 | cookie := http.Cookie{ 23 | Name: "session", 24 | Value: os.Getenv("ADMIN_SESSION_TOKEN"), 25 | Expires: time.Now().Add(24 * time.Hour), 26 | HttpOnly: true, 27 | } 28 | http.SetCookie(w, &cookie) 29 | http.Redirect(w, r, "/admin", http.StatusSeeOther) 30 | 31 | } 32 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "go-quickstart/internal/appform" 6 | "go-quickstart/internal/database" 7 | "go-quickstart/internal/handler" 8 | "go-quickstart/internal/middleware" 9 | "go-quickstart/internal/route" 10 | "log" 11 | 12 | "github.com/jmoiron/sqlx" 13 | "github.com/joho/godotenv" 14 | _ "github.com/mattn/go-sqlite3" 15 | ) 16 | 17 | func main() { 18 | 19 | _ = godotenv.Load() 20 | 21 | db, err := sqlx.Connect("sqlite3", "main.db") 22 | if err != nil { 23 | log.Fatalln(err) 24 | } 25 | defer db.Close() 26 | 27 | err = database.CreateTables(db) 28 | if err != nil { 29 | log.Fatalln(err) 30 | } 31 | 32 | database.PrintTables(db) 33 | 34 | r, err := route.NewRouter() 35 | if err != nil { 36 | panic(err) 37 | } 38 | 39 | r.Add("GET /favicon.ico", handler.Favicon) 40 | r.Add("GET /static/", handler.Static) 41 | 42 | r.Add("GET /", handler.PageHome, middleware.IsNotGuest) 43 | r.Add("GET /admin", handler.PageAdminPanel) 44 | 45 | r.Add("POST /", appform.Login) 46 | 47 | port := "8080" 48 | r.Serve(port, fmt.Sprintf("Server is running on port %s", port)) 49 | 50 | } 51 | -------------------------------------------------------------------------------- /internal/database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/jmoiron/sqlx" 8 | ) 9 | 10 | func CreateTables(db *sqlx.DB) error { 11 | 12 | tx := db.MustBegin() 13 | 14 | tx.MustExec(` 15 | CREATE TABLE IF NOT EXISTS users ( 16 | id INTEGER PRIMARY KEY AUTOINCREMENT, 17 | email TEXT, 18 | password BLOB 19 | ) 20 | `) 21 | 22 | tx.MustExec(` 23 | CREATE TABLE IF NOT EXISTS session ( 24 | id INTEGER PRIMARY KEY AUTOINCREMENT, 25 | expires_at TEXT, 26 | token TEXT, 27 | FOREIGN KEY (id) REFERENCES users(id) 28 | ) 29 | `) 30 | 31 | err := tx.Commit() 32 | if err != nil { 33 | return err 34 | } 35 | 36 | return nil 37 | 38 | } 39 | 40 | func PrintTables(db *sqlx.DB) { 41 | var tables []string 42 | query := ` 43 | SELECT name 44 | FROM sqlite_master 45 | WHERE type='table' 46 | ORDER BY name; 47 | ` 48 | err := db.Select(&tables, query) 49 | if err != nil { 50 | log.Fatalf("Error querying tables: %v", err) 51 | } 52 | 53 | fmt.Println("Tables in the database:") 54 | for _, table := range tables { 55 | fmt.Println(table) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /internal/handler/handler.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "go-quickstart/internal/component" 5 | "go-quickstart/internal/httpcontext" 6 | "go-quickstart/internal/templates" 7 | "net/http" 8 | "path/filepath" 9 | ) 10 | 11 | type HandlerFunc func(ctx *httpcontext.Context, w http.ResponseWriter, r *http.Request) 12 | 13 | func Favicon(httpContext *httpcontext.Context, w http.ResponseWriter, r *http.Request) { 14 | filePath := "favicon.ico" 15 | fullPath := filepath.Join(".", ".", filePath) 16 | http.ServeFile(w, r, fullPath) 17 | } 18 | 19 | func Static(httpContext *httpcontext.Context, w http.ResponseWriter, r *http.Request) { 20 | filePath := r.URL.Path[len("/static/"):] 21 | fullPath := filepath.Join(".", "static", filePath) 22 | http.ServeFile(w, r, fullPath) 23 | } 24 | 25 | func PageHome(httpContext *httpcontext.Context, w http.ResponseWriter, r *http.Request) { 26 | loginErr := r.URL.Query().Get("loginErr") 27 | email := r.URL.Query().Get("email") 28 | password := r.URL.Query().Get("password") 29 | w.Write([]byte(templates.Guest("Home", ` 30 | `+component.LoginForm(email, password, loginErr)+` 31 | `))) 32 | } 33 | 34 | func PageAdminPanel(httpContext *httpcontext.Context, w http.ResponseWriter, r *http.Request) { 35 | w.Write([]byte(templates.Admin("Admin Panel", ` 36 |

Admin Panel

37 | `))) 38 | } 39 | -------------------------------------------------------------------------------- /internal/validinput/validinput.go: -------------------------------------------------------------------------------- 1 | package validinput 2 | 3 | import ( 4 | "errors" 5 | "regexp" 6 | "unicode" 7 | ) 8 | 9 | func IsValidEmail(email string) bool { 10 | const emailRegex = `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$` 11 | re := regexp.MustCompile(emailRegex) 12 | return re.MatchString(email) 13 | } 14 | 15 | func IsValidPassword(password string) error { 16 | var ( 17 | hasMinLen = false 18 | hasUpper = false 19 | hasLower = false 20 | hasNumber = false 21 | hasSpecial = false 22 | ) 23 | 24 | if len(password) >= 8 { 25 | hasMinLen = true 26 | } 27 | 28 | for _, char := range password { 29 | switch { 30 | case unicode.IsUpper(char): 31 | hasUpper = true 32 | case unicode.IsLower(char): 33 | hasLower = true 34 | case unicode.IsDigit(char): 35 | hasNumber = true 36 | case unicode.IsPunct(char) || unicode.IsSymbol(char): 37 | hasSpecial = true 38 | } 39 | } 40 | 41 | if !hasMinLen { 42 | return errors.New("password must be at least 8 characters long") 43 | } 44 | if !hasUpper { 45 | return errors.New("password must contain at least one uppercase letter") 46 | } 47 | if !hasLower { 48 | return errors.New("password must contain at least one lowercase letter") 49 | } 50 | if !hasNumber { 51 | return errors.New("password must contain at least one digit") 52 | } 53 | if !hasSpecial { 54 | return errors.New("password must contain at least one special character") 55 | } 56 | 57 | return nil 58 | } 59 | -------------------------------------------------------------------------------- /internal/middleware/middleware.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "go-quickstart/internal/handler" 7 | "go-quickstart/internal/httpcontext" 8 | "html/template" 9 | "net/http" 10 | "os" 11 | "time" 12 | ) 13 | 14 | type MiddlewareFunc func(ctx *httpcontext.Context, w http.ResponseWriter, r *http.Request) error 15 | type MiddlewareChainFunc func(w http.ResponseWriter, r *http.Request, templates *template.Template, handler handler.HandlerFunc, middleware ...MiddlewareFunc) 16 | 17 | func Chain(w http.ResponseWriter, r *http.Request, templates *template.Template, handler handler.HandlerFunc, middleware ...MiddlewareFunc) { 18 | customContext := &httpcontext.Context{ 19 | Context: context.Background(), 20 | Templates: templates, 21 | StartTime: time.Now(), 22 | } 23 | for _, mw := range middleware { 24 | err := mw(customContext, w, r) 25 | if err != nil { 26 | return 27 | } 28 | } 29 | handler(customContext, w, r) 30 | Log(customContext, w, r) 31 | } 32 | 33 | func Log(ctx *httpcontext.Context, w http.ResponseWriter, r *http.Request) error { 34 | elapsedTime := time.Since(ctx.StartTime) 35 | formattedTime := time.Now().Format("2006-01-02 15:04:05") 36 | fmt.Printf("[%s] [%s] [%s] [%s]\n", formattedTime, r.Method, r.URL.Path, elapsedTime) 37 | return nil 38 | } 39 | 40 | func IsNotGuest(ctx *httpcontext.Context, w http.ResponseWriter, r *http.Request) error { 41 | cookie, err := r.Cookie("session") 42 | if err != nil { 43 | return nil 44 | } 45 | if cookie.Value == os.Getenv("ADMIN_SESSION_TOKEN") { 46 | http.Redirect(w, r, "/admin", http.StatusSeeOther) 47 | } 48 | return nil 49 | } 50 | -------------------------------------------------------------------------------- /internal/route/route.go: -------------------------------------------------------------------------------- 1 | package route 2 | 3 | import ( 4 | "fmt" 5 | "go-quickstart/internal/handler" 6 | "go-quickstart/internal/middleware" 7 | "html/template" 8 | "net/http" 9 | ) 10 | 11 | type Router struct { 12 | Mux *http.ServeMux 13 | // Templates *template.Template 14 | } 15 | 16 | func NewRouter() (*Router, error) { 17 | // templates, err := templates.ParseTemplates() 18 | // if err != nil { 19 | // return nil, err 20 | 21 | // } 22 | return &Router{ 23 | Mux: http.NewServeMux(), 24 | // Templates: templates, 25 | }, nil 26 | } 27 | 28 | func (r *Router) Add(path string, handler handler.HandlerFunc, middleware ...middleware.MiddlewareFunc) { 29 | isIndex := false 30 | if path == "GET /" || path == "POST /" || path == "PUT /" || path == "DELETE /" || path == "PATCH /" || path == "OPTIONS /" || path == "HEAD /" { 31 | isIndex = true 32 | } 33 | route := &Route{ 34 | mux: r.Mux, 35 | path: path, 36 | handler: handler, 37 | // templates: r.Templates, 38 | middleware: middleware, 39 | isIndex: isIndex, 40 | } 41 | if isIndex { 42 | route.HandleIndex() 43 | return 44 | } 45 | route.Handle() 46 | } 47 | 48 | func (r *Router) Serve(port string, message string) { 49 | fmt.Println(message) 50 | http.ListenAndServe(":"+port, r.Mux) 51 | } 52 | 53 | type Route struct { 54 | mux *http.ServeMux 55 | path string 56 | handler handler.HandlerFunc 57 | templates *template.Template 58 | middleware []middleware.MiddlewareFunc 59 | isIndex bool 60 | } 61 | 62 | func (r *Route) Handle() { 63 | r.mux.HandleFunc(r.path, func(w http.ResponseWriter, req *http.Request) { 64 | middleware.Chain(w, req, r.templates, r.handler, r.middleware...) 65 | }) 66 | } 67 | 68 | func (r *Route) HandleIndex() { 69 | r.mux.HandleFunc(r.path, func(w http.ResponseWriter, req *http.Request) { 70 | if req.URL.Path != "/" { 71 | http.NotFound(w, req) 72 | return 73 | } 74 | middleware.Chain(w, req, r.templates, r.handler, r.middleware...) 75 | }) 76 | } 77 | -------------------------------------------------------------------------------- /internal/templates/templates.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | "bytes" 5 | "go-quickstart/internal/component" 6 | "html/template" 7 | "os" 8 | "path/filepath" 9 | ) 10 | 11 | type BasePageData struct { 12 | Title string 13 | Content template.HTML 14 | } 15 | 16 | func ParseTemplates() (*template.Template, error) { 17 | templates := template.New("") 18 | err := filepath.Walk("./html", func(path string, info os.FileInfo, err error) error { 19 | if err != nil { 20 | return err 21 | } 22 | if !info.IsDir() && filepath.Ext(path) == ".html" { 23 | _, err := templates.ParseFiles(path) 24 | if err != nil { 25 | return err 26 | } 27 | } 28 | return nil 29 | }) 30 | if err != nil { 31 | return nil, err 32 | } 33 | return templates, nil 34 | } 35 | 36 | func ExecuteTemplate(t *template.Template, name string, data interface{}) template.HTML { 37 | var templateContent bytes.Buffer 38 | err := t.ExecuteTemplate(&templateContent, name, data) 39 | if err != nil { 40 | panic(err) 41 | } 42 | return template.HTML(templateContent.String()) 43 | } 44 | 45 | const baseMeta = ` 46 | 47 | 48 | 49 | 50 | 51 | ` 52 | 53 | func Guest(title string, content string) string { 54 | return ` 55 | 56 | 57 | 58 | ` + baseMeta + ` 59 | ` + title + ` - CFA Suite 60 | 61 | 62 | ` + component.GuestHeader() + ` 63 |
64 | ` + content + ` 65 |
66 | 67 | 68 | ` 69 | } 70 | 71 | func Admin(title string, content string) string { 72 | return ` 73 | 74 | 75 | 76 | ` + baseMeta + ` 77 | ` + title + ` - CFA Suite 78 | 79 | 80 | ` + component.GuestHeader() + ` 81 |
82 | ` + content + ` 83 |
84 | 85 | 86 | ` 87 | } 88 | -------------------------------------------------------------------------------- /static/js/index.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | function qs(id) { 4 | return document.querySelector(id); 5 | } 6 | 7 | function qsa(id) { 8 | return document.querySelectorAll(id); 9 | } 10 | 11 | class Dom { 12 | 13 | static climbUntil(element, callback) { 14 | if (!element) { 15 | return qs('html') 16 | } 17 | if (callback(element) == true) { 18 | return element 19 | } 20 | return Dom.climbUntil(element.parentElement, callback) 21 | } 22 | 23 | static parseForm(form) { 24 | const formData = new FormData(form); 25 | const formObject = {}; 26 | formData.forEach((value, key) => { 27 | formObject[key] = value; 28 | }); 29 | let potentialErrElement = form.querySelector('.form-err'); 30 | if (potentialErrElement) { 31 | formObject['err'] = potentialErrElement; 32 | } 33 | return formObject; 34 | } 35 | 36 | } 37 | 38 | 39 | 40 | class Animus { 41 | 42 | static defaultDuration = 100; 43 | 44 | static fadeIn(element, duration=Animus.defaultDuration, targetOpacity=1, display='flex') { 45 | element.style.display = display; 46 | element.style.opacity = 0; 47 | let start = null; 48 | function step(timestamp) { 49 | if (!start) start = timestamp; 50 | let progress = timestamp - start; 51 | let opacity = progress / duration * targetOpacity; 52 | element.style.opacity = Math.min(opacity, targetOpacity); 53 | if (progress < duration) { 54 | window.requestAnimationFrame(step); 55 | } 56 | } 57 | window.requestAnimationFrame(step); 58 | } 59 | 60 | static fadeOut(element, duration=Animus.defaultDuration, targetOpacity=0) { 61 | element.style.opacity = 1; 62 | let start = null; 63 | function step(timestamp) { 64 | if (!start) start = timestamp; 65 | let progress = timestamp - start; 66 | let opacity = 1 - progress / duration * (1 - targetOpacity); 67 | element.style.opacity = Math.max(opacity, targetOpacity); 68 | if (progress < duration) { 69 | window.requestAnimationFrame(step); 70 | } 71 | // else { 72 | // if (targetOpacity === 0) { 73 | // element.style.display = 'none'; 74 | // } 75 | // } 76 | } 77 | window.requestAnimationFrame(step); 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /internal/component/component.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | "go-quickstart/internal/util" 5 | ) 6 | 7 | func GuestHeader() string { 8 | return ` 9 |
10 |
11 | logo 12 |
13 |
14 | ` 15 | } 16 | 17 | func BaseForm(method string, action string, content string) string { 18 | formId := util.RandStr(12) 19 | return ` 20 |
21 | ` + content + ` 22 |
23 | ` 24 | } 25 | 26 | func LoginForm(email string, password string, err string) string { 27 | return BaseForm("POST", "/", ` 28 | `+FormName("Login")+` 29 | `+FormErr(err)+` 30 | `+TextInput("Email", "email", email)+` 31 | `+TextInput("Password", "password", password)+` 32 | `+FormSubmit("Login")+` 33 | `) 34 | } 35 | 36 | func TextInput(label string, name string, value string) string { 37 | inputId := util.RandStr(12) 38 | return ` 39 |
40 | 41 | 42 |
43 | 67 | ` 68 | } 69 | 70 | func FormName(name string) string { 71 | return ` 72 |

` + name + `

73 | ` 74 | } 75 | 76 | func FormErr(err string) string { 77 | return ` 78 |

` + err + `

79 | ` 80 | 81 | } 82 | 83 | func FormSubmit(label string) string { 84 | btnId := util.RandStr(12) 85 | labelId := util.RandStr(12) 86 | loaderId := util.RandStr(12) 87 | btnOverlayId := util.RandStr(12) 88 | return ` 89 | 94 | 100 | ` 101 | } 102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-quickstart 2 | go-quickstart is a small layer over the standard http library in Go to make building routes and chaining middleware as simple as possible 3 | 4 | ## Requirements 5 | Go version 1.22.0 or greater required 6 | 7 | ### Tailwind 8 | This repo comes with a script to run tailwind at `/tailwind.sh` but you'll need to install the [tailwind binary](https://tailwindcss.com/blog/standalone-cli) and place it somewhere on your system's PATH. 9 | 10 | Make sure the binary is named, `tailwindcss`. 11 | 12 | ## Installation 13 | Clone the repo: 14 | ```bash 15 | git clone https://github.com/phillip-england/go-quickstart 16 | ``` 17 | 18 | ## Serving 19 | From the root of your app run: 20 | ```bash 21 | go run main.go 22 | ``` 23 | The application server on `localhost:8080` by default. This can be easliy changed in `main.go`. 24 | 25 | ## Features 26 | Here is an overview of the provided features. 27 | 28 | ### Router 29 | A router can be created: 30 | ```go 31 | r, err := route.NewRouter() 32 | if err != nil { 33 | panic(err) 34 | } 35 | ``` 36 | 37 | Routes can be added: 38 | ```go 39 | r.Add("GET /", handler.HandleHome) 40 | ``` 41 | 42 | Then serve your app: 43 | ```go 44 | port := "8080" 45 | r.Serve(port, fmt.Sprintf("Server is running on port %s", port)) 46 | ``` 47 | 48 | ### Handlers 49 | Here is a simple `Handler`: 50 | ```go 51 | func HandleHome(httpContext *httpcontext.Context, w http.ResponseWriter, r *http.Request) { 52 | err := httpContext.Templates.ExecuteTemplate(w, "base.html", templates.BasePageData{ 53 | Title: "Home", 54 | Content: templates.ExecuteTemplate(httpContext.Templates, "hello-world.html", nil), 55 | }) 56 | if err != nil { 57 | fmt.Println(err) 58 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 59 | return 60 | } 61 | } 62 | ``` 63 | 64 | It simply executes a few templates found in `/html` and checks for any errors. 65 | 66 | 67 | Handlers are functions with the following definition: 68 | ```go 69 | type HandlerFunc func(ctx *httpcontext.Context, w http.ResponseWriter, r *http.Request) 70 | ``` 71 | 72 | Notice how the `HandlerFunc` takes in a `*httpcontext.Context`? This just enables you to share data between your middleware and handler. 73 | 74 | To add more values to your context, simply update `./internal/httpcontext/httpcontext.go`: 75 | ```go 76 | type Context struct { 77 | context.Context 78 | Templates *template.Template 79 | StartTime time.Time 80 | NewContextValue string // new value added 81 | } 82 | ``` 83 | 84 | Now the `NewContextValue` can be set and shared amoung your middleware and handler. 85 | 86 | ### Middleware 87 | 88 | Middleware can be "chained" onto handlers: 89 | ```go 90 | func CustomMiddleware(ctx *httpcontext.Context, w http.ResponseWriter, r *http.Request) error { 91 | fmt.Println("Executing custom middleware") 92 | return nil 93 | } 94 | 95 | r.Add("GET /", handler.HandleHome, CustomMiddleware) // chaining middleware 96 | ``` 97 | 98 | You can even chain the same middleware multiple times: 99 | ```go 100 | r.Add("GET /", handler.HandleHome, CustomMiddleware, CustomMiddleware) 101 | ``` 102 | 103 | The definition for `MiddlewareFunc` is exactly like `HandlerFunc` except `MiddlewareFunc` can return an error: 104 | ```go 105 | type MiddlewareFunc func(ctx *httpcontext.Context, w http.ResponseWriter, r *http.Request) error 106 | ``` 107 | 108 | If a `MiddlewareFunc` returns an error, the request/response cycle will exit: 109 | ```go 110 | func ExitMiddleware(ctx *httpcontext.Context, w http.ResponseWriter, r *http.Request) error { 111 | w.Write([]byte("exiting from middleware\n")) 112 | return errors.New("exit!") 113 | } 114 | r.Add("GET /exit", handler.HandleHome, CustomMiddleware, ExitMiddleware) 115 | ``` -------------------------------------------------------------------------------- /static/css/output.css: -------------------------------------------------------------------------------- 1 | /* 2 | ! tailwindcss v3.4.3 | MIT License | https://tailwindcss.com 3 | */ 4 | 5 | /* 6 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 7 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) 8 | */ 9 | 10 | *, 11 | ::before, 12 | ::after { 13 | box-sizing: border-box; 14 | /* 1 */ 15 | border-width: 0; 16 | /* 2 */ 17 | border-style: solid; 18 | /* 2 */ 19 | border-color: #e5e7eb; 20 | /* 2 */ 21 | } 22 | 23 | ::before, 24 | ::after { 25 | --tw-content: ''; 26 | } 27 | 28 | /* 29 | 1. Use a consistent sensible line-height in all browsers. 30 | 2. Prevent adjustments of font size after orientation changes in iOS. 31 | 3. Use a more readable tab size. 32 | 4. Use the user's configured `sans` font-family by default. 33 | 5. Use the user's configured `sans` font-feature-settings by default. 34 | 6. Use the user's configured `sans` font-variation-settings by default. 35 | 7. Disable tap highlights on iOS 36 | */ 37 | 38 | html, 39 | :host { 40 | line-height: 1.5; 41 | /* 1 */ 42 | -webkit-text-size-adjust: 100%; 43 | /* 2 */ 44 | -moz-tab-size: 4; 45 | /* 3 */ 46 | -o-tab-size: 4; 47 | tab-size: 4; 48 | /* 3 */ 49 | font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 50 | /* 4 */ 51 | font-feature-settings: normal; 52 | /* 5 */ 53 | font-variation-settings: normal; 54 | /* 6 */ 55 | -webkit-tap-highlight-color: transparent; 56 | /* 7 */ 57 | } 58 | 59 | /* 60 | 1. Remove the margin in all browsers. 61 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. 62 | */ 63 | 64 | body { 65 | margin: 0; 66 | /* 1 */ 67 | line-height: inherit; 68 | /* 2 */ 69 | } 70 | 71 | /* 72 | 1. Add the correct height in Firefox. 73 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) 74 | 3. Ensure horizontal rules are visible by default. 75 | */ 76 | 77 | hr { 78 | height: 0; 79 | /* 1 */ 80 | color: inherit; 81 | /* 2 */ 82 | border-top-width: 1px; 83 | /* 3 */ 84 | } 85 | 86 | /* 87 | Add the correct text decoration in Chrome, Edge, and Safari. 88 | */ 89 | 90 | abbr:where([title]) { 91 | -webkit-text-decoration: underline dotted; 92 | text-decoration: underline dotted; 93 | } 94 | 95 | /* 96 | Remove the default font size and weight for headings. 97 | */ 98 | 99 | h1, 100 | h2, 101 | h3, 102 | h4, 103 | h5, 104 | h6 { 105 | font-size: inherit; 106 | font-weight: inherit; 107 | } 108 | 109 | /* 110 | Reset links to optimize for opt-in styling instead of opt-out. 111 | */ 112 | 113 | a { 114 | color: inherit; 115 | text-decoration: inherit; 116 | } 117 | 118 | /* 119 | Add the correct font weight in Edge and Safari. 120 | */ 121 | 122 | b, 123 | strong { 124 | font-weight: bolder; 125 | } 126 | 127 | /* 128 | 1. Use the user's configured `mono` font-family by default. 129 | 2. Use the user's configured `mono` font-feature-settings by default. 130 | 3. Use the user's configured `mono` font-variation-settings by default. 131 | 4. Correct the odd `em` font sizing in all browsers. 132 | */ 133 | 134 | code, 135 | kbd, 136 | samp, 137 | pre { 138 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 139 | /* 1 */ 140 | font-feature-settings: normal; 141 | /* 2 */ 142 | font-variation-settings: normal; 143 | /* 3 */ 144 | font-size: 1em; 145 | /* 4 */ 146 | } 147 | 148 | /* 149 | Add the correct font size in all browsers. 150 | */ 151 | 152 | small { 153 | font-size: 80%; 154 | } 155 | 156 | /* 157 | Prevent `sub` and `sup` elements from affecting the line height in all browsers. 158 | */ 159 | 160 | sub, 161 | sup { 162 | font-size: 75%; 163 | line-height: 0; 164 | position: relative; 165 | vertical-align: baseline; 166 | } 167 | 168 | sub { 169 | bottom: -0.25em; 170 | } 171 | 172 | sup { 173 | top: -0.5em; 174 | } 175 | 176 | /* 177 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) 178 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) 179 | 3. Remove gaps between table borders by default. 180 | */ 181 | 182 | table { 183 | text-indent: 0; 184 | /* 1 */ 185 | border-color: inherit; 186 | /* 2 */ 187 | border-collapse: collapse; 188 | /* 3 */ 189 | } 190 | 191 | /* 192 | 1. Change the font styles in all browsers. 193 | 2. Remove the margin in Firefox and Safari. 194 | 3. Remove default padding in all browsers. 195 | */ 196 | 197 | button, 198 | input, 199 | optgroup, 200 | select, 201 | textarea { 202 | font-family: inherit; 203 | /* 1 */ 204 | font-feature-settings: inherit; 205 | /* 1 */ 206 | font-variation-settings: inherit; 207 | /* 1 */ 208 | font-size: 100%; 209 | /* 1 */ 210 | font-weight: inherit; 211 | /* 1 */ 212 | line-height: inherit; 213 | /* 1 */ 214 | letter-spacing: inherit; 215 | /* 1 */ 216 | color: inherit; 217 | /* 1 */ 218 | margin: 0; 219 | /* 2 */ 220 | padding: 0; 221 | /* 3 */ 222 | } 223 | 224 | /* 225 | Remove the inheritance of text transform in Edge and Firefox. 226 | */ 227 | 228 | button, 229 | select { 230 | text-transform: none; 231 | } 232 | 233 | /* 234 | 1. Correct the inability to style clickable types in iOS and Safari. 235 | 2. Remove default button styles. 236 | */ 237 | 238 | button, 239 | input:where([type='button']), 240 | input:where([type='reset']), 241 | input:where([type='submit']) { 242 | -webkit-appearance: button; 243 | /* 1 */ 244 | background-color: transparent; 245 | /* 2 */ 246 | background-image: none; 247 | /* 2 */ 248 | } 249 | 250 | /* 251 | Use the modern Firefox focus style for all focusable elements. 252 | */ 253 | 254 | :-moz-focusring { 255 | outline: auto; 256 | } 257 | 258 | /* 259 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) 260 | */ 261 | 262 | :-moz-ui-invalid { 263 | box-shadow: none; 264 | } 265 | 266 | /* 267 | Add the correct vertical alignment in Chrome and Firefox. 268 | */ 269 | 270 | progress { 271 | vertical-align: baseline; 272 | } 273 | 274 | /* 275 | Correct the cursor style of increment and decrement buttons in Safari. 276 | */ 277 | 278 | ::-webkit-inner-spin-button, 279 | ::-webkit-outer-spin-button { 280 | height: auto; 281 | } 282 | 283 | /* 284 | 1. Correct the odd appearance in Chrome and Safari. 285 | 2. Correct the outline style in Safari. 286 | */ 287 | 288 | [type='search'] { 289 | -webkit-appearance: textfield; 290 | /* 1 */ 291 | outline-offset: -2px; 292 | /* 2 */ 293 | } 294 | 295 | /* 296 | Remove the inner padding in Chrome and Safari on macOS. 297 | */ 298 | 299 | ::-webkit-search-decoration { 300 | -webkit-appearance: none; 301 | } 302 | 303 | /* 304 | 1. Correct the inability to style clickable types in iOS and Safari. 305 | 2. Change font properties to `inherit` in Safari. 306 | */ 307 | 308 | ::-webkit-file-upload-button { 309 | -webkit-appearance: button; 310 | /* 1 */ 311 | font: inherit; 312 | /* 2 */ 313 | } 314 | 315 | /* 316 | Add the correct display in Chrome and Safari. 317 | */ 318 | 319 | summary { 320 | display: list-item; 321 | } 322 | 323 | /* 324 | Removes the default spacing and border for appropriate elements. 325 | */ 326 | 327 | blockquote, 328 | dl, 329 | dd, 330 | h1, 331 | h2, 332 | h3, 333 | h4, 334 | h5, 335 | h6, 336 | hr, 337 | figure, 338 | p, 339 | pre { 340 | margin: 0; 341 | } 342 | 343 | fieldset { 344 | margin: 0; 345 | padding: 0; 346 | } 347 | 348 | legend { 349 | padding: 0; 350 | } 351 | 352 | ol, 353 | ul, 354 | menu { 355 | list-style: none; 356 | margin: 0; 357 | padding: 0; 358 | } 359 | 360 | /* 361 | Reset default styling for dialogs. 362 | */ 363 | 364 | dialog { 365 | padding: 0; 366 | } 367 | 368 | /* 369 | Prevent resizing textareas horizontally by default. 370 | */ 371 | 372 | textarea { 373 | resize: vertical; 374 | } 375 | 376 | /* 377 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) 378 | 2. Set the default placeholder color to the user's configured gray 400 color. 379 | */ 380 | 381 | input::-moz-placeholder, textarea::-moz-placeholder { 382 | opacity: 1; 383 | /* 1 */ 384 | color: #9ca3af; 385 | /* 2 */ 386 | } 387 | 388 | input::placeholder, 389 | textarea::placeholder { 390 | opacity: 1; 391 | /* 1 */ 392 | color: #9ca3af; 393 | /* 2 */ 394 | } 395 | 396 | /* 397 | Set the default cursor for buttons. 398 | */ 399 | 400 | button, 401 | [role="button"] { 402 | cursor: pointer; 403 | } 404 | 405 | /* 406 | Make sure disabled buttons don't get the pointer cursor. 407 | */ 408 | 409 | :disabled { 410 | cursor: default; 411 | } 412 | 413 | /* 414 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) 415 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) 416 | This can trigger a poorly considered lint error in some tools but is included by design. 417 | */ 418 | 419 | img, 420 | svg, 421 | video, 422 | canvas, 423 | audio, 424 | iframe, 425 | embed, 426 | object { 427 | display: block; 428 | /* 1 */ 429 | vertical-align: middle; 430 | /* 2 */ 431 | } 432 | 433 | /* 434 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) 435 | */ 436 | 437 | img, 438 | video { 439 | max-width: 100%; 440 | height: auto; 441 | } 442 | 443 | /* Make elements with the HTML hidden attribute stay hidden by default */ 444 | 445 | [hidden] { 446 | display: none; 447 | } 448 | 449 | *, ::before, ::after { 450 | --tw-border-spacing-x: 0; 451 | --tw-border-spacing-y: 0; 452 | --tw-translate-x: 0; 453 | --tw-translate-y: 0; 454 | --tw-rotate: 0; 455 | --tw-skew-x: 0; 456 | --tw-skew-y: 0; 457 | --tw-scale-x: 1; 458 | --tw-scale-y: 1; 459 | --tw-pan-x: ; 460 | --tw-pan-y: ; 461 | --tw-pinch-zoom: ; 462 | --tw-scroll-snap-strictness: proximity; 463 | --tw-gradient-from-position: ; 464 | --tw-gradient-via-position: ; 465 | --tw-gradient-to-position: ; 466 | --tw-ordinal: ; 467 | --tw-slashed-zero: ; 468 | --tw-numeric-figure: ; 469 | --tw-numeric-spacing: ; 470 | --tw-numeric-fraction: ; 471 | --tw-ring-inset: ; 472 | --tw-ring-offset-width: 0px; 473 | --tw-ring-offset-color: #fff; 474 | --tw-ring-color: rgb(59 130 246 / 0.5); 475 | --tw-ring-offset-shadow: 0 0 #0000; 476 | --tw-ring-shadow: 0 0 #0000; 477 | --tw-shadow: 0 0 #0000; 478 | --tw-shadow-colored: 0 0 #0000; 479 | --tw-blur: ; 480 | --tw-brightness: ; 481 | --tw-contrast: ; 482 | --tw-grayscale: ; 483 | --tw-hue-rotate: ; 484 | --tw-invert: ; 485 | --tw-saturate: ; 486 | --tw-sepia: ; 487 | --tw-drop-shadow: ; 488 | --tw-backdrop-blur: ; 489 | --tw-backdrop-brightness: ; 490 | --tw-backdrop-contrast: ; 491 | --tw-backdrop-grayscale: ; 492 | --tw-backdrop-hue-rotate: ; 493 | --tw-backdrop-invert: ; 494 | --tw-backdrop-opacity: ; 495 | --tw-backdrop-saturate: ; 496 | --tw-backdrop-sepia: ; 497 | --tw-contain-size: ; 498 | --tw-contain-layout: ; 499 | --tw-contain-paint: ; 500 | --tw-contain-style: ; 501 | } 502 | 503 | ::backdrop { 504 | --tw-border-spacing-x: 0; 505 | --tw-border-spacing-y: 0; 506 | --tw-translate-x: 0; 507 | --tw-translate-y: 0; 508 | --tw-rotate: 0; 509 | --tw-skew-x: 0; 510 | --tw-skew-y: 0; 511 | --tw-scale-x: 1; 512 | --tw-scale-y: 1; 513 | --tw-pan-x: ; 514 | --tw-pan-y: ; 515 | --tw-pinch-zoom: ; 516 | --tw-scroll-snap-strictness: proximity; 517 | --tw-gradient-from-position: ; 518 | --tw-gradient-via-position: ; 519 | --tw-gradient-to-position: ; 520 | --tw-ordinal: ; 521 | --tw-slashed-zero: ; 522 | --tw-numeric-figure: ; 523 | --tw-numeric-spacing: ; 524 | --tw-numeric-fraction: ; 525 | --tw-ring-inset: ; 526 | --tw-ring-offset-width: 0px; 527 | --tw-ring-offset-color: #fff; 528 | --tw-ring-color: rgb(59 130 246 / 0.5); 529 | --tw-ring-offset-shadow: 0 0 #0000; 530 | --tw-ring-shadow: 0 0 #0000; 531 | --tw-shadow: 0 0 #0000; 532 | --tw-shadow-colored: 0 0 #0000; 533 | --tw-blur: ; 534 | --tw-brightness: ; 535 | --tw-contrast: ; 536 | --tw-grayscale: ; 537 | --tw-hue-rotate: ; 538 | --tw-invert: ; 539 | --tw-saturate: ; 540 | --tw-sepia: ; 541 | --tw-drop-shadow: ; 542 | --tw-backdrop-blur: ; 543 | --tw-backdrop-brightness: ; 544 | --tw-backdrop-contrast: ; 545 | --tw-backdrop-grayscale: ; 546 | --tw-backdrop-hue-rotate: ; 547 | --tw-backdrop-invert: ; 548 | --tw-backdrop-opacity: ; 549 | --tw-backdrop-saturate: ; 550 | --tw-backdrop-sepia: ; 551 | --tw-contain-size: ; 552 | --tw-contain-layout: ; 553 | --tw-contain-paint: ; 554 | --tw-contain-style: ; 555 | } 556 | 557 | .static { 558 | position: static; 559 | } 560 | 561 | .absolute { 562 | position: absolute; 563 | } 564 | 565 | .relative { 566 | position: relative; 567 | } 568 | 569 | .left-0 { 570 | left: 0px; 571 | } 572 | 573 | .right-2 { 574 | right: 0.5rem; 575 | } 576 | 577 | .top-0 { 578 | top: 0px; 579 | } 580 | 581 | .z-10 { 582 | z-index: 10; 583 | } 584 | 585 | .z-20 { 586 | z-index: 20; 587 | } 588 | 589 | .flex { 590 | display: flex; 591 | } 592 | 593 | .table { 594 | display: table; 595 | } 596 | 597 | .h-5 { 598 | height: 1.25rem; 599 | } 600 | 601 | .h-full { 602 | height: 100%; 603 | } 604 | 605 | .w-5 { 606 | width: 1.25rem; 607 | } 608 | 609 | .w-\[150px\] { 610 | width: 150px; 611 | } 612 | 613 | .w-full { 614 | width: 100%; 615 | } 616 | 617 | @keyframes spin { 618 | to { 619 | transform: rotate(360deg); 620 | } 621 | } 622 | 623 | .animate-spin { 624 | animation: spin 1s linear infinite; 625 | } 626 | 627 | .flex-row { 628 | flex-direction: row; 629 | } 630 | 631 | .flex-col { 632 | flex-direction: column; 633 | } 634 | 635 | .items-center { 636 | align-items: center; 637 | } 638 | 639 | .gap-2 { 640 | gap: 0.5rem; 641 | } 642 | 643 | .gap-8 { 644 | gap: 2rem; 645 | } 646 | 647 | .rounded { 648 | border-radius: 0.25rem; 649 | } 650 | 651 | .rounded-full { 652 | border-radius: 9999px; 653 | } 654 | 655 | .border { 656 | border-width: 1px; 657 | } 658 | 659 | .border-b { 660 | border-bottom-width: 1px; 661 | } 662 | 663 | .border-gray-500 { 664 | --tw-border-opacity: 1; 665 | border-color: rgb(153 153 153 / var(--tw-border-opacity)); 666 | } 667 | 668 | .border-primary { 669 | --tw-border-opacity: 1; 670 | border-color: rgb(229 22 54 / var(--tw-border-opacity)); 671 | } 672 | 673 | .border-t-white { 674 | --tw-border-opacity: 1; 675 | border-top-color: rgb(255 255 255 / var(--tw-border-opacity)); 676 | } 677 | 678 | .bg-black { 679 | --tw-bg-opacity: 1; 680 | background-color: rgb(0 0 0 / var(--tw-bg-opacity)); 681 | } 682 | 683 | .bg-primary { 684 | --tw-bg-opacity: 1; 685 | background-color: rgb(229 22 54 / var(--tw-bg-opacity)); 686 | } 687 | 688 | .p-1 { 689 | padding: 0.25rem; 690 | } 691 | 692 | .p-2 { 693 | padding: 0.5rem; 694 | } 695 | 696 | .p-4 { 697 | padding: 1rem; 698 | } 699 | 700 | .p-6 { 701 | padding: 1.5rem; 702 | } 703 | 704 | .text-2xl { 705 | font-size: 1.5rem; 706 | line-height: 2rem; 707 | } 708 | 709 | .text-sm { 710 | font-size: 0.875rem; 711 | line-height: 1.25rem; 712 | } 713 | 714 | .font-semibold { 715 | font-weight: 600; 716 | } 717 | 718 | .uppercase { 719 | text-transform: uppercase; 720 | } 721 | 722 | .lowercase { 723 | text-transform: lowercase; 724 | } 725 | 726 | .text-\[\#8B0000\] { 727 | --tw-text-opacity: 1; 728 | color: rgb(139 0 0 / var(--tw-text-opacity)); 729 | } 730 | 731 | .text-white { 732 | --tw-text-opacity: 1; 733 | color: rgb(255 255 255 / var(--tw-text-opacity)); 734 | } 735 | 736 | .opacity-0 { 737 | opacity: 0; 738 | } 739 | 740 | .blur { 741 | --tw-blur: blur(8px); 742 | filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); 743 | } 744 | 745 | .transition { 746 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; 747 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; 748 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter; 749 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 750 | transition-duration: 150ms; 751 | } 752 | 753 | .duration-200 { 754 | transition-duration: 200ms; 755 | } 756 | 757 | .focus\:outline-none:focus { 758 | outline: 2px solid transparent; 759 | outline-offset: 2px; 760 | } -------------------------------------------------------------------------------- /static/img/logo.svg: -------------------------------------------------------------------------------- 1 | --------------------------------------------------------------------------------