Initial commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
content/
|
||||||
|
public/
|
||||||
|
photogallery
|
||||||
|
result
|
||||||
|
result-*
|
||||||
|
*.tar.gz
|
||||||
|
.envrc
|
||||||
|
.direnv/
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed templates static
|
||||||
|
var assets embed.FS
|
||||||
Generated
+61
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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=
|
||||||
@@ -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
@@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
})
|
||||||
|
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user