From a8862721cd50d12e4a4d75429663cc358b4de3da Mon Sep 17 00:00:00 2001 From: Jason Hilder Date: Thu, 23 Apr 2026 08:09:09 +0200 Subject: [PATCH] Initial project structure commit. Static directory for public folders and business logic for the app within internal, split into domain specific folders to keep clear seperation of concerns. --- .gitignore | 2 + cmd/main.go | 86 +++++ internal/db/db.go | 32 ++ internal/db/models.go | 11 + internal/db/querier.go | 19 ++ internal/db/queries.sql.go | 96 ++++++ internal/db/store.go | 19 ++ internal/templates/layouts/base.html | 33 ++ internal/templates/pages/home.html | 5 + internal/web/handlers_public.go | 14 + internal/web/handlers_user.go | 1 + internal/web/routes.go | 28 ++ internal/web/server.go | 35 ++ internal/web/utils.go | 32 ++ static/css/app.css | 0 static/css/index.css | 467 +++++++++++++++++++++++++++ static/css/reset.css | 48 +++ static/js/index.js | 120 +++++++ 18 files changed, 1048 insertions(+) create mode 100644 cmd/main.go create mode 100644 internal/db/db.go create mode 100644 internal/db/models.go create mode 100644 internal/db/querier.go create mode 100644 internal/db/queries.sql.go create mode 100644 internal/db/store.go create mode 100644 internal/templates/layouts/base.html create mode 100644 internal/templates/pages/home.html create mode 100644 internal/web/handlers_public.go create mode 100644 internal/web/handlers_user.go create mode 100644 internal/web/routes.go create mode 100644 internal/web/server.go create mode 100644 internal/web/utils.go create mode 100644 static/css/app.css create mode 100644 static/css/index.css create mode 100644 static/css/reset.css create mode 100644 static/js/index.js diff --git a/.gitignore b/.gitignore index adf8f72..494ed7a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ # Go workspace file go.work +# direnv files +.envrc diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..688bc86 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,86 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "nfeeder/internal/db" + "nfeeder/internal/web" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func main() { + ctx, ctxCancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer ctxCancel() + + // DB init + // ------------------------------------------------------------ + connStr := os.Getenv("DATABASE_URL") + if connStr == "" { + log.Fatal("DATABASE_URL environment variable is not set") + } + + poolConfig, err := pgxpool.ParseConfig(connStr) + if err != nil { + log.Fatalf("Unable to parse DATABASE_URL: %v", err) + } + + poolConfig.MaxConns = 25 + poolConfig.MinConns = 5 + poolConfig.MaxConnLifetime = 5 * time.Minute + poolConfig.MaxConnIdleTime = 30 * time.Second + + pool, err := pgxpool.NewWithConfig(ctx, poolConfig) + if err != nil { + log.Fatalf("Unable to create connection pool: %v", err) + } + + // Verify the connection is alive before proceeding + if err := pool.Ping(ctx); err != nil { + log.Fatalf("Unable to reach database: %v", err) + } + fmt.Println("Database connection established") + + defer func() { + pool.Close() + fmt.Println("Database pool closed") + }() + + // Create Store sqlc wrapper + store := db.NewStore(pool) + + // Server Init + // ------------------------------------------------------------ + server := web.NewServer(store) + + // We run it in a goroutine so it doesn't block main from reaching the signal listener. + go func() { + addr := ":3000" + fmt.Printf("Server starting on %s\n", addr) + + if err := server.Start(addr); err != nil && err != http.ErrServerClosed { + log.Fatalf("Server failed: %v", err) + } + }() + + // Graceful Shutdown + // ------------------------------------------------------------ + <-ctx.Done() + fmt.Println("\nShutdown signal received. Starting graceful exit...") + + shutdownCtx, shutdownCtxCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownCtxCancel() + + if err := server.Shutdown(shutdownCtx); err != nil { + log.Fatalf("Graceful shutdown failed: %v", err) + } + + fmt.Println("Server exited properly") +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..9d485b5 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/db/models.go b/internal/db/models.go new file mode 100644 index 0000000..3e12d39 --- /dev/null +++ b/internal/db/models.go @@ -0,0 +1,11 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package db + +type User struct { + ID int64 `json:"id"` + Name string `json:"name"` + Age int32 `json:"age"` +} diff --git a/internal/db/querier.go b/internal/db/querier.go new file mode 100644 index 0000000..28ff344 --- /dev/null +++ b/internal/db/querier.go @@ -0,0 +1,19 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package db + +import ( + "context" +) + +type Querier interface { + CreateUser(ctx context.Context, arg CreateUserParams) (User, error) + DeleteUser(ctx context.Context, id int64) error + GetUser(ctx context.Context, id int64) (User, error) + ListUsers(ctx context.Context) ([]User, error) + UpdateUser(ctx context.Context, arg UpdateUserParams) error +} + +var _ Querier = (*Queries)(nil) diff --git a/internal/db/queries.sql.go b/internal/db/queries.sql.go new file mode 100644 index 0000000..d750ae0 --- /dev/null +++ b/internal/db/queries.sql.go @@ -0,0 +1,96 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: queries.sql + +package db + +import ( + "context" +) + +const createUser = `-- name: CreateUser :one +INSERT INTO users ( + name, age +) VALUES ( + $1, $2 +) +RETURNING id, name, age +` + +type CreateUserParams struct { + Name string `json:"name"` + Age int32 `json:"age"` +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { + row := q.db.QueryRow(ctx, createUser, arg.Name, arg.Age) + var i User + err := row.Scan(&i.ID, &i.Name, &i.Age) + return i, err +} + +const deleteUser = `-- name: DeleteUser :exec +DELETE FROM users +WHERE id = $1 +` + +func (q *Queries) DeleteUser(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, deleteUser, id) + return err +} + +const getUser = `-- name: GetUser :one +SELECT id, name, age FROM users +WHERE id = $1 LIMIT 1 +` + +func (q *Queries) GetUser(ctx context.Context, id int64) (User, error) { + row := q.db.QueryRow(ctx, getUser, id) + var i User + err := row.Scan(&i.ID, &i.Name, &i.Age) + return i, err +} + +const listUsers = `-- name: ListUsers :many +SELECT id, name, age FROM users +ORDER BY name +` + +func (q *Queries) ListUsers(ctx context.Context) ([]User, error) { + rows, err := q.db.Query(ctx, listUsers) + if err != nil { + return nil, err + } + defer rows.Close() + var items []User + for rows.Next() { + var i User + if err := rows.Scan(&i.ID, &i.Name, &i.Age); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateUser = `-- name: UpdateUser :exec +UPDATE users + set name = $2, + age = $3 +WHERE id = $1 +` + +type UpdateUserParams struct { + ID int64 `json:"id"` + Name string `json:"name"` + Age int32 `json:"age"` +} + +func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) error { + _, err := q.db.Exec(ctx, updateUser, arg.ID, arg.Name, arg.Age) + return err +} diff --git a/internal/db/store.go b/internal/db/store.go new file mode 100644 index 0000000..afc51f8 --- /dev/null +++ b/internal/db/store.go @@ -0,0 +1,19 @@ +package db + +import ( + "github.com/jackc/pgx/v5/pgxpool" +) + +type Store struct { + // embedded field, for easier access ie store.GetUser + *Queries + // named field for explicit calles ie db.Begin + db *pgxpool.Pool +} + +func NewStore(db *pgxpool.Pool) *Store { + return &Store{ + Queries: New(db), + db: db, + } +} diff --git a/internal/templates/layouts/base.html b/internal/templates/layouts/base.html new file mode 100644 index 0000000..8741ba8 --- /dev/null +++ b/internal/templates/layouts/base.html @@ -0,0 +1,33 @@ +{{define "base"}} + + + + + + {{.Title}} + + + + + + +
+ +
+ +
+ {{template "content" .}} +
+ + + + + + +{{end}} diff --git a/internal/templates/pages/home.html b/internal/templates/pages/home.html new file mode 100644 index 0000000..0f19719 --- /dev/null +++ b/internal/templates/pages/home.html @@ -0,0 +1,5 @@ +{{define "content"}} +
+

