├── 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 |

12 |
13 |
14 | `
15 | }
16 |
17 | func BaseForm(method string, action string, content string) string {
18 | formId := util.RandStr(12)
19 | return `
20 |
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 |
--------------------------------------------------------------------------------