Merge pull request #54 from stevedylandev/feat/quotes 2bf08b36
Add Quotes App
Steve Simkins · 2026-07-02 15:57 24 file(s) · +1154 −4
.dockerignore +7 −0
4 4
.gitignore
5 5
*.md
6 6
LICENSE
7 +
8 +
# App databases and bulk seed data must never enter an image build context.
9 +
**/*.sqlite
10 +
**/*.sqlite-journal
11 +
**/*.sqlite-wal
12 +
**/*.sqlite-shm
13 +
apps/quotes/quotes.csv
.github/workflows/docker-test.yml +2 −2
19 19
      - name: Determine which apps to build
20 20
        id: filter
21 21
        run: |
22 -
          ALL='["backup","blobs","bookmarks","cellar","easel","feeds","jotts","kepler","library","og","posts","shrink","sipp"]'
22 +
          ALL='["backup","blobs","bookmarks","cellar","easel","feeds","jotts","kepler","library","og","posts","quotes","shrink","sipp"]'
23 23
24 24
          changed=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)
25 25
29 29
          fi
30 30
31 31
          apps=()
32 -
          for app in backup blobs bookmarks cellar easel feeds jotts kepler library og posts shrink sipp; do
32 +
          for app in backup blobs bookmarks cellar easel feeds jotts kepler library og posts quotes shrink sipp; do
33 33
            if echo "$changed" | grep -q "^apps/${app}/"; then
34 34
              apps+=("\"${app}\"")
35 35
            fi
.github/workflows/docker.yml +2 −2
25 25
      - name: Determine which apps to build
26 26
        id: filter
27 27
        run: |
28 -
          ALL='["backup","blobs","bookmarks","cellar","easel","feeds","jotts","kepler","library","og","posts","shrink","sipp"]'
28 +
          ALL='["backup","blobs","bookmarks","cellar","easel","feeds","jotts","kepler","library","og","posts","quotes","shrink","sipp"]'
29 29
30 30
          # Tags: per-app (app/version) or bare (version)
31 31
          if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
52 52
          fi
53 53
54 54
          apps=()
55 -
          for app in backup blobs bookmarks cellar easel feeds jotts kepler library og posts shrink sipp; do
55 +
          for app in backup blobs bookmarks cellar easel feeds jotts kepler library og posts quotes shrink sipp; do
56 56
            if echo "$changed" | grep -q "^apps/${app}/"; then
57 57
              apps+=("\"${app}\"")
58 58
            fi
.gitignore +2 −0
30 30
apps/shrink/shrink
31 31
apps/sipp/sipp
32 32
apps/kepler/kepler
33 +
apps/quotes/quotes
34 +
apps/quotes/quotes.csv
apps/quotes/.env.example (added) +7 −0
1 +
QUOTES_PASSWORD=changeme
2 +
QUOTES_API_KEY=
3 +
QUOTES_DB_PATH=quotes.sqlite
4 +
COOKIE_SECURE=false
5 +
BASE_URL=http://localhost:3000
6 +
HOST=127.0.0.1
7 +
PORT=3000
apps/quotes/Dockerfile (added) +18 −0
1 +
# Build from repo root: docker build -t quotes -f apps/quotes/Dockerfile .
2 +
FROM golang:1.24-bookworm AS builder
3 +
WORKDIR /app
4 +
COPY pkg/ ./pkg/
5 +
COPY apps/quotes/go.mod apps/quotes/go.sum ./apps/quotes/
6 +
WORKDIR /app/apps/quotes
7 +
RUN go mod download
8 +
COPY apps/quotes/ ./
9 +
RUN CGO_ENABLED=0 go build -o /quotes .
10 +
11 +
FROM debian:bookworm-slim
12 +
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
13 +
COPY --from=builder /quotes /usr/local/bin/quotes
14 +
WORKDIR /data
15 +
ENV HOST=0.0.0.0
16 +
ENV PORT=3000
17 +
EXPOSE 3000
18 +
CMD ["quotes"]
apps/quotes/README.md (added) +85 −0
1 +
# quotes
2 +
3 +
A minimal quote-a-day site. The landing page shows a single **quote of the day**
4 +
from classic literature — white, centered, on a dark background. `/admin` is a
5 +
session-protected page to add and manage quotes, matching the other andromeda
6 +
apps.
7 +
8 +
## Run
9 +
10 +
```sh
11 +
cp .env.example .env   # set QUOTES_PASSWORD
12 +
go run .
13 +
```
14 +
15 +
Landing: `http://localhost:3000/` — the quote of the day (deterministic, rotates
16 +
at UTC midnight).
17 +
Admin: `http://localhost:3000/admin` — log in with `QUOTES_PASSWORD`.
18 +
19 +
## Seeding from a CSV
20 +
21 +
`quotes.csv` is a Goodreads-style export (`quote,author,category`). The seed
22 +
command imports only classic-literature quotes, matched case-sensitively against
23 +
the author/title list in `classic_authors.txt`:
24 +
25 +
```sh
26 +
go run . seed quotes.csv
27 +
```
28 +
29 +
- Edit `classic_authors.txt` (one author or book title per line, `#` comments
30 +
  allowed) to widen or narrow the selection, then re-run the command.
31 +
- Attribution is split at the first comma: `author` and `source` (book title).
32 +
- Re-seeding is idempotent — quotes already present (matched on text + author)
33 +
  are skipped.
34 +
- The author list is also **embedded in the binary**, so `seed` works even when
35 +
  `classic_authors.txt` is not on disk (e.g. inside a container). An on-disk
36 +
  file always takes precedence, so local edits apply without a rebuild.