NFeeder

+
+{{end}} diff --git a/internal/web/handlers_public.go b/internal/web/handlers_public.go new file mode 100644 index 0000000..b0ca5d3 --- /dev/null +++ b/internal/web/handlers_public.go @@ -0,0 +1,14 @@ +package web + +import "net/http" + +type HomeData struct { + Title string +} + +func (s *Server) handleHome() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + data := HomeData{Title: "NFeeder"} + view(w, r, "home", data) + } +} diff --git a/internal/web/handlers_user.go b/internal/web/handlers_user.go new file mode 100644 index 0000000..efb3895 --- /dev/null +++ b/internal/web/handlers_user.go @@ -0,0 +1 @@ +package web diff --git a/internal/web/routes.go b/internal/web/routes.go new file mode 100644 index 0000000..e620e29 --- /dev/null +++ b/internal/web/routes.go @@ -0,0 +1,28 @@ +package web + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +func (s *Server) setupRoutes() *chi.Mux { + router := chi.NewRouter() + + router.Use(middleware.Logger) + router.Use(middleware.Recoverer) + + // Setup basic file server nothing fancy + router.Handle("/static/*", http.StripPrefix("/static", http.FileServer(http.Dir("static")))) + + // Public routes + router.Group(func(r chi.Router) { + r.Get("/", s.handleHome()) + }) + + //s.router.Get("/", s.handleIndex()) + //s.router.Post("/users", s.handleCreateUser()) + + return router +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..e2d59d1 --- /dev/null +++ b/internal/web/server.go @@ -0,0 +1,35 @@ +package web + +import ( + "context" + "net/http" + + "nfeeder/internal/db" +) + +type Server struct { + httpServer *http.Server + store *db.Store +} + +func NewServer(store *db.Store) *Server { + s := &Server { + store: store, + } + + s.httpServer = &http.Server { + Handler: s.setupRoutes(), + } + + return s +} + +func (s *Server) Start(addr string) error { + s.httpServer.Addr = addr + return s.httpServer.ListenAndServe() +} + +func (s *Server) Shutdown(ctx context.Context) error { + return s.httpServer.Shutdown(ctx) +} + diff --git a/internal/web/utils.go b/internal/web/utils.go new file mode 100644 index 0000000..6f10d9a --- /dev/null +++ b/internal/web/utils.go @@ -0,0 +1,32 @@ +package web + +import ( + "html/template" + "net/http" + "path/filepath" +) + +// Renders a full page by combining the base template with a page template +// Parsed together so the page can define blocks needed for base template +func render(w http.ResponseWriter, _ *http.Request, page string, data any) { + files := []string{ + filepath.Join("internal", "templates", "layouts", "base.html"), + filepath.Join("internal", "templates", "pages", page+".html"), + } + + tmpl, err := template.ParseFiles(files...) + if err != nil { + http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html") + + if err := tmpl.ExecuteTemplate(w, "base", data); err != nil { + http.Error(w, "render error: "+err.Error(), http.StatusInternalServerError) + } +} + +func view(w http.ResponseWriter, r *http.Request, page string, data any) { + render(w, r, page, data) +} diff --git a/static/css/app.css b/static/css/app.css new file mode 100644 index 0000000..e69de29 diff --git a/static/css/index.css b/static/css/index.css new file mode 100644 index 0000000..8f4f0ce --- /dev/null +++ b/static/css/index.css @@ -0,0 +1,467 @@ +/** + * By Oskar Wickström + * Licensed under the MIT License (https://github.com/owickstrom/the-monospace-web/blob/main/LICENSE.md) + **/ +@import url('https://fonts.cdnfonts.com/css/jetbrains-mono-2'); + +:root { + --font-family: "JetBrains Mono", monospace; + --line-height: 1.20rem; + --border-thickness: 2px; + --text-color: #000; + --text-color-alt: #666; + --background-color: #fff; + --background-color-alt: #eee; + + --font-weight-normal: 500; + --font-weight-medium: 600; + --font-weight-bold: 800; + + font-family: var(--font-family); + font-optical-sizing: auto; + font-weight: var(--font-weight-normal); + font-style: normal; + font-variant-numeric: tabular-nums lining-nums; + font-size: 18px; +} + +@media (prefers-color-scheme: dark) { + :root { + --text-color: #fff; + --text-color-alt: #aaa; + --background-color: #000; + --background-color-alt: #111; + } +} + +* { + box-sizing: border-box; +} + + +* + * { + margin-top: var(--line-height); +} + +html { + display: flex; + width: 100%; + margin: 0; + padding: 0; + flex-direction: column; + align-items: center; + background: var(--background-color); + color: var(--text-color); +} + +body { + position: relative; + width: 100%; + margin: 0; + padding: var(--line-height) 2ch; + max-width: calc(min(80ch, round(down, 100%, 1ch))); + line-height: var(--line-height); + overflow-x: hidden; +} + +@media screen and (max-width: 480px) { + :root { + font-size: 14px; + } + body { + padding: var(--line-height) 1ch; + } +} + +h1, h2, h3, h4, h5, h6 { + font-weight: var(--font-weight-bold); + margin: calc(var(--line-height) * 2) 0 var(--line-height); + line-height: var(--line-height); +} + +h1 { + font-size: 2rem; + line-height: calc(2 * var(--line-height)); + margin-bottom: calc(var(--line-height) * 2); + text-transform: uppercase; +} +h2 { + font-size: 1rem; + text-transform: uppercase; +} + +hr { + position: relative; + display: block; + height: var(--line-height); + margin: calc(var(--line-height) * 1.5) 0; + border: none; + color: var(--text-color); +} +hr:after { + display: block; + content: ""; + position: absolute; + top: calc(var(--line-height) / 2 - var(--border-thickness)); + left: 0; + width: 100%; + border-top: calc(var(--border-thickness) * 3) double var(--text-color); + height: 0; +} + +a { + text-decoration-thickness: var(--border-thickness); +} + +a:link, a:visited { + color: var(--text-color); +} + +p { + margin-bottom: var(--line-height); +} + +strong { + font-weight: var(--font-weight-bold); +} +em { + font-style: italic; +} + +sub { + position: relative; + display: inline-block; + margin: 0; + vertical-align: sub; + line-height: 0; + width: calc(1ch / 0.75); + font-size: .75rem; +} + +table { + position: relative; + top: calc(var(--line-height) / 2); + width: calc(round(down, 100%, 1ch)); + border-collapse: collapse; + margin: 0 0 calc(var(--line-height) * 2); +} + +th, td { + border: var(--border-thickness) solid var(--text-color); + padding: + calc((var(--line-height) / 2)) + calc(1ch - var(--border-thickness) / 2) + calc((var(--line-height) / 2) - (var(--border-thickness))) + ; + line-height: var(--line-height); + vertical-align: top; + text-align: left; +} +table tbody tr:first-child > * { + padding-top: calc((var(--line-height) / 2) - var(--border-thickness)); +} + + +th { + font-weight: 700; +} +.width-min { + width: 0%; +} +.width-auto { + width: 100%; +} + +.header { + margin-bottom: calc(var(--line-height) * 2); +} +.header h1 { + margin: 0; +} +.header tr td:last-child { + text-align: right; +} + +p { + word-break: break-word; + word-wrap: break-word; + hyphens: auto; +} + +img, video { + display: block; + width: 100%; + object-fit: contain; + overflow: hidden; +} +img { + font-style: italic; + color: var(--text-color-alt); +} + +details { + border: var(--border-thickness) solid var(--text-color); + padding: calc(var(--line-height) - var(--border-thickness)) 1ch; + margin-bottom: var(--line-height); +} + +summary { + font-weight: var(--font-weight-medium); + cursor: pointer; +} +details[open] summary { + margin-bottom: var(--line-height); +} + +details ::marker { + display: inline-block; + content: '▶'; + margin: 0; +} +details[open] ::marker { + content: '▼'; +} + +details :last-child { + margin-bottom: 0; +} + +pre { + white-space: pre; + overflow-x: auto; + margin: var(--line-height) 0; + overflow-y: hidden; +} +figure pre { + margin: 0; +} + +pre, code { + font-family: var(--font-family); +} + +code { + font-weight: var(--font-weight-medium); +} + +figure { + margin: calc(var(--line-height) * 2) 3ch; + overflow-x: auto; + overflow-y: hidden; +} + +figcaption { + display: block; + font-style: italic; + margin-top: var(--line-height); +} + +ul, ol { + padding: 0; + margin: 0 0 var(--line-height); +} + +ul { + list-style-type: square; + padding: 0 0 0 2ch; +} +ol { + list-style-type: none; + counter-reset: item; + padding: 0; +} +ol ul, +ol ol, +ul ol, +ul ul { + padding: 0 0 0 3ch; + margin: 0; +} +ol li:before { + content: counters(item, ".") ". "; + counter-increment: item; + font-weight: var(--font-weight-medium); +} + +li { + margin: 0; + padding: 0; +} + +li::marker { + line-height: 0; +} + +::-webkit-scrollbar { + height: var(--line-height); +} + +input, button, textarea { + border: var(--border-thickness) solid var(--text-color); + padding: + calc(var(--line-height) / 2 - var(--border-thickness)) + calc(1ch - var(--border-thickness)); + margin: 0; + font: inherit; + font-weight: inherit; + height: calc(var(--line-height) * 2); + width: auto; + overflow: visible; + background: var(--background-color); + color: var(--text-color); + line-height: normal; + -webkit-font-smoothing: inherit; + -moz-osx-font-smoothing: inherit; + -webkit-appearance: none; +} + +input[type=checkbox], +input[type=radio] { + display: inline-grid; + place-content: center; + vertical-align: top; + width: 2ch; + height: var(--line-height); + cursor: pointer; +} +input[type=checkbox]:checked:before, +input[type=radio]:checked:before { + content: ""; + width: 1ch; + height: calc(var(--line-height) / 2); + background: var(--text-color); +} +input[type=radio], +input[type=radio]:before { + border-radius: 100%; +} + +button:focus, input:focus { + --border-thickness: 3px; + outline: none; +} + +input { + width: calc(round(down, 100%, 1ch)); +} +::placeholder { + color: var(--text-color-alt); + opacity: 1; +} +::-ms-input-placeholder { + color: var(--text-color-alt); +} +button::-moz-focus-inner { + padding: 0; + border: 0 +} + +button { + text-transform: uppercase; + font-weight: var(--font-weight-medium); + cursor: pointer; +} + +button:hover { + background: var(--background-color-alt); +} +button:active { + transform: translate(2px, 2px); +} + +label { + display: block; + width: calc(round(down, 100%, 1ch)); + height: auto; + line-height: var(--line-height); + font-weight: var(--font-weight-medium); + margin: 0; +} + +label input { + width: 100%; +} + +.tree, .tree ul { + position: relative; + padding-left: 0; + list-style-type: none; + line-height: var(--line-height); +} +.tree ul { + margin: 0; +} +.tree ul li { + position: relative; + padding-left: 1.5ch; + margin-left: 1.5ch; + border-left: var(--border-thickness) solid var(--text-color); +} +.tree ul li:before { + position: absolute; + display: block; + top: calc(var(--line-height) / 2); + left: 0; + content: ""; + width: 1ch; + border-bottom: var(--border-thickness) solid var(--text-color); +} +.tree ul li:last-child { + border-left: none; +} +.tree ul li:last-child:after { + position: absolute; + display: block; + top: 0; + left: 0; + content: ""; + height: calc(var(--line-height) / 2); + border-left: var(--border-thickness) solid var(--text-color); +} + +.grid { + --grid-cells: 0; + display: flex; + gap: 1ch; + width: calc(round(down, 100%, (1ch * var(--grid-cells)) - (1ch * var(--grid-cells) - 1))); + margin-bottom: var(--line-height); +} + +.grid > *, +.grid > input { + flex: 0 0 calc(round(down, (100% - (1ch * (var(--grid-cells) - 1))) / var(--grid-cells), 1ch)); +} +.grid:has(> :last-child:nth-child(1)) { --grid-cells: 1; } +.grid:has(> :last-child:nth-child(2)) { --grid-cells: 2; } +.grid:has(> :last-child:nth-child(3)) { --grid-cells: 3; } +.grid:has(> :last-child:nth-child(4)) { --grid-cells: 4; } +.grid:has(> :last-child:nth-child(5)) { --grid-cells: 5; } +.grid:has(> :last-child:nth-child(6)) { --grid-cells: 6; } +.grid:has(> :last-child:nth-child(7)) { --grid-cells: 7; } +.grid:has(> :last-child:nth-child(8)) { --grid-cells: 8; } +.grid:has(> :last-child:nth-child(9)) { --grid-cells: 9; } + +/* DEBUG UTILITIES */ + +.debug .debug-grid { + --color: color-mix(in srgb, var(--text-color) 10%, var(--background-color) 90%); + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: -1; + background-image: + repeating-linear-gradient(var(--color) 0 1px, transparent 1px 100%), + repeating-linear-gradient(90deg, var(--color) 0 1px, transparent 1px 100%); + background-size: 1ch var(--line-height); + margin: 0; +} + +.debug .off-grid { + background: rgba(255, 0, 0, 0.1); +} + +.debug-toggle-label { + text-align: right; +} diff --git a/static/css/reset.css b/static/css/reset.css new file mode 100644 index 0000000..aabd759 --- /dev/null +++ b/static/css/reset.css @@ -0,0 +1,48 @@ +/* http://meyerweb.com/eric/tools/css/reset/ + v2.0 | 20110126 + License: none (public domain) +*/ + +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + /*margin: 0;*/ + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} +/* HTML5 display-role reset for older browsers */ +article, aside, details, figcaption, figure, +footer, header, hgroup, menu, nav, section { + display: block; +} +body { + line-height: 1; +} +ol, ul { + list-style: none; +} +blockquote, q { + quotes: none; +} +blockquote:before, blockquote:after, +q:before, q:after { + content: ''; + content: none; +} +table { + border-collapse: collapse; + border-spacing: 0; +} diff --git a/static/js/index.js b/static/js/index.js new file mode 100644 index 0000000..4379fc2 --- /dev/null +++ b/static/js/index.js @@ -0,0 +1,120 @@ +function gridCellDimensions() { + const element = document.createElement("div"); + element.style.position = "fixed"; + element.style.height = "var(--line-height)"; + element.style.width = "1ch"; + document.body.appendChild(element); + const rect = element.getBoundingClientRect(); + document.body.removeChild(element); + return { width: rect.width, height: rect.height }; +} + +// Add padding to each media to maintain grid. +function adjustMediaPadding() { + const cell = gridCellDimensions(); + + function setHeightFromRatio(media, ratio) { + const rect = media.getBoundingClientRect(); + const realHeight = rect.width / ratio; + const diff = cell.height - (realHeight % cell.height); + media.style.setProperty("padding-bottom", `${diff}px`); + } + + function setFallbackHeight(media) { + const rect = media.getBoundingClientRect(); + const height = Math.round((rect.width / 2) / cell.height) * cell.height; + media.style.setProperty("height", `${height}px`); + } + + function onMediaLoaded(media) { + var width, height; + switch (media.tagName) { + case "IMG": + width = media.naturalWidth; + height = media.naturalHeight; + break; + case "VIDEO": + width = media.videoWidth; + height = media.videoHeight; + break; + } + if (width > 0 && height > 0) { + setHeightFromRatio(media, width / height); + } else { + setFallbackHeight(media); + } + } + + const medias = document.querySelectorAll("img, video"); + for (media of medias) { + switch (media.tagName) { + case "IMG": + if (media.complete) { + onMediaLoaded(media); + } else { + media.addEventListener("load", () => onMediaLoaded(media)); + media.addEventListener("error", function() { + setFallbackHeight(media); + }); + } + break; + case "VIDEO": + switch (media.readyState) { + case HTMLMediaElement.HAVE_CURRENT_DATA: + case HTMLMediaElement.HAVE_FUTURE_DATA: + case HTMLMediaElement.HAVE_ENOUGH_DATA: + onMediaLoaded(media); + break; + default: + media.addEventListener("loadeddata", () => onMediaLoaded(media)); + media.addEventListener("error", function() { + setFallbackHeight(media); + }); + break; + } + break; + } + } +} + +adjustMediaPadding(); +window.addEventListener("load", adjustMediaPadding); +window.addEventListener("resize", adjustMediaPadding); + +function checkOffsets() { + const ignoredTagNames = new Set([ + "THEAD", + "TBODY", + "TFOOT", + "TR", + "TD", + "TH", + ]); + const cell = gridCellDimensions(); + const elements = document.querySelectorAll("body :not(.debug-grid, .debug-toggle)"); + for (const element of elements) { + if (ignoredTagNames.has(element.tagName)) { + continue; + } + const rect = element.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) { + continue; + } + const top = rect.top + window.scrollY; + const left = rect.left + window.scrollX; + const offset = top % (cell.height / 2); + if(offset > 0) { + element.classList.add("off-grid"); + console.error("Incorrect vertical offset for", element, "with remainder", top % cell.height, "when expecting divisible by", cell.height / 2); + } else { + element.classList.remove("off-grid"); + } + } +} + +const debugToggle = document.querySelector(".debug-toggle"); +function onDebugToggle() { + document.body.classList.toggle("debug", debugToggle.checked); +} +debugToggle.addEventListener("change", onDebugToggle); +onDebugToggle();