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"}}
+
+{{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();