37 +
38 +
### Seeding a Docker volume
39 +
40 +
The image only ships the binary — the DB lives on the `quotes_data` volume
41 +
(`QUOTES_DB_PATH=/data/quotes.sqlite`), and the 138 MB `quotes.csv` is
42 +
deliberately excluded from the build (see `.dockerignore`). So seeding is a
43 +
one-off `run` that bind-mounts the CSV and writes into the same named volume the
44 +
service uses:
45 +
46 +
```sh
47 +
# From the repo root (uses the `quotes` service's volume + env)
48 +
docker compose run --rm \
49 +
  -v "$PWD/apps/quotes/quotes.csv:/seed/quotes.csv:ro" \
50 +
  quotes quotes seed /seed/quotes.csv
51 +
52 +
# Then start the service normally
53 +
docker compose up -d quotes
54 +
```
55 +
56 +
The author list comes from the embedded copy. To seed with a different list
57 +
without rebuilding the image, mount your edited file over the working directory
58 +
(`/data`) too:
59 +
60 +
```sh
61 +
docker compose run --rm \
62 +
  -v "$PWD/apps/quotes/quotes.csv:/seed/quotes.csv:ro" \
63 +
  -v "$PWD/apps/quotes/classic_authors.txt:/data/classic_authors.txt:ro" \
64 +
  quotes quotes seed /seed/quotes.csv
65 +
```
66 +
67 +
Because seeding is idempotent, you can re-run either command after widening the
68 +
list to pull in the newly matched quotes.
69 +
70 +
## API (public, read-only)
71 +
72 +
- `GET /api/quotes?limit=100` — most recent quotes
73 +
- `GET /api/quotes/today` — the quote of the day
74 +
- `GET /api/quotes/{short_id}` — a single quote
75 +
76 +
## Environment
77 +
78 +
| Var | Default | Notes |
79 +
| --- | --- | --- |
80 +
| `QUOTES_PASSWORD` | _(empty)_ | Admin login password (plaintext or bcrypt hash). Empty disables login. |
81 +
| `QUOTES_API_KEY` | _(empty)_ | Reserved; the read API is currently public. |
82 +
| `QUOTES_DB_PATH` | `quotes.sqlite` | SQLite file path. |
83 +
| `HOST` / `PORT` | `0.0.0.0` / `3000` | Listen address. |
84 +
| `BASE_URL` | `http://localhost:3000` | Used in social meta tags. |
85 +
| `COOKIE_SECURE` | `false` | Set `true` behind HTTPS. |
apps/quotes/app.go (added) +55 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"embed"
6 +
	"html/template"
7 +
	"log/slog"
8 +
9 +
	"github.com/stevedylandev/andromeda/pkg/auth"
10 +
)
11 +
12 +
//go:embed templates/*.html static/*
13 +
var appFS embed.FS
14 +
15 +
type App struct {
16 +
	DB            *sql.DB
17 +
	Log           *slog.Logger
18 +
	Templates     *template.Template
19 +
	Sessions      *auth.Store
20 +
	AdminPassword string
21 +
	APIKey        string
22 +
	CookieSecure  bool
23 +
	BaseURL       string
24 +
}
25 +
26 +
type quoteView struct {
27 +
	Text   string
28 +
	Author string
29 +
	Source string
30 +
}
31 +
32 +
type indexPageData struct {
33 +
	BaseURL string
34 +
	Quote   *quoteView
35 +
}
36 +
37 +
type loginPageData struct {
38 +
	Error string
39 +
}
40 +
41 +
type adminQuoteRow struct {
42 +
	ShortID string
43 +
	Text    string
44 +
	Author  string
45 +
	Source  string
46 +
}
47 +
48 +
type adminPageData struct {
49 +
	Success  string
50 +
	Error    string
51 +
	Total    int
52 +
	Quotes   []adminQuoteRow
53 +
	Query    string
54 +
	Searched bool
55 +
}
apps/quotes/classic_authors.txt (added) +47 −0
1 +
# Classic literature match list for the `quotes seed` command.
2 +
#
3 +
# Each non-empty, non-comment line is matched as a CASE-SENSITIVE substring
4 +
# against the CSV "author" column. So "Jane Austen" matches both
5 +
# "Jane Austen" and "Jane Austen, Pride and Prejudice".
6 +
#
7 +
# Edit this file (add/remove names or book titles) and re-run
8 +
#   go run . seed quotes.csv
9 +
# to pull in more quotes. Re-seeding is idempotent (dedup on text + author).
10 +
11 +
Jane Austen
12 +
Charles Dickens
13 +
Leo Tolstoy
14 +
Dostoyevsky
15 +
Fyodor Dostoevsky
16 +
Homer
17 +
Herman Melville
18 +
Nathaniel Hawthorne
19 +
Mary Shelley
20 +
Bram Stoker
21 +
Emily Brontë
22 +
Charlotte Brontë
23 +
Anne Brontë
24 +
Victor Hugo
25 +
Alexandre Dumas
26 +
Franz Kafka
27 +
Marcel Proust
28 +
James Joyce
29 +
Virginia Woolf
30 +
F. Scott Fitzgerald
31 +
Ernest Hemingway
32 +
John Steinbeck
33 +
George Orwell
34 +
Jules Verne
35 +
H.G. Wells
36 +
Edgar Allan Poe
37 +
Walt Whitman
38 +
Emily Dickinson
39 +
Oscar Wilde
40 +
Mark Twain
41 +
Henry David Thoreau
42 +
George Eliot
43 +
Rudyard Kipling
44 +
Voltaire
45 +
Geoffrey Chaucer
46 +
Plato
47 +
Aristotle
apps/quotes/db.go (added) +148 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"errors"
6 +
	"strings"
7 +
	"time"
8 +
9 +
	"github.com/stevedylandev/andromeda/pkg/auth"
