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:
2026-04-23 08:09:09 +02:00
parent f3ccbc95c1
commit a8862721cd
18 changed files with 1048 additions and 0 deletions
+2
View File
@@ -21,3 +21,5 @@
# Go workspace file # Go workspace file
go.work go.work
# direnv files
.envrc
+86
View File
@@ -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")
}
+32
View File
@@ -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,
}
}
+11
View File
@@ -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"`
}
+19
View File
@@ -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)
+96
View File
@@ -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
}
+19
View File
@@ -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,
}
}
+33
View File
@@ -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>&copy; <span id="year"></span></p>
</footer>
</body>
<script>
document.queryselector('#year').innerhtml = new date().getfullyear();
</script>
</html>
{{end}}
+5
View File
@@ -0,0 +1,5 @@
{{define "content"}}
<section class="hero">
<h1>NFeeder</h1>
</section>
{{end}}
+14
View File
@@ -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)
}
}
+1
View File
@@ -0,0 +1 @@
package web
+28
View File
@@ -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
}
+35
View File
@@ -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)
}
+32
View File
@@ -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)
}
View File
+467
View File
@@ -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;
}
+48
View File
@@ -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;
}
+120
View File
@@ -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();