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.
This commit is contained in:
@@ -21,3 +21,5 @@
|
|||||||
# Go workspace file
|
# Go workspace file
|
||||||
go.work
|
go.work
|
||||||
|
|
||||||
|
# direnv files
|
||||||
|
.envrc
|
||||||
|
|||||||
+86
@@ -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")
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{{define "base"}}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/reset.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/index.css">
|
||||||
|
<link rel="stylesheet" href="/static/js/index.js" defer>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<nav>
|
||||||
|
<a href="/">home</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{{template "content" .}}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>© <span id="year"></span></p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
<script>
|
||||||
|
document.queryselector('#year').innerhtml = new date().getfullyear();
|
||||||
|
</script>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<section class="hero">
|
||||||
|
<h1>NFeeder</h1>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
package web
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
Reference in New Issue
Block a user