10 +
)
11 +
12 +
const quotesSchema = `
13 +
CREATE TABLE IF NOT EXISTS quotes (
14 +
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
15 +
    short_id   TEXT NOT NULL UNIQUE,
16 +
    text       TEXT NOT NULL,
17 +
    author     TEXT NOT NULL,
18 +
    source     TEXT,
19 +
    added_at   INTEGER NOT NULL,
20 +
    updated_at INTEGER NOT NULL
21 +
);
22 +
CREATE INDEX IF NOT EXISTS idx_quotes_added ON quotes(added_at DESC);
23 +
`
24 +
25 +
type Quote struct {
26 +
	ID        int64   `json:"id"`
27 +
	ShortID   string  `json:"short_id"`
28 +
	Text      string  `json:"text"`
29 +
	Author    string  `json:"author"`
30 +
	Source    *string `json:"source,omitempty"`
31 +
	AddedAt   int64   `json:"added_at"`
32 +
	UpdatedAt int64   `json:"updated_at"`
33 +
}
34 +
35 +
const selectCols = `id, short_id, text, author, source, added_at, updated_at`
36 +
37 +
func scanQuote(s interface{ Scan(...any) error }) (*Quote, error) {
38 +
	var q Quote
39 +
	var source sql.NullString
40 +
	err := s.Scan(&q.ID, &q.ShortID, &q.Text, &q.Author, &source, &q.AddedAt, &q.UpdatedAt)
41 +
	if errors.Is(err, sql.ErrNoRows) {
42 +
		return nil, nil
43 +
	}
44 +
	if err != nil {
45 +
		return nil, err
46 +
	}
47 +
	if source.Valid {
48 +
		v := source.String
49 +
		q.Source = &v
50 +
	}
51 +
	return &q, nil
52 +
}
53 +
54 +
func countQuotes(db *sql.DB) (int, error) {
55 +
	var n int
56 +
	err := db.QueryRow(`SELECT COUNT(*) FROM quotes`).Scan(&n)
57 +
	return n, err
58 +
}
59 +
60 +
func listQuotes(db *sql.DB, limit int) ([]Quote, error) {
61 +
	rows, err := db.Query(`SELECT `+selectCols+` FROM quotes ORDER BY added_at DESC, id DESC LIMIT ?`, limit)
62 +
	if err != nil {
63 +
		return nil, err
64 +
	}
65 +
	defer rows.Close()
66 +
	var out []Quote
67 +
	for rows.Next() {
68 +
		q, err := scanQuote(rows)
69 +
		if err != nil {
70 +
			return nil, err
71 +
		}
72 +
		out = append(out, *q)
73 +
	}
74 +
	return out, rows.Err()
75 +
}
76 +
77 +
func getQuoteByShortID(db *sql.DB, shortID string) (*Quote, error) {
78 +
	return scanQuote(db.QueryRow(`SELECT `+selectCols+` FROM quotes WHERE short_id = ?`, shortID))
79 +
}
80 +
81 +
// quoteOfTheDay returns a deterministic quote that is stable for the whole UTC
82 +
// day and rotates at midnight. Returns (nil, nil) when the table is empty.
83 +
func quoteOfTheDay(db *sql.DB) (*Quote, error) {
84 +
	n, err := countQuotes(db)
85 +
	if err != nil {
86 +
		return nil, err
87 +
	}
88 +
	if n == 0 {
89 +
		return nil, nil
90 +
	}
91 +
	offset := int(time.Now().UTC().Unix()/86400) % n
92 +
	return scanQuote(db.QueryRow(`SELECT `+selectCols+` FROM quotes ORDER BY id LIMIT 1 OFFSET ?`, offset))
93 +
}
94 +
95 +
func searchQuotes(db *sql.DB, q string) ([]Quote, error) {
96 +
	term := strings.TrimSpace(q)
97 +
	if term == "" {
98 +
		return nil, nil
99 +
	}
100 +
	pattern := "%" + strings.ToLower(term) + "%"
101 +
	rows, err := db.Query(
102 +
		`SELECT `+selectCols+` FROM quotes
103 +
		 WHERE LOWER(text) LIKE ? OR LOWER(author) LIKE ? OR LOWER(IFNULL(source,'')) LIKE ?
104 +
		 ORDER BY added_at DESC, id DESC LIMIT 50`,
105 +
		pattern, pattern, pattern,
106 +
	)
107 +
	if err != nil {
108 +
		return nil, err
109 +
	}
110 +
	defer rows.Close()
111 +
	var out []Quote
112 +
	for rows.Next() {
113 +
		q, err := scanQuote(rows)
114 +
		if err != nil {
115 +
			return nil, err
116 +
		}
117 +
		out = append(out, *q)
118 +
	}
119 +
	return out, rows.Err()
120 +
}
121 +
122 +
// insertQuote inserts a quote with a freshly generated short id. source may be
123 +
// empty, in which case it is stored as NULL.
124 +
func insertQuote(db *sql.DB, text, author, source string) (int64, error) {
125 +
	shortID, err := auth.GenerateShortID(10)
126 +
	if err != nil {
127 +
		return 0, err
128 +
	}
129 +
	now := time.Now().UTC().Unix()
130 +
	var src any
131 +
	if strings.TrimSpace(source) != "" {
132 +
		src = source
133 +
	}
134 +
	res, err := db.Exec(
135 +
		`INSERT INTO quotes (short_id, text, author, source, added_at, updated_at)
136 +
		 VALUES (?, ?, ?, ?, ?, ?)`,
137 +
		shortID, text, author, src, now, now,
138 +
	)
139 +
	if err != nil {
140 +
		return 0, err
141 +
	}
142 +
	return res.LastInsertId()
143 +
}
144 +
145 +
func deleteQuoteByShortID(db *sql.DB, shortID string) error {
146 +
	_, err := db.Exec(`DELETE FROM quotes WHERE short_id = ?`, shortID)
147 +
	return err
148 +
}
apps/quotes/docker-compose.yml (added) +21 −0
1 +
services:
2 +
  app:
3 +
    build:
4 +
      context: ../..
5 +
      dockerfile: apps/quotes/Dockerfile
6 +
    ports:
7 +
      - "${PORT:-3000}:${PORT:-3000}"
8 +
    environment:
9 +
      - HOST=0.0.0.0
10 +
      - PORT=${PORT:-3000}
11 +
      - QUOTES_DB_PATH=/data/quotes.sqlite
12 +
      - QUOTES_PASSWORD=${QUOTES_PASSWORD:-changeme}
13 +
      - QUOTES_API_KEY=${QUOTES_API_KEY:-}
