Initial commit

This commit is contained in:
2026-07-02 00:53:17 +02:00
commit fd2828f1e7
17 changed files with 1627 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
content/
public/
photogallery
result
result-*
*.tar.gz
.envrc
.direnv/
+101
View File
@@ -0,0 +1,101 @@
# photogallery
Personal static photo gallery with Atom feed and upload API. Single-user.
No database, no framework — just a Go binary that watches a directory and regenerates a static site.
## Architecture
```
content/ # images + .toml sidecars (source of truth)
public/ # generated output (served by nginx / --serve flag)
templates/ # embedded HTML templates
static/ # embedded CSS
```
The binary does three things at once:
- Watches `content/` via fsnotify and rebuilds on any change
- Serves a multipart upload API at `POST /upload`
- In dev mode (`--serve`), also serves `public/` as static files
In production nginx sits in front: serves `public/` directly, proxies `/upload` to localhost, htpasswd on the upload endpoint.
## Stack
- **Go** — stdlib + `BurntSushi/toml`, `fsnotify/fsnotify`, `golang.org/x/image`
- **Templates** — `html/template`, embedded via `embed.FS`
- **Images** — centre-crop to square, Catmull-Rom resize to 600×600 JPEG thumbnails
- **Feed** — Atom (`encoding/xml`)
- **NixOS** — `flake.nix` + `module.nix`, exports `nixosModules.default`
## Sidecar format
```toml
caption = "Tempelhof at sunrise"
date = "2026-06-14"
```
Missing sidecar → filename as caption, today as date.
## Design
- Gruvbox dark hard colour scheme
- Vollkorn (serif) for headings and captions
- Manjari for Malayalam text (bio line)
- 3-column square grid → 2-column on mobile
- Single photo page with sidebar
## Running locally
```bash
nix develop
go mod tidy
go run . --serve
# gallery: http://localhost:8080
# upload: http://localhost:8080/upload
```
## NixOS module usage
```nix
inputs.photogallery.url = "github:youruser/photogallery";
# in modules:
inputs.photogallery.nixosModules.default
# in configuration.nix:
services.photogallery = {
enable = true;
baseURL = "https://photos.yourdomain.tld";
nginx = {
enable = true;
domain = "photos.yourdomain.tld";
htpasswdFile = "/etc/nginx/.htpasswd-gallery";
};
};
```
## Bootstrap (first nix build)
```bash
go mod tidy # generates go.sum — commit it
nix build # fails, prints correct vendorHash
# paste hash into flake.nix
nix build # succeeds
```
## Key files
| File | Purpose |
|---|---|
| `main.go` | Flags, wires generator + watcher + server |
| `generator.go` | Scans content dir, renders HTML + Atom feed, generates thumbs |
| `server.go` | Upload API + optional static file serving |
| `watcher.go` | fsnotify watcher with 500ms debounce |
| `assets.go` | `//go:embed` for templates and static |
| `module.nix` | NixOS module (systemd service, tmpfiles, nginx vhost) |
| `flake.nix` | `buildGoModule` package + devShell + nixosModules |
## What's not done yet
- ActivityPub federation (planned later — start with RSS/Atom)
- Multi-image upload
- Delete/edit via UI
- Any auth beyond nginx htpasswd
+112
View File
@@ -0,0 +1,112 @@
# photogallery
A minimal static photo gallery with an Atom feed and an upload API.
## How it works
1. Drop `photo.jpg` + `photo.toml` into the content directory (or upload via the web UI at `/upload`).
2. The binary watches the directory and rebuilds the static site on any change.
3. nginx serves `public/` and proxies `/upload` to the local API, secured with htpasswd.
## Sidecar format
```toml
caption = "Tempelhof at sunrise"
date = "2026-06-14"
```
If no `.toml` is present the filename is used as the caption and today's date as the date.
---
## Using as a NixOS flake module
### 1. Add to your flake inputs
```nix
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
photogallery.url = "github:youruser/photogallery";
};
```
### 2. Import the module
```nix
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
modules = [
inputs.photogallery.nixosModules.default
./configuration.nix
];
};
```
### 3. Configure
```nix
services.photogallery = {
enable = true;
baseURL = "https://photos.yourdomain.tld";
# Optional: built-in nginx vhost
nginx = {
enable = true;
domain = "photos.yourdomain.tld";
htpasswdFile = "/etc/nginx/.htpasswd-gallery";
};
};
```
Create the htpasswd file:
```bash
nix-shell -p apacheHttpd --run \
"htpasswd -c /etc/nginx/.htpasswd-gallery youruser"
```
### Available options
| Option | Default | Description |
|-----------------------------|------------------------------------|--------------------------------------|
| `enable` | `false` | Enable the service |
| `baseURL` | — | Public URL, no trailing slash |
| `contentDir` | `/var/lib/photogallery/content` | Images and .toml sidecars |
| `outputDir` | `/var/lib/photogallery/public` | Generated site output |
| `port` | `8080` | Upload API port (localhost only) |
| `nginx.enable` | `false` | Configure an nginx vhost |
| `nginx.domain` | — | Domain for the vhost |
| `nginx.htpasswdFile` | — | htpasswd file for `/upload` |
| `nginx.enableACME` | `true` | Enable Let's Encrypt |
---
## Bootstrap (first build)
`buildGoModule` needs a `go.sum` and the correct `vendorHash`.
```bash
# 1. Generate go.sum
go mod tidy
# 2. Commit go.sum
git add go.sum && git commit -m "add go.sum"
# 3. Get the vendorHash — this will fail and print the correct hash
nix build
# error: hash mismatch ... got: sha256-XXXXX...
# 4. Paste the hash into flake.nix, then build again
nix build
```
## Uploading via curl
```bash
curl -u user:password \
-F "image=@photo.jpg" \
-F "caption=Tempelhof at sunrise" \
-F "date=2026-06-14" \
https://photos.yourdomain.tld/upload
```
Or open `https://photos.yourdomain.tld/upload` in a browser.
+6
View File
@@ -0,0 +1,6 @@
package main
import "embed"
//go:embed templates static
var assets embed.FS
Generated
+61
View File
@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1782723713,
"narHash": "sha256-oPXCU/SSUokcGaJREHibG1CBX3+s/W7orDWQOZDsEeQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b5aa0fbd538984f6e3d201be0005b4463d8b09f8",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+44
View File
@@ -0,0 +1,44 @@
{
description = "Minimal static photo gallery with Atom feed and upload API";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in {
packages.default = pkgs.buildGoModule {
pname = "photogallery";
version = "0.1.0";
src = self;
# Run `go mod tidy` to generate go.sum, then `nix build`.
# Nix will fail and print the correct hash — paste it here.
vendorHash = "sha256-SxQDRProQtauWXPvPzbZZChLQd0iey9hnXFLiPYTS98=";
meta = with pkgs.lib; {
description = "Minimal static photo gallery";
license = licenses.mit;
mainProgram = "photogallery";
platforms = platforms.linux;
};
};
devShells.default = pkgs.mkShell {
packages = [ pkgs.go pkgs.gopls pkgs.gotools ];
};
}
) // {
nixosModules.default = import ./module.nix self;
# Lets consumers add photogallery to their pkgs without
# pulling in the full flake module.
overlays.default = _final: _prev: {
photogallery = self.packages.${_final.system}.default;
};
};
}
+318
View File
@@ -0,0 +1,318 @@
package main
import (
"encoding/xml"
"html/template"
imagedraw "image/draw"
"image"
"image/jpeg"
_ "image/png"
"io"
"log"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/BurntSushi/toml"
xdraw "golang.org/x/image/draw"
)
const thumbSize = 600
// PhotoMeta is the structure of a .toml sidecar file.
type PhotoMeta struct {
Caption string `toml:"caption"`
Date string `toml:"date"` // YYYY-MM-DD
}
// Photo is the in-memory representation of one image.
type Photo struct {
Slug string
File string // relative path under public/, e.g. "images/foo.jpg"
Thumb string // relative path under public/, e.g. "thumbs/foo.jpg"
Caption string
Date time.Time
}
type Generator struct {
contentDir string
outputDir string
baseURL string
mu sync.Mutex
tmpl *template.Template
}
func NewGenerator(contentDir, outputDir, baseURL string) *Generator {
tmpl := template.Must(template.ParseFS(assets, "templates/*.html"))
return &Generator{
contentDir: contentDir,
outputDir: outputDir,
baseURL: baseURL,
tmpl: tmpl,
}
}
// Build performs a full rebuild of the site. It is safe to call concurrently;
// concurrent calls are serialised by a mutex.
func (g *Generator) Build() error {
g.mu.Lock()
defer g.mu.Unlock()
photos, err := g.loadPhotos()
if err != nil {
return err
}
for _, d := range []string{"images", "thumbs", "photo", "static"} {
if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil {
return err
}
}
for i := range photos {
if err := g.processImage(&photos[i]); err != nil {
log.Printf("processImage %s: %v", photos[i].Slug, err)
}
}
if err := g.renderIndex(photos); err != nil {
return err
}
for _, p := range photos {
if err := g.renderPhotoPage(p); err != nil {
return err
}
}
if err := g.renderFeed(photos); err != nil {
return err
}
if err := g.copyStatic(); err != nil {
return err
}
log.Printf("built: %d photos", len(photos))
return nil
}
// loadPhotos scans the content directory and returns photos sorted newest first.
func (g *Generator) loadPhotos() ([]Photo, error) {
entries, err := os.ReadDir(g.contentDir)
if err != nil {
return nil, err
}
var photos []Photo
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
ext := strings.ToLower(filepath.Ext(name))
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" {
continue
}
slug := strings.TrimSuffix(name, filepath.Ext(name))
meta := PhotoMeta{
Caption: slug,
Date: time.Now().Format("2006-01-02"),
}
tomlPath := filepath.Join(g.contentDir, slug+".toml")
if _, err := os.Stat(tomlPath); err == nil {
if _, err := toml.DecodeFile(tomlPath, &meta); err != nil {
log.Printf("parsing %s: %v", tomlPath, err)
}
}
date, err := time.Parse("2006-01-02", meta.Date)
if err != nil {
date = time.Now()
}
photos = append(photos, Photo{
Slug: slug,
File: "images/" + name,
Thumb: "thumbs/" + slug + ".jpg",
Caption: meta.Caption,
Date: date,
})
}
sort.Slice(photos, func(i, j int) bool {
return photos[i].Date.After(photos[j].Date)
})
return photos, nil
}
// processImage copies the original to public/images/ and writes a square
// thumbnail to public/thumbs/.
func (g *Generator) processImage(p *Photo) error {
src := filepath.Join(g.contentDir, filepath.Base(p.File))
if err := copyFile(src, filepath.Join(g.outputDir, p.File)); err != nil {
return err
}
return generateThumb(src, filepath.Join(g.outputDir, p.Thumb), thumbSize)
}
// generateThumb reads src, centre-crops it to a square, scales it to size×size,
// and writes a JPEG to dst.
func generateThumb(src, dst string, size int) error {
f, err := os.Open(src)
if err != nil {
return err
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return err
}
// Centre crop to square.
b := img.Bounds()
w, h := b.Dx(), b.Dy()
crop := w
if h < w {
crop = h
}
x0 := (w - crop) / 2
y0 := (h - crop) / 2
cropped := image.NewRGBA(image.Rect(0, 0, crop, crop))
imagedraw.Draw(cropped, cropped.Bounds(), img, image.Point{x0, y0}, imagedraw.Src)
// Scale to thumbSize×thumbSize using Catmull-Rom for quality.
thumb := image.NewRGBA(image.Rect(0, 0, size, size))
xdraw.CatmullRom.Scale(thumb, thumb.Bounds(), cropped, cropped.Bounds(), xdraw.Over, nil)
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
return jpeg.Encode(out, thumb, &jpeg.Options{Quality: 85})
}
// renderIndex writes public/index.html.
func (g *Generator) renderIndex(photos []Photo) error {
return g.render("index.html", filepath.Join(g.outputDir, "index.html"), map[string]any{
"Photos": photos,
"BaseURL": g.baseURL,
})
}
// renderPhotoPage writes public/photo/<slug>/index.html.
func (g *Generator) renderPhotoPage(p Photo) error {
dir := filepath.Join(g.outputDir, "photo", p.Slug)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
return g.render("photo.html", filepath.Join(dir, "index.html"), map[string]any{
"Photo": p,
"BaseURL": g.baseURL,
})
}
func (g *Generator) render(tmpl, dst string, data any) error {
f, err := os.Create(dst)
if err != nil {
return err
}
defer f.Close()
return g.tmpl.ExecuteTemplate(f, tmpl, data)
}
// Atom feed ---------------------------------------------------------------
type atomFeed struct {
XMLName xml.Name `xml:"feed"`
Xmlns string `xml:"xmlns,attr"`
Title string `xml:"title"`
Link atomLink `xml:"link"`
Updated string `xml:"updated"`
ID string `xml:"id"`
Entries []atomEntry `xml:"entry"`
}
type atomLink struct {
Href string `xml:"href,attr"`
Rel string `xml:"rel,attr,omitempty"`
}
type atomEntry struct {
Title string `xml:"title"`
Link atomLink `xml:"link"`
ID string `xml:"id"`
Updated string `xml:"updated"`
Summary string `xml:"summary"`
}
func (g *Generator) renderFeed(photos []Photo) error {
feed := atomFeed{
Xmlns: "http://www.w3.org/2005/Atom",
Title: "Photo Gallery",
Link: atomLink{Href: g.baseURL + "/feed.xml", Rel: "self"},
ID: g.baseURL + "/",
}
if len(photos) > 0 {
feed.Updated = photos[0].Date.Format(time.RFC3339)
}
for _, p := range photos {
url := g.baseURL + "/photo/" + p.Slug + "/"
feed.Entries = append(feed.Entries, atomEntry{
Title: p.Caption,
Link: atomLink{Href: url},
ID: url,
Updated: p.Date.Format(time.RFC3339),
Summary: p.Caption,
})
}
f, err := os.Create(filepath.Join(g.outputDir, "feed.xml"))
if err != nil {
return err
}
defer f.Close()
f.WriteString(xml.Header)
enc := xml.NewEncoder(f)
enc.Indent("", " ")
return enc.Encode(feed)
}
// copyStatic copies embedded static/ assets to public/static/.
func (g *Generator) copyStatic() error {
entries, err := assets.ReadDir("static")
if err != nil {
return err
}
for _, e := range entries {
data, err := assets.ReadFile("static/" + e.Name())
if err != nil {
return err
}
dst := filepath.Join(g.outputDir, "static", e.Name())
if err := os.WriteFile(dst, data, 0644); err != nil {
return err
}
}
return nil
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}
+11
View File
@@ -0,0 +1,11 @@
module photogallery
go 1.22
require (
github.com/BurntSushi/toml v1.4.0
github.com/fsnotify/fsnotify v1.7.0
golang.org/x/image v0.21.0
)
require golang.org/x/sys v0.4.0 // indirect
+8
View File
@@ -0,0 +1,8 @@
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s=
golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78=
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+38
View File
@@ -0,0 +1,38 @@
package main
import (
"flag"
"log"
"os"
)
func main() {
contentDir := flag.String("content", "./content", "directory containing images and .toml sidecars")
outputDir := flag.String("output", "./public", "directory for generated site output")
baseURL := flag.String("base-url", "http://localhost:8080", "public base URL, no trailing slash")
port := flag.String("port", "8080", "port to listen on")
serve := flag.Bool("serve", false, "serve the generated site on the same port (for local development)")
flag.Parse()
for _, d := range []string{*contentDir, *outputDir} {
if err := os.MkdirAll(d, 0755); err != nil {
log.Fatalf("mkdir %s: %v", d, err)
}
}
g := NewGenerator(*contentDir, *outputDir, *baseURL)
if err := g.Build(); err != nil {
log.Printf("initial build: %v", err)
}
w, err := NewWatcher(*contentDir, g)
if err != nil {
log.Fatalf("watcher: %v", err)
}
defer w.Close()
go w.Watch()
s := NewServer(*port, *contentDir, *outputDir, *serve, g)
log.Fatal(s.Listen())
}
+135
View File
@@ -0,0 +1,135 @@
self:
{ config, lib, pkgs, ... }:
let
cfg = config.services.photogallery;
pkg = self.packages.${pkgs.system}.default;
in {
options.services.photogallery = {
enable = lib.mkEnableOption "photogallery static photo gallery";
contentDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/photogallery/content";
description = "Directory containing images and .toml sidecars.";
};
outputDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/photogallery/public";
description = "Directory for the generated static site.";
};
baseURL = lib.mkOption {
type = lib.types.str;
example = "https://photos.example.com";
description = "Public base URL, no trailing slash.";
};
port = lib.mkOption {
type = lib.types.port;
default = 8080;
description = "Upload API port. Always bound to 127.0.0.1.";
};
nginx = {
enable = lib.mkEnableOption "nginx virtual host for photogallery";
domain = lib.mkOption {
type = lib.types.str;
example = "photos.example.com";
description = "Domain for the nginx virtual host.";
};
htpasswdFile = lib.mkOption {
type = lib.types.path;
example = "/etc/nginx/.htpasswd-gallery";
description = "Path to htpasswd file protecting the /upload endpoint.";
};
enableACME = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Enable ACME/Let's Encrypt for TLS.";
};
};
};
config = lib.mkIf cfg.enable (lib.mkMerge [
# ── Core service ──────────────────────────────────────────────────────
{
users.users.photogallery = {
isSystemUser = true;
group = "photogallery";
home = "/var/lib/photogallery";
createHome = true;
description = "photogallery service user";
};
users.groups.photogallery = {};
systemd.tmpfiles.rules = [
"d ${cfg.contentDir} 0755 photogallery photogallery -"
"d ${cfg.outputDir} 0755 photogallery photogallery -"
];
systemd.services.photogallery = {
description = "photogallery static site generator";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
User = "photogallery";
Group = "photogallery";
ExecStart = lib.escapeShellArgs [
"${pkg}/bin/photogallery"
"--content" cfg.contentDir
"--output" cfg.outputDir
"--base-url" cfg.baseURL
"--port" (toString cfg.port)
];
Restart = "on-failure";
RestartSec = "5s";
# Hardening
NoNewPrivileges = true;
PrivateTmp = true;
PrivateDevices = true;
ProtectSystem = "strict";
ProtectHome = true;
ReadWritePaths = [ cfg.contentDir cfg.outputDir ];
};
};
}
# ── Optional nginx vhost ──────────────────────────────────────────────
(lib.mkIf cfg.nginx.enable {
services.nginx = {
enable = true;
virtualHosts.${cfg.nginx.domain} = {
enableACME = cfg.nginx.enableACME;
forceSSL = cfg.nginx.enableACME;
root = cfg.outputDir;
locations."/" = {
tryFiles = "$uri $uri/ =404";
};
locations."/upload" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}";
basicAuth = "gallery";
basicAuthFile = cfg.nginx.htpasswdFile;
};
};
};
})
]);
}
+152
View File
@@ -0,0 +1,152 @@
package main
import (
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/BurntSushi/toml"
)
type Server struct {
port string
contentDir string
outputDir string
serve bool
generator *Generator
}
func NewServer(port, contentDir, outputDir string, serve bool, g *Generator) *Server {
return &Server{
port: port,
contentDir: contentDir,
outputDir: outputDir,
serve: serve,
generator: g,
}
}
func (s *Server) Listen() error {
mux := http.NewServeMux()
mux.HandleFunc("GET /upload", s.handleUploadPage)
mux.HandleFunc("POST /upload", s.handleUpload)
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})
if s.serve {
// Serve the generated site on the same port — for local development only.
// In production nginx handles this and the server binds to localhost.
mux.Handle("/", http.FileServer(http.Dir(s.outputDir)))
log.Printf("gallery: http://localhost:%s", s.port)
log.Printf("upload: http://localhost:%s/upload", s.port)
return http.ListenAndServe(":"+s.port, mux)
}
log.Printf("upload API on 127.0.0.1:%s", s.port)
return http.ListenAndServe("127.0.0.1:"+s.port, mux)
}
func (s *Server) handleUploadPage(w http.ResponseWriter, r *http.Request) {
data, err := assets.ReadFile("templates/upload.html")
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(data)
}
// handleUpload accepts multipart/form-data with fields:
//
// image — the image file (jpg or png)
// caption — human-readable caption (optional, defaults to filename)
// date — YYYY-MM-DD (optional, defaults to today)
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("image")
if err != nil {
http.Error(w, "missing 'image' field", http.StatusBadRequest)
return
}
defer file.Close()
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" {
http.Error(w, "only jpg and png are accepted", http.StatusBadRequest)
return
}
caption := r.FormValue("caption")
dateStr := r.FormValue("date")
if dateStr == "" {
dateStr = time.Now().Format("2006-01-02")
}
if caption == "" {
caption = strings.TrimSuffix(header.Filename, ext)
}
slug := sanitiseSlug(strings.TrimSuffix(header.Filename, ext))
if slug == "" {
slug = fmt.Sprintf("photo-%d", time.Now().UnixMilli())
}
imgPath := filepath.Join(s.contentDir, slug+ext)
tomlPath := filepath.Join(s.contentDir, slug+".toml")
out, err := os.Create(imgPath)
if err != nil {
log.Printf("create %s: %v", imgPath, err)
http.Error(w, "could not save image", http.StatusInternalServerError)
return
}
defer out.Close()
if _, err := io.Copy(out, file); err != nil {
http.Error(w, "could not write image", http.StatusInternalServerError)
return
}
tf, err := os.Create(tomlPath)
if err != nil {
log.Printf("create %s: %v", tomlPath, err)
http.Error(w, "could not save metadata", http.StatusInternalServerError)
return
}
defer tf.Close()
if err := toml.NewEncoder(tf).Encode(PhotoMeta{Caption: caption, Date: dateStr}); err != nil {
http.Error(w, "could not write metadata", http.StatusInternalServerError)
return
}
// Build() is mutex-guarded so calling it here and from the watcher is safe.
go func() {
if err := s.generator.Build(); err != nil {
log.Printf("rebuild after upload: %v", err)
}
}()
w.WriteHeader(http.StatusCreated)
fmt.Fprintf(w, "uploaded %s\n", slug+ext)
}
// sanitiseSlug returns a filesystem- and URL-safe version of s.
func sanitiseSlug(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(s) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
b.WriteRune(r)
case r == ' ', r == '_':
b.WriteRune('-')
}
}
return b.String()
}
+270
View File
@@ -0,0 +1,270 @@
:root {
--bg: #1d2021;
--bg0: #282828;
--bg1: #3c3836;
--bg2: #504945;
--fg: #ebdbb2;
--fg2: #d5c4a1;
--fg3: #bdae93;
--fg4: #a89984;
--aqua: #8ec07c;
--line: #3c3836;
--vk: 'Vollkorn', Georgia, serif;
--mj: 'Manjari', sans-serif;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background: var(--bg);
color: var(--fg);
font-family: var(--vk);
min-height: 100vh;
}
/* ── Profile header ────────────────────────────────────────── */
.profile-hdr {
border-bottom: 1px solid var(--line);
padding: 28px 20px 22px;
}
.profile-inner {
max-width: 935px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 36px;
}
.avatar-ring {
width: 84px;
height: 84px;
border-radius: 50%;
border: 2px solid var(--bg2);
overflow: hidden;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg1);
}
.avatar-ring img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.avatar-initials {
font-size: 32px;
font-weight: 700;
color: var(--fg3);
font-family: var(--vk);
}
.pinfo h1 {
font-family: var(--vk);
font-size: 26px;
font-weight: 700;
color: var(--fg);
margin-bottom: 3px;
}
.handle {
font-size: 12px;
color: var(--fg4);
font-family: monospace;
margin-bottom: 10px;
letter-spacing: 0.02em;
}
.pcount {
font-size: 15px;
color: var(--fg3);
margin-bottom: 8px;
}
.pcount strong {
color: var(--fg);
font-weight: 600;
}
.pbio {
font-size: 15px;
color: var(--fg2);
margin-bottom: 10px;
line-height: 1.7;
max-width: 280px;
font-style: italic;
}
.ml {
font-family: var(--mj);
font-style: normal;
font-weight: 400;
}
.rss-a {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: var(--aqua);
text-decoration: none;
text-transform: uppercase;
letter-spacing: 0.05em;
font-family: monospace;
}
.rss-a:hover {
color: var(--fg2);
}
/* ── Photo grid ────────────────────────────────────────────── */
.grid-wrap {
max-width: 935px;
margin: 0 auto;
padding: 3px 0;
}
.pgrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 3px;
}
.ptile {
aspect-ratio: 1;
overflow: hidden;
display: block;
background: var(--bg1);
}
.ptile img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.3s ease;
}
.ptile:hover img {
transform: scale(1.05);
}
/* ── Single photo page ─────────────────────────────────────── */
.photo-page {
max-width: 935px;
margin: 0 auto;
padding: 16px 20px;
}
.back-btn {
display: inline-block;
font-size: 15px;
color: var(--fg3);
text-decoration: none;
padding: 8px 0;
margin-bottom: 16px;
font-family: var(--vk);
}
.back-btn:hover {
color: var(--fg);
}
.dlayout {
display: flex;
border: 1px solid var(--line);
}
.dimg-wrap {
flex: 1;
aspect-ratio: 1;
overflow: hidden;
background: var(--bg1);
}
.dimg-wrap img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.dsidebar {
width: 220px;
flex-shrink: 0;
padding: 18px;
border-left: 1px solid var(--line);
background: var(--bg0);
}
.dauthor {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 14px;
border-bottom: 1px solid var(--line);
margin-bottom: 14px;
}
.mini-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid var(--bg2);
background: var(--bg1);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
color: var(--fg3);
font-family: var(--vk);
flex-shrink: 0;
}
.dauthor-name {
font-size: 14px;
font-weight: 600;
color: var(--fg);
font-family: var(--vk);
}
.dcaption {
font-family: var(--vk);
font-size: 17px;
font-style: italic;
color: var(--fg);
margin-bottom: 10px;
line-height: 1.6;
}
.ddate {
font-size: 11px;
color: var(--fg4);
font-family: monospace;
text-transform: uppercase;
letter-spacing: 0.06em;
}
/* ── Responsive ────────────────────────────────────────────── */
@media (max-width: 600px) {
.profile-inner { gap: 18px; }
.avatar-ring { width: 60px; height: 60px; }
.avatar-initials { font-size: 22px; }
.pinfo h1 { font-size: 20px; }
.pgrid { grid-template-columns: repeat(2, 1fr); }
.dlayout { flex-direction: column; }
.dsidebar { width: 100%; border-left: none; border-top: 1px solid var(--line); }
}
+46
View File
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Photo Gallery</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Vollkorn:ital,wght@0,400;0,600;0,700;1,400&family=Manjari:wght@100;400;700&display=swap">
<link rel="stylesheet" href="/static/style.css">
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Photo Feed">
</head>
<body>
<header class="profile-hdr">
<div class="profile-inner">
<div class="avatar-ring">
<span class="avatar-initials">A</span>
</div>
<div class="pinfo">
<h1>Ashik</h1>
<p class="handle">@ashik@yourdomain.tld</p>
<p class="pcount"><strong>{{len .Photos}}</strong> posts</p>
<p class="pbio">Personal photo journal. Berlin-based. <span class="ml">വെളിച്ചം കൊണ്ട് വരയ്ക്കുന്നു.</span></p>
<a href="/feed.xml" class="rss-a">
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true">
<circle cx="2.5" cy="11.5" r="1.5"/>
<path d="M1.5 7.5A5 5 0 0 1 6.5 12.5H8A6.5 6.5 0 0 0 1.5 6V7.5z"/>
<path d="M1.5 4A8.5 8.5 0 0 1 10 12.5h1.5A10 10 0 0 0 1.5 2.5V4z"/>
</svg>
RSS feed
</a>
</div>
</div>
</header>
<main>
<div class="grid-wrap">
<div class="pgrid">
{{range .Photos}}
<a href="/photo/{{.Slug}}/" class="ptile">
<img src="/{{.Thumb}}" alt="{{.Caption}}" loading="lazy">
</a>
{{end}}
</div>
</div>
</main>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Photo.Caption}}</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Vollkorn:ital,wght@0,400;0,600;0,700;1,400&family=Manjari:wght@100;400;700&display=swap">
<link rel="stylesheet" href="/static/style.css">
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Photo Feed">
</head>
<body>
<div class="photo-page">
<a href="/" class="back-btn">← Back</a>
<div class="dlayout">
<div class="dimg-wrap">
<img src="/{{.Photo.File}}" alt="{{.Photo.Caption}}">
</div>
<div class="dsidebar">
<div class="dauthor">
<div class="mini-avatar"><span>A</span></div>
<span class="dauthor-name">Ashik</span>
</div>
<p class="dcaption">{{.Photo.Caption}}</p>
<p class="ddate">{{.Photo.Date.Format "Jan 02, 2006"}}</p>
</div>
</div>
</div>
</body>
</html>
+213
View File
@@ -0,0 +1,213 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Upload — Photo Gallery</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Vollkorn:ital,wght@0,400;0,600;0,700;1,400&family=Manjari:wght@100;400;700&display=swap">
<link rel="stylesheet" href="/static/style.css">
<style>
.upload-page {
max-width: 520px;
margin: 0 auto;
padding: 32px 20px;
}
.upload-page h2 {
font-family: var(--vk);
font-size: 22px;
font-weight: 600;
color: var(--fg);
margin-bottom: 28px;
}
.drop-zone {
border: 1px dashed var(--bg2);
background: var(--bg0);
width: 100%;
aspect-ratio: 1;
max-height: 340px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
margin-bottom: 24px;
position: relative;
overflow: hidden;
transition: border-color 0.2s;
}
.drop-zone:hover { border-color: var(--fg4); }
.drop-zone.has-image { border-style: solid; border-color: var(--bg2); }
.drop-zone input[type="file"] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
width: 100%;
height: 100%;
}
.drop-prompt {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: var(--fg4);
pointer-events: none;
}
.drop-prompt p { font-size: 14px; font-family: var(--vk); }
.drop-prompt small { font-size: 12px; font-family: monospace; }
#preview {
display: none;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.field { margin-bottom: 18px; }
.field label {
display: block;
font-size: 12px;
color: var(--fg4);
font-family: monospace;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 6px;
}
.field input[type="text"],
.field input[type="date"] {
width: 100%;
background: var(--bg0);
border: 1px solid var(--bg2);
color: var(--fg);
font-size: 16px;
padding: 10px 12px;
outline: none;
transition: border-color 0.2s;
}
.field input[type="text"] { font-family: var(--vk); }
.field input[type="date"] { font-family: monospace; font-size: 14px; color-scheme: dark; }
.field input:focus { border-color: var(--fg4); }
.submit-btn {
width: 100%;
background: var(--bg1);
border: 1px solid var(--bg2);
color: var(--fg2);
font-family: var(--vk);
font-size: 16px;
font-weight: 600;
padding: 12px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.submit-btn:hover:not(:disabled) { background: var(--bg2); color: var(--fg); }
.submit-btn:disabled { opacity: 0.35; cursor: not-allowed; }
#feedback {
display: none;
margin-top: 14px;
font-size: 13px;
font-family: monospace;
padding: 10px 12px;
}
#feedback.ok { display: block; background: #1d2e1d; color: #b8bb26; border: 1px solid #344d28; }
#feedback.err { display: block; background: #2e1d1d; color: #fb4934; border: 1px solid #5a2828; }
</style>
</head>
<body>
<div class="upload-page">
<a href="/" class="back-btn">← Gallery</a>
<h2>Upload photo</h2>
<div class="drop-zone" id="drop-zone">
<input type="file" id="file-input" accept=".jpg,.jpeg,.png" aria-label="Choose photo">
<div class="drop-prompt" id="prompt">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="1"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="M21 15l-5-5L5 21"/>
</svg>
<p>Click to choose a photo</p>
<small>jpg or png</small>
</div>
<img id="preview" alt="Preview">
</div>
<div class="field">
<label for="caption">Caption</label>
<input type="text" id="caption" placeholder="Describe the photo" autocomplete="off">
</div>
<div class="field">
<label for="date">Date</label>
<input type="date" id="date">
</div>
<button class="submit-btn" id="submit-btn" disabled>Upload photo</button>
<div id="feedback"></div>
</div>
<script>
const fileInput = document.getElementById('file-input');
const preview = document.getElementById('preview');
const prompt = document.getElementById('prompt');
const dropZone = document.getElementById('drop-zone');
const submitBtn = document.getElementById('submit-btn');
const feedback = document.getElementById('feedback');
const dateInput = document.getElementById('date');
dateInput.value = new Date().toISOString().slice(0, 10);
fileInput.addEventListener('change', () => {
const file = fileInput.files[0];
if (!file) return;
const url = URL.createObjectURL(file);
preview.onload = () => URL.revokeObjectURL(url);
preview.src = url;
preview.style.display = 'block';
prompt.style.display = 'none';
dropZone.classList.add('has-image');
submitBtn.disabled = false;
feedback.className = '';
feedback.style.display = 'none';
});
submitBtn.addEventListener('click', async () => {
const file = fileInput.files[0];
if (!file) return;
submitBtn.disabled = true;
submitBtn.textContent = 'Uploading…';
feedback.className = '';
const form = new FormData();
form.append('image', file);
form.append('caption', document.getElementById('caption').value);
form.append('date', dateInput.value);
try {
const res = await fetch('/upload', { method: 'POST', body: form });
const text = (await res.text()).trim();
if (res.ok) {
feedback.className = 'ok';
feedback.textContent = '✓ ' + text;
fileInput.value = '';
preview.style.display = 'none';
prompt.style.display = 'flex';
dropZone.classList.remove('has-image');
document.getElementById('caption').value = '';
submitBtn.disabled = true;
} else {
feedback.className = 'err';
feedback.textContent = '✗ ' + text;
submitBtn.disabled = false;
}
} catch (err) {
feedback.className = 'err';
feedback.textContent = '✗ ' + err.message;
submitBtn.disabled = false;
}
submitBtn.textContent = 'Upload photo';
});
</script>
</body>
</html>
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"log"
"path/filepath"
"strings"
"time"
"github.com/fsnotify/fsnotify"
)
type Watcher struct {
w *fsnotify.Watcher
g *Generator
done chan struct{}
}
func NewWatcher(dir string, g *Generator) (*Watcher, error) {
w, err := fsnotify.NewWatcher()
if err != nil {
return nil, err
}
if err := w.Add(dir); err != nil {
w.Close()
return nil, err
}
return &Watcher{w: w, g: g, done: make(chan struct{})}, nil
}
// Watch blocks, watching for changes and triggering rebuilds.
// Rapid events (e.g. scp writing an image then its sidecar) are debounced
// with a 500 ms window so only one rebuild fires per logical upload.
func (w *Watcher) Watch() {
var timer *time.Timer
rebuild := func() {
if err := w.g.Build(); err != nil {
log.Printf("rebuild: %v", err)
}
}
for {
select {
case event, ok := <-w.w.Events:
if !ok {
return
}
if !isRelevant(event.Name) {
continue
}
if event.Has(fsnotify.Create) || event.Has(fsnotify.Write) || event.Has(fsnotify.Remove) {
if timer != nil {
timer.Stop()
}
timer = time.AfterFunc(500*time.Millisecond, rebuild)
}
case err, ok := <-w.w.Errors:
if !ok {
return
}
log.Printf("watcher: %v", err)
case <-w.done:
return
}
}
}
func (w *Watcher) Close() {
close(w.done)
w.w.Close()
}
func isRelevant(name string) bool {
ext := strings.ToLower(filepath.Ext(name))
return ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".toml"
}