14 +
      - BASE_URL=${BASE_URL:-http://localhost:${PORT:-3000}}
15 +
      - COOKIE_SECURE=${COOKIE_SECURE:-false}
16 +
    volumes:
17 +
      - quotes-data:/data
18 +
    restart: unless-stopped
19 +
20 +
volumes:
21 +
  quotes-data:
apps/quotes/go.mod (added) +34 −0
1 +
module github.com/stevedylandev/andromeda/apps/quotes
2 +
3 +
go 1.24.4
4 +
5 +
require (
6 +
	github.com/stevedylandev/andromeda/pkg/auth v0.0.0
7 +
	github.com/stevedylandev/andromeda/pkg/config v0.0.0
8 +
	github.com/stevedylandev/andromeda/pkg/darkmatter v0.0.0
9 +
	github.com/stevedylandev/andromeda/pkg/sqlite v0.0.0
10 +
	github.com/stevedylandev/andromeda/pkg/web v0.0.0
11 +
)
12 +
13 +
require (
14 +
	github.com/dustin/go-humanize v1.0.1 // indirect
15 +
	github.com/google/uuid v1.6.0 // indirect
16 +
	github.com/mattn/go-isatty v0.0.20 // indirect
17 +
	github.com/ncruces/go-strftime v0.1.9 // indirect
18 +
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
19 +
	golang.org/x/crypto v0.39.0 // indirect
20 +
	golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
21 +
	golang.org/x/sys v0.33.0 // indirect
22 +
	modernc.org/libc v1.65.7 // indirect
23 +
	modernc.org/mathutil v1.7.1 // indirect
24 +
	modernc.org/memory v1.11.0 // indirect
25 +
	modernc.org/sqlite v1.37.1 // indirect
26 +
)
27 +
28 +
replace (
29 +
	github.com/stevedylandev/andromeda/pkg/auth => ../../pkg/auth
30 +
	github.com/stevedylandev/andromeda/pkg/config => ../../pkg/config
31 +
	github.com/stevedylandev/andromeda/pkg/darkmatter => ../../pkg/darkmatter
32 +
	github.com/stevedylandev/andromeda/pkg/sqlite => ../../pkg/sqlite
33 +
	github.com/stevedylandev/andromeda/pkg/web => ../../pkg/web
34 +
)
apps/quotes/go.sum (added) +49 −0
1 +
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
2 +
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
3 +
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
4 +
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
5 +
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
6 +
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
7 +
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
8 +
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
9 +
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
10 +
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
11 +
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
12 +
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
13 +
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
14 +
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
15 +
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
16 +
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
17 +
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
18 +
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
19 +
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
20 +
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
21 +
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
22 +
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
23 +
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
24 +
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
25 +
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
26 +
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
27 +
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
28 +
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
29 +
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
30 +
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
31 +
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
32 +
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
33 +
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
34 +
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
35 +
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
36 +
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
37 +
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
38 +
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
39 +
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
40 +
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
41 +
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
42 +
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
43 +
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
44 +
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
45 +
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
46 +
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
47 +
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
48 +
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
49 +
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
apps/quotes/handlers_api.go (added) +55 −0
1 +
package main
2 +
3 +
import (
4 +
	"net/http"
5 +
	"strconv"
6 +
7 +
	"github.com/stevedylandev/andromeda/pkg/web"
8 +
)
9 +
10 +
func (a *App) apiListQuotes(w http.ResponseWriter, r *http.Request) {
11 +
	limit := 100
12 +
	if v := r.URL.Query().Get("limit"); v != "" {
13 +
		if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 500 {
14 +
			limit = n
15 +
		}
16 +
	}
17 +
	quotes, err := listQuotes(a.DB, limit)
18 +
	if err != nil {
19 +
		a.Log.Error("list quotes", "err", err)
20 +
		w.WriteHeader(http.StatusInternalServerError)
21 +
		return
22 +
	}
23 +
	if quotes == nil {
24 +
		quotes = []Quote{}
25 +
	}
26 +
	web.WriteJSON(w, http.StatusOK, quotes)
27 +
}
28 +
29 +
func (a *App) apiQuoteOfTheDay(w http.ResponseWriter, r *http.Request) {
30 +
	q, err := quoteOfTheDay(a.DB)
31 +
	if err != nil {
32 +
		a.Log.Error("quote of the day", "err", err)
33 +
		w.WriteHeader(http.StatusInternalServerError)
34 +
		return
35 +
	}
36 +
	if q == nil {
37 +
		web.WriteError(w, http.StatusNotFound, "no quotes")
38 +
		return
39 +
	}
40 +
	web.WriteJSON(w, http.StatusOK, q)
41 +
}
42 +
43 +
func (a *App) apiGetQuote(w http.ResponseWriter, r *http.Request) {
44 +
	q, err := getQuoteByShortID(a.DB, r.PathValue("short_id"))
45 +
	if err != nil {
46 +
		a.Log.Error("get quote", "err", err)
47 +
		w.WriteHeader(http.StatusInternalServerError)
48 +
		return
49 +
	}
50 +
	if q == nil {
51 +
		web.WriteError(w, http.StatusNotFound, "not found")
52 +
		return
53 +
	}
54 +
	web.WriteJSON(w, http.StatusOK, q)
55 +
}
apps/quotes/handlers_web.go (added) +128 −0
1 +
package main
2 +
3 +
import (
4 +
	"net/http"
5 +
	"strings"
6 +
7 +
	"github.com/stevedylandev/andromeda/pkg/auth"
8 +
	"github.com/stevedylandev/andromeda/pkg/web"
9 +
)
10 +
11 +
func quoteToView(q Quote) quoteView {
12 +
	v := quoteView{Text: q.Text, Author: q.Author}
13 +
	if q.Source != nil {
14 +
		v.Source = *q.Source
15 +
	}
16 +
	return v
17 +
}
18 +
19 +
func quoteToRow(q Quote) adminQuoteRow {
20 +
	r := adminQuoteRow{ShortID: q.ShortID, Text: q.Text, Author: q.Author}
21 +
	if q.Source != nil {
22 +
		r.Source = *q.Source
23 +
	}
24 +
	return r
25 +
}
26 +
27 +
func (a *App) indexHandler(w http.ResponseWriter, r *http.Request) {
28 +
	data := indexPageData{BaseURL: a.BaseURL}
29 +
	if q, err := quoteOfTheDay(a.DB); err != nil {
30 +
		a.Log.Error("quote of the day", "err", err)
31 +
	} else if q != nil {
32 +
		v := quoteToView(*q)
33 +
		data.Quote = &v
34 +
	}
35 +
	web.Render(a.Templates, w, "index.html", data, a.Log)
36 +
}
37 +
38 +
func (a *App) loginGetHandler(w http.ResponseWriter, r *http.Request) {
39 +
	web.Render(a.Templates, w, "login.html", loginPageData{Error: r.URL.Query().Get("error")}, a.Log)
40 +
}
41 +
42 +
func (a *App) loginPostHandler(w http.ResponseWriter, r *http.Request) {
43 +
	if a.AdminPassword == "" {
44 +
		web.RedirectWithError(w, r, "/admin/login", "No admin password configured")
45 +
		return
46 +
	}
47 +
	if err := r.ParseForm(); err != nil {
48 +
		web.RedirectWithError(w, r, "/admin/login", "Bad request")
49 +
		return
50 +
	}
51 +
	if !auth.VerifyPassword(r.FormValue("password"), a.AdminPassword) {
52 +
		web.RedirectWithError(w, r, "/admin/login", "Invalid password")
53 +
		return
54 +
	}
55 +
	token, err := a.Sessions.Create()
56 +
	if err != nil {
57 +
		a.Log.Error("create session failed", "err", err)
58 +
		web.RedirectWithError(w, r, "/admin/login", "Session error")
59 +
		return
60 +
	}
61 +
	a.Sessions.PruneExpired()
62 +
	http.SetCookie(w, a.Sessions.SessionCookie(token))
63 +
	http.Redirect(w, r, "/admin", http.StatusSeeOther)
64 +
}
65 +
66 +
func (a *App) logoutHandler(w http.ResponseWriter, r *http.Request) {
67 +
	if c, err := r.Cookie(a.Sessions.CookieName); err == nil && c.Value != "" {
68 +
		a.Sessions.Delete(c.Value)
69 +
	}
70 +
	http.SetCookie(w, a.Sessions.ClearCookie())
71 +
	http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
72 +
}
73 +
74 +
func (a *App) adminHandler(w http.ResponseWriter, r *http.Request) {
75 +
	total, _ := countQuotes(a.DB)
76 +
77 +
	query := r.URL.Query().Get("q")
78 +
	searched := strings.TrimSpace(query) != ""
79 +
80 +
	var found []Quote
81 +
	if searched {
82 +
		found, _ = searchQuotes(a.DB, query)
83 +
	} else {
84 +
		found, _ = listQuotes(a.DB, 50)
85 +
	}
86 +
	rows := make([]adminQuoteRow, 0, len(found))
87 +
	for _, q := range found {
88 +
		rows = append(rows, quoteToRow(q))
89 +
	}
90 +
91 +
	web.Render(a.Templates, w, "admin.html", adminPageData{
92 +
		Success:  r.URL.Query().Get("success"),
93 +
		Error:    r.URL.Query().Get("error"),
94 +
		Total:    total,
95 +
		Quotes:   rows,
96 +
		Query:    query,
97 +
		Searched: searched,
98 +
	}, a.Log)
99 +
}
100 +
101 +
func (a *App) adminAddQuote(w http.ResponseWriter, r *http.Request) {
102 +
	if err := r.ParseForm(); err != nil {
103 +
		web.RedirectWithError(w, r, "/admin", "Bad request")
104 +
		return
105 +
	}
106 +
	text := strings.TrimSpace(r.FormValue("text"))
107 +
	author := strings.TrimSpace(r.FormValue("author"))
108 +
	source := strings.TrimSpace(r.FormValue("source"))
109 +
	if text == "" || author == "" {
110 +
		web.RedirectWithError(w, r, "/admin", "Quote and author are required")
111 +
		return
112 +
	}
113 +
	if _, err := insertQuote(a.DB, text, author, source); err != nil {
114 +
		a.Log.Error("insert quote", "err", err)
115 +
		web.RedirectWithError(w, r, "/admin", "Failed to add quote")
116 +
		return
117 +
	}
118 +
	web.RedirectWithSuccess(w, r, "/admin", "Quote added")
119 +
}
120 +
121 +
func (a *App) adminDeleteQuote(w http.ResponseWriter, r *http.Request) {
122 +
	if err := deleteQuoteByShortID(a.DB, r.PathValue("short_id")); err != nil {
123 +
		a.Log.Error("delete quote", "err", err)
124 +
		web.RedirectWithError(w, r, "/admin", "Failed to remove quote")
125 +
		return
126 +
	}
127 +
	web.RedirectWithSuccess(w, r, "/admin", "Quote removed")
128 +
}
apps/quotes/main.go (added) +68 −0
1 +
package main
2 +
3 +
import (
4 +
	"html/template"
5 +
	"log"
6 +
	"log/slog"
7 +
	"net/http"
8 +
	"os"
9 +
10 +
	"github.com/stevedylandev/andromeda/pkg/auth"
11 +
	"github.com/stevedylandev/andromeda/pkg/config"
12 +
	"github.com/stevedylandev/andromeda/pkg/sqlite"
13 +
)
14 +
15 +
func main() {
16 +
	config.LoadDotEnv(".env")
17 +
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
18 +
19 +
	dbPath := config.Getenv("QUOTES_DB_PATH", "quotes.sqlite")
20 +
21 +
	// Seed subcommand: `quotes seed <csv>` imports classic-literature quotes
22 +
	// from a Goodreads-style CSV and exits. Not part of normal boot.
23 +
	if len(os.Args) > 1 && os.Args[1] == "seed" {
24 +
		csvPath := "quotes.csv"
25 +
		if len(os.Args) > 2 {
26 +
			csvPath = os.Args[2]
27 +
		}
28 +
		if err := runSeed(logger, dbPath, csvPath); err != nil {
29 +
			log.Fatal(err)
30 +
		}
31 +
		return
32 +
	}
33 +
34 +
	db, err := sqlite.Open(dbPath, quotesSchema)
35 +
	if err != nil {
36 +
		log.Fatal(err)
37 +
	}
38 +
	defer db.Close()
39 +
40 +
	sessions := &auth.Store{DB: db, CookieName: "session", CookieSecure: config.GetenvBool("COOKIE_SECURE", false)}
41 +
	if err := sessions.EnsureSchema(); err != nil {
42 +
		log.Fatal(err)
43 +
	}
44 +
	sessions.PruneExpired()
45 +
46 +
	adminPassword := config.Getenv("QUOTES_PASSWORD", "")
47 +
	if adminPassword == "" {
48 +
		logger.Warn("QUOTES_PASSWORD not set; admin login is disabled")
49 +
	}
50 +
51 +
	tmpl := template.Must(template.ParseFS(appFS, "templates/*.html"))
52 +
	app := &App{
53 +
		DB:            db,
54 +
		Log:           logger,
55 +
		Templates:     tmpl,
56 +
		Sessions:      sessions,
57 +
		AdminPassword: adminPassword,
58 +
		APIKey:        config.Getenv("QUOTES_API_KEY", ""),
59 +
		CookieSecure:  sessions.CookieSecure,
60 +
		BaseURL:       config.Getenv("BASE_URL", "http://localhost:3000"),
61 +
	}
62 +
63 +
	addr := config.Getenv("HOST", "0.0.0.0") + ":" + config.Getenv("PORT", "3000")
64 +
	logger.Info("quotes server running", "addr", addr)
65 +
	if err := http.ListenAndServe(addr, app.routes()); err != nil {
66 +
		log.Fatal(err)
67 +
	}
68 +
}
apps/quotes/routes.go (added) +33 −0
1 +
package main
2 +
3 +
import (
4 +
	"net/http"
5 +
6 +
	"github.com/stevedylandev/andromeda/pkg/darkmatter"
7 +
	"github.com/stevedylandev/andromeda/pkg/web"
8 +
)
9 +
10 +
func (a *App) routes() *http.ServeMux {
11 +
	mux := http.NewServeMux()
12 +
13 +
	requireSession := func(next http.HandlerFunc) http.HandlerFunc {
14 +
		return a.Sessions.RequireSession("/admin/login", next)
15 +
	}
16 +
17 +
	mux.HandleFunc("GET /", a.indexHandler)
18 +
	mux.HandleFunc("GET /static/", web.EmbeddedHandler(appFS, "static"))
19 +
	darkmatter.Mount(mux, "/assets")
20 +
21 +
	mux.HandleFunc("GET /admin/login", a.loginGetHandler)
22 +
	mux.HandleFunc("POST /admin/login", a.loginPostHandler)
23 +
	mux.HandleFunc("GET /admin/logout", a.logoutHandler)
24 +
	mux.HandleFunc("GET /admin", requireSession(a.adminHandler))
25 +
	mux.HandleFunc("POST /admin/add", requireSession(a.adminAddQuote))
26 +
	mux.HandleFunc("POST /admin/quotes/{short_id}/delete", requireSession(a.adminDeleteQuote))
27 +
28 +
	mux.HandleFunc("GET /api/quotes", a.apiListQuotes)
29 +
	mux.HandleFunc("GET /api/quotes/today", a.apiQuoteOfTheDay)
30 +
	mux.HandleFunc("GET /api/quotes/{short_id}", a.apiGetQuote)
31 +
32 +
	return mux
33 +
}
apps/quotes/seed.go (added) +194 −0
1 +
package main
2 +
3 +
import (
4 +
	_ "embed"
5 +
	"encoding/csv"
6 +
	"errors"
7 +
	"fmt"
8 +
	"io"
9 +
	"log/slog"
10 +
	"os"
11 +
	"strings"
12 +
	"time"
13 +
14 +
	"github.com/stevedylandev/andromeda/pkg/auth"
15 +
	"github.com/stevedylandev/andromeda/pkg/sqlite"
16 +
)
17 +
18 +
const classicListPath = "classic_authors.txt"
19 +
20 +
// embeddedClassicList is baked into the binary so the seed command works inside
21 +
// a container (or anywhere the txt file is absent) without needing the file on
22 +
// disk. An on-disk classic_authors.txt still takes precedence, so local edits
23 +
// apply without a rebuild.
24 +
//
25 +
//go:embed classic_authors.txt
26 +
var embeddedClassicList string
27 +
28 +
func parseClassicList(data string) []string {
29 +
	var out []string
30 +
	for _, line := range strings.Split(data, "\n") {
31 +
		line = strings.TrimSpace(line)
32 +
		if line == "" || strings.HasPrefix(line, "#") {
33 +
			continue
34 +
		}
35 +
		out = append(out, line)
36 +
	}
37 +
	return out
38 +
}
39 +
40 +
// loadClassicList reads the match list from disk, falling back to the embedded
41 +
// copy when the file is absent. Skips blank lines and # comments.
42 +
func loadClassicList(path string) ([]string, bool, error) {
43 +
	data, err := os.ReadFile(path)
44 +
	if errors.Is(err, os.ErrNotExist) {
45 +
		return parseClassicList(embeddedClassicList), true, nil
46 +
	}
47 +
	if err != nil {
48 +
		return nil, false, err
49 +
	}
50 +
	return parseClassicList(string(data)), false, nil
51 +
}
52 +
53 +
// matchesClassic reports whether the author column contains any list entry as a
54 +
// case-sensitive substring.
55 +
func matchesClassic(author string, list []string) bool {
56 +
	for _, name := range list {
57 +
		if strings.Contains(author, name) {
58 +
			return true
59 +
		}
60 +
	}
61 +
	return false
62 +
}
63 +
64 +
// splitAttribution splits a CSV author field of the form "Author, Book Title"
65 +
// at the first comma into author and source. No comma -> empty source.
66 +
func splitAttribution(field string) (author, source string) {
67 +
	if i := strings.Index(field, ","); i >= 0 {
68 +
		return strings.TrimSpace(field[:i]), strings.TrimSpace(field[i+1:])
69 +
	}
70 +
	return strings.TrimSpace(field), ""
71 +
}
72 +
73 +
func listSource(embedded bool) string {
74 +
	if embedded {
75 +
		return "embedded"
76 +
	}
77 +
	return classicListPath
78 +
}
79 +
80 +
func dedupKey(text, author string) string {
81 +
	return text + "\x00" + author
82 +
}
83 +
84 +
// runSeed imports classic-literature quotes from csvPath into the database at
85 +
// dbPath. It is idempotent: quotes already present (matched on text+author) are
86 +
// skipped, so re-running after editing classic_authors.txt only adds new rows.
87 +
func runSeed(logger *slog.Logger, dbPath, csvPath string) error {
88 +
	list, embedded, err := loadClassicList(classicListPath)
89 +
	if err != nil {
90 +
		return fmt.Errorf("read %s: %w", classicListPath, err)
91 +
	}
92 +
	logger.Info("loaded classic list", "entries", len(list), "source", listSource(embedded))
93 +
94 +
	db, err := sqlite.Open(dbPath, quotesSchema)
95 +
	if err != nil {
96 +
		return err
97 +
	}
98 +
	defer db.Close()
99 +
100 +
	// Preload existing (text, author) pairs so re-seeds stay idempotent.
101 +
	seen := map[string]struct{}{}
102 +
	rows, err := db.Query(`SELECT text, author FROM quotes`)
103 +
	if err != nil {
104 +
		return err
105 +
	}
106 +
	for rows.Next() {
107 +
		var t, a string
108 +
		if err := rows.Scan(&t, &a); err != nil {
109 +
			rows.Close()
110 +
			return err
111 +
		}
112 +
		seen[dedupKey(t, a)] = struct{}{}
113 +
	}
114 +
	rows.Close()
115 +
116 +
	f, err := os.Open(csvPath)
117 +
	if err != nil {
118 +
		return err
119 +
	}
120 +
	defer f.Close()
121 +
122 +
	reader := csv.NewReader(f)
123 +
	reader.FieldsPerRecord = -1
124 +
	if _, err := reader.Read(); err != nil { // skip header
125 +
		return err
126 +
	}
127 +
128 +
	tx, err := db.Begin()
129 +
	if err != nil {
130 +
		return err
131 +
	}
132 +
	stmt, err := tx.Prepare(
133 +
		`INSERT INTO quotes (short_id, text, author, source, added_at, updated_at)
134 +
		 VALUES (?, ?, ?, ?, ?, ?)`,
135 +
	)
136 +
	if err != nil {
137 +
		tx.Rollback()
138 +
		return err
139 +
	}
140 +
	defer stmt.Close()
141 +
142 +
	now := time.Now().UTC().Unix()
143 +
	var scanned, inserted, skipped int
144 +
	for {
145 +
		rec, err := reader.Read()
146 +
		if err == io.EOF {
147 +
			break
148 +
		}
149 +
		if err != nil {
150 +
			logger.Warn("skipping malformed row", "err", err)
151 +
			continue
152 +
		}
153 +
		if len(rec) < 2 {
154 +
			continue
155 +
		}
156 +
		scanned++
157 +
		text := strings.TrimSpace(rec[0])
158 +
		rawAuthor := rec[1]
159 +
		if text == "" || !matchesClassic(rawAuthor, list) {
160 +
			continue
161 +
		}
162 +
		author, source := splitAttribution(rawAuthor)
163 +
		if author == "" {
164 +
			continue
165 +
		}
166 +
		key := dedupKey(text, author)
167 +
		if _, ok := seen[key]; ok {
168 +
			skipped++
169 +
			continue
170 +
		}
171 +
		shortID, err := auth.GenerateShortID(10)
172 +
		if err != nil {
173 +
			tx.Rollback()
174 +
			return err
175 +
		}
176 +
		var src any
177 +
		if source != "" {
178 +
			src = source
179 +
		}
180 +
		if _, err := stmt.Exec(shortID, text, author, src, now, now); err != nil {
181 +
			tx.Rollback()
182 +
			return err
183 +
		}
184 +
		seen[key] = struct{}{}
185 +
		inserted++
186 +
	}
187 +
188 +
	if err := tx.Commit(); err != nil {
189 +
		return err
190 +
	}
191 +
	total, _ := countQuotes(db)
192 +
	logger.Info("seed complete", "scanned", scanned, "inserted", inserted, "skipped_duplicates", skipped, "total_in_db", total)
193 +
	return nil
194 +
}
apps/quotes/static/site.webmanifest (added) +8 −0
1 +
{
2 +
  "name": "Quotes",
3 +
  "short_name": "Quotes",
4 +
  "icons": [],
5 +
  "theme_color": "#121113",
6 +
  "background_color": "#121113",
7 +
  "display": "standalone"
8 +
}
apps/quotes/static/styles.css (added) +39 −0
1 +
/* App-local styles for quotes. The shared design system (palette, typography,
2 +
   forms, admin lists) comes from /assets/darkmatter.css. Only the minimal
3 +
   centered landing "hero" is added here, since darkmatter's body is a
4 +
   left-aligned 700px column and does not vertically center. */
5 +
6 +
.quote-hero {
7 +
  width: 100%;
8 +
  min-height: 100vh;
9 +
  display: flex;
10 +
  flex-direction: column;
11 +
  justify-content: center;
12 +
  align-items: center;
13 +
  text-align: center;
14 +
  gap: 1.5rem;
15 +
}
16 +
17 +
.quote-hero .quote-text {
18 +
  margin: 0;
19 +
  padding: 0;
20 +
  border: 0;
21 +
  font-size: 1.5rem;
22 +
  line-height: 1.6;
23 +
  max-width: 38rem;
24 +
  color: #ffffff;
25 +
}
26 +
27 +
.quote-hero .attribution {
28 +
  margin: 0;
29 +
  opacity: 0.5;
30 +
  font-size: 0.875rem;
31 +
}
32 +
33 +
.quote-hero cite {
34 +
  font-style: italic;
35 +
}
36 +
37 +
.admin-list-meta cite {
38 +
  font-style: italic;
39 +
}
apps/quotes/templates/admin.html (added) +79 −0
1 +
<!doctype html>
2 +
<html lang="en">
3 +
  <head>
4 +
    <meta charset="UTF-8" />
5 +
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 +
    <meta name="theme-color" content="#121113" />
7 +
    <link rel="stylesheet" href="/assets/darkmatter.css" />
8 +
    <link rel="stylesheet" href="/static/styles.css" />
9 +
    <link rel="manifest" href="/static/site.webmanifest" />
10 +
    <title>Quotes | Admin</title>
11 +
  </head>
12 +
  <body>
13 +
    <div class="header">
14 +
      <a href="/" class="logo">QUOTES</a>
15 +
      <nav class="links">
16 +
        <a href="/admin/logout">logout</a>
17 +
      </nav>
18 +
    </div>
19 +
20 +
    {{if .Success}}<p class="success">{{.Success}}</p>{{end}}
21 +
    {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
22 +
23 +
    <form class="form" method="POST" action="/admin/add">
24 +
      <div class="form-field">
25 +
        <label for="text">Quote</label>
26 +
        <textarea id="text" name="text" rows="3" placeholder="the quote" required></textarea>
27 +
      </div>
28 +
      <div class="form-row">
29 +
        <div class="form-field">
30 +
          <label for="author">Author</label>
31 +
          <input type="text" id="author" name="author" placeholder="author" required />
32 +
        </div>
33 +
        <div class="form-field">
34 +
          <label for="source">Source</label>
35 +
          <input type="text" id="source" name="source" placeholder="book / work (optional)" />
36 +
        </div>
37 +
      </div>
38 +
      <div class="form-actions">
39 +
        <button type="submit">Add quote</button>
40 +
      </div>
41 +
    </form>
42 +
43 +
    <div class="admin-toolbar">
44 +
      <h2>Quotes ({{.Total}})</h2>
45 +
      <form method="GET" action="/admin" class="form-row">
46 +
        <div class="form-field">
47 +
          <input type="text" name="q" placeholder="search text, author, source" value="{{.Query}}" />
48 +
        </div>
49 +
        <button type="submit">Search</button>
50 +
        {{if .Searched}}<a href="/admin" class="link-button">clear</a>{{end}}
51 +
      </form>
52 +
    </div>
53 +
54 +
    {{if not .Quotes}}
55 +
    <p class="empty">
56 +
      {{if .Searched}}No matches.{{else}}No quotes yet. Add one above or seed from the CSV.{{end}}
57 +
    </p>
58 +
    {{else}}
59 +
    <ul class="admin-list">
60 +
      {{range .Quotes}}
61 +
      <li class="admin-list-item">
62 +
        <div class="admin-list-info">
63 +
          <span class="admin-list-title">{{.Text}}</span>
64 +
          <span class="admin-list-meta">&mdash; {{.Author}}{{if .Source}}, <cite>{{.Source}}</cite>{{end}}</span>
65 +
        </div>
66 +
        <div class="admin-list-actions">
67 +
          <form method="POST" action="/admin/quotes/{{.ShortID}}/delete" class="inline-form">
68 +
            <button type="submit" class="link-button danger">delete</button>
69 +
          </form>
70 +
        </div>
71 +
      </li>
72 +
      {{end}}
73 +
    </ul>
74 +
    {{if not .Searched}}
75 +
    <p class="empty">Showing the 50 most recent. Use search to find older quotes.</p>
76 +
    {{end}}
77 +
    {{end}}
78 +
  </body>
79 +
</html>
apps/quotes/templates/index.html (added) +33 −0
1 +
<!doctype html>
2 +
<html lang="en">
3 +
  <head>
4 +
    <meta charset="UTF-8" />
5 +
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 +
    <meta name="theme-color" content="#121113" />
7 +
    <link rel="stylesheet" href="/assets/darkmatter.css" />
8 +
    <link rel="stylesheet" href="/static/styles.css" />
9 +
    <link rel="manifest" href="/static/site.webmanifest" />
10 +
    <title>Quotes</title>
11 +
    <meta name="description" content="A quote a day from classic literature" />
12 +
    <meta property="og:url" content="{{.BaseURL}}" />
13 +
    <meta property="og:type" content="website" />
14 +
    <meta property="og:title" content="Quotes" />
15 +
    <meta property="og:description" content="A quote a day from classic literature" />
16 +
    <meta name="twitter:card" content="summary_large_image" />
17 +
    <meta property="twitter:url" content="{{.BaseURL}}" />
18 +
    <meta name="twitter:title" content="Quotes" />
19 +
    <meta name="twitter:description" content="A quote a day from classic literature" />
20 +
  </head>
21 +
  <body>
22 +
    <main class="quote-hero">
23 +
      {{if .Quote}}
24 +
      <blockquote class="quote-text">{{.Quote.Text}}</blockquote>
25 +
      <p class="attribution">
26 +
        &mdash; {{.Quote.Author}}{{if .Quote.Source}}, <cite>{{.Quote.Source}}</cite>{{end}}
27 +
      </p>
28 +
      {{else}}
29 +
      <p class="attribution">No quotes yet.</p>
30 +
      {{end}}
31 +
    </main>
32 +
  </body>
33 +
</html>
apps/quotes/templates/login.html (added) +27 −0
1 +
<!doctype html>
2 +
<html lang="en">
3 +
  <head>
4 +
    <meta charset="UTF-8" />
5 +
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 +
    <meta name="theme-color" content="#121113" />
7 +
    <link rel="stylesheet" href="/assets/darkmatter.css" />
8 +
    <link rel="stylesheet" href="/static/styles.css" />
9 +
    <link rel="manifest" href="/static/site.webmanifest" />
10 +
    <title>Quotes | Login</title>
11 +
  </head>
12 +
  <body>
13 +
    <div class="header">
14 +
      <a href="/" class="logo">QUOTES</a>
15 +
    </div>
16 +
    {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
17 +
    <form class="form" method="POST" action="/admin/login">
18 +
      <div class="form-field">
19 +
        <label for="password">Password</label>
20 +
        <input type="password" id="password" name="password" required autofocus />
21 +
      </div>
22 +
      <div class="form-actions">
23 +
        <button type="submit">Login</button>
24 +
      </div>
25 +
    </form>
26 +
  </body>
27 +
</html>
docker-compose.yml +13 −0
105 105
      - blobs_data:/data
106 106
    env_file: apps/blobs/.env
107 107
108 +
  quotes:
109 +
    image: ghcr.io/stevedylandev/andromeda/quotes:latest
110 +
    restart: unless-stopped
111 +
    ports:
112 +
      - "4040:3000"
113 +
    volumes:
114 +
      - quotes_data:/data
115 +
    env_file: apps/quotes/.env
116 +
108 117
  backup:
109 118
    image: ghcr.io/stevedylandev/andromeda/backup:latest
110 119
    volumes:
117 126
      - bookmarks_data:/data/bookmarks:ro
118 127
      - easel_data:/data/easel:ro
119 128
      - blobs_data:/data/blobs:ro
129 +
      - quotes_data:/data/quotes:ro
120 130
    env_file: apps/backup/.env
121 131
    restart: unless-stopped
122 132
151 161
  blobs_data:
152 162
    external: true
153 163
    name: blobs_blobs-data
164 +
  quotes_data:
165 +
    external: true
166 +
    name: quotes_quotes-data