Make profile identity, bio and fonts configurable
The profile header, photo page byline and copyright footer previously
hardcoded the author name, handle, bio, and font choices. Move all of
these to CLI flags on the binary and matching options on the NixOS
module so the same build can serve any single-user gallery.
New surface, all with defaults matching the previous hardcoded values:
--author / services.photogallery.author
--handle / services.photogallery.handle
--bio / services.photogallery.bio
--bio-alt / services.photogallery.bioAlt ("" to omit)
--fonts-url / services.photogallery.fontsURL
--serif-family / services.photogallery.serifFamily
--ml-family / services.photogallery.mlFamily
Templates read the two font families from a small inline <style> that
overrides the :root variables, so the stylesheet stays fully static and
the font URL is loaded via the configured stylesheet link.
README updated to describe the new options, the medium/full-size photo
detail flow, and the EXIF-based date prefill on the upload page.
This commit is contained in:
@@ -8,6 +8,16 @@ A minimal static photo gallery with an Atom feed and an upload API.
|
|||||||
2. The binary watches the directory and rebuilds the static site on any change.
|
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.
|
3. nginx serves `public/` and proxies `/upload` to the local API, secured with htpasswd.
|
||||||
|
|
||||||
|
For each photo the generator produces a 600×600 square thumbnail for the grid
|
||||||
|
and an aspect-preserved medium JPEG (longest side 1600) for the detail page.
|
||||||
|
The original file stays reachable from a download link on the detail page.
|
||||||
|
Regeneration is skipped when the destination is at least as new as the source,
|
||||||
|
so editing one sidecar doesn't reprocess every photo.
|
||||||
|
|
||||||
|
The upload page reads `DateTimeOriginal` from a JPEG's EXIF (or falls back to
|
||||||
|
the file's modification time) and prefills the date field, which stays
|
||||||
|
editable before submitting.
|
||||||
|
|
||||||
## Sidecar format
|
## Sidecar format
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
@@ -48,6 +58,17 @@ services.photogallery = {
|
|||||||
enable = true;
|
enable = true;
|
||||||
baseURL = "https://photos.yourdomain.tld";
|
baseURL = "https://photos.yourdomain.tld";
|
||||||
|
|
||||||
|
# Optional: identity shown on the header and copyright footer
|
||||||
|
author = "Ada Lovelace";
|
||||||
|
handle = "@ada@example.com";
|
||||||
|
bio = "Photos from the field.";
|
||||||
|
bioAlt = ""; # optional secondary line in the alt-script font
|
||||||
|
|
||||||
|
# Optional: swap fonts
|
||||||
|
fontsURL = "https://fonts.googleapis.com/css2?family=EB+Garamond:wght@400;600;700&family=Manjari:wght@400;700&display=swap";
|
||||||
|
serifFamily = "EB Garamond";
|
||||||
|
mlFamily = "Manjari";
|
||||||
|
|
||||||
# Optional: built-in nginx vhost
|
# Optional: built-in nginx vhost
|
||||||
nginx = {
|
nginx = {
|
||||||
enable = true;
|
enable = true;
|
||||||
@@ -73,6 +94,13 @@ nix-shell -p apacheHttpd --run \
|
|||||||
| `contentDir` | `/var/lib/photogallery/content` | Images and .toml sidecars |
|
| `contentDir` | `/var/lib/photogallery/content` | Images and .toml sidecars |
|
||||||
| `outputDir` | `/var/lib/photogallery/public` | Generated site output |
|
| `outputDir` | `/var/lib/photogallery/public` | Generated site output |
|
||||||
| `port` | `8080` | Upload API port (localhost only) |
|
| `port` | `8080` | Upload API port (localhost only) |
|
||||||
|
| `author` | `Ashik Salahudeen` | Name shown on the header, photo pages and copyright footer |
|
||||||
|
| `handle` | `@puttaalu@inflo.ws` | Handle line under the author name |
|
||||||
|
| `bio` | `Personal photo journal.` | Primary bio line |
|
||||||
|
| `bioAlt` | Malayalam default | Secondary bio line in the alt-script font (`""` to omit) |
|
||||||
|
| `fontsURL` | Crimson Pro + Manjari | Stylesheet URL loading both web fonts |
|
||||||
|
| `serifFamily` | `Crimson Pro` | CSS font-family for the primary serif |
|
||||||
|
| `mlFamily` | `Manjari` | CSS font-family for the alt-script bio |
|
||||||
| `nginx.enable` | `false` | Configure an nginx vhost |
|
| `nginx.enable` | `false` | Configure an nginx vhost |
|
||||||
| `nginx.domain` | — | Domain for the vhost |
|
| `nginx.domain` | — | Domain for the vhost |
|
||||||
| `nginx.htpasswdFile` | — | htpasswd file for `/upload` |
|
| `nginx.htpasswdFile` | — | htpasswd file for `/upload` |
|
||||||
|
|||||||
+55
-20
@@ -3,8 +3,8 @@ package main
|
|||||||
import (
|
import (
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"html/template"
|
"html/template"
|
||||||
imagedraw "image/draw"
|
|
||||||
"image"
|
"image"
|
||||||
|
imagedraw "image/draw"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
"io"
|
"io"
|
||||||
@@ -42,23 +42,44 @@ type Photo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Generator struct {
|
type Generator struct {
|
||||||
contentDir string
|
contentDir string
|
||||||
outputDir string
|
outputDir string
|
||||||
baseURL string
|
baseURL string
|
||||||
mu sync.Mutex
|
author string
|
||||||
tmpl *template.Template
|
handle string
|
||||||
|
bio string
|
||||||
|
bioAlt string
|
||||||
|
fontsURL string
|
||||||
|
serifFamily string
|
||||||
|
mlFamily string
|
||||||
|
mu sync.Mutex
|
||||||
|
tmpl *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewGenerator(contentDir, outputDir, baseURL string) *Generator {
|
func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, fontsURL, serifFamily, mlFamily string) *Generator {
|
||||||
tmpl := template.Must(template.ParseFS(assets, "templates/*.html"))
|
tmpl := template.Must(template.ParseFS(assets, "templates/*.html"))
|
||||||
return &Generator{
|
return &Generator{
|
||||||
contentDir: contentDir,
|
contentDir: contentDir,
|
||||||
outputDir: outputDir,
|
outputDir: outputDir,
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
tmpl: tmpl,
|
author: author,
|
||||||
|
handle: handle,
|
||||||
|
bio: bio,
|
||||||
|
bioAlt: bioAlt,
|
||||||
|
fontsURL: fontsURL,
|
||||||
|
serifFamily: serifFamily,
|
||||||
|
mlFamily: mlFamily,
|
||||||
|
tmpl: tmpl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (g *Generator) initial() string {
|
||||||
|
for _, r := range g.author {
|
||||||
|
return strings.ToUpper(string(r))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// Build performs a full rebuild of the site. It is safe to call concurrently;
|
// Build performs a full rebuild of the site. It is safe to call concurrently;
|
||||||
// concurrent calls are serialised by a mutex.
|
// concurrent calls are serialised by a mutex.
|
||||||
func (g *Generator) Build() error {
|
func (g *Generator) Build() error {
|
||||||
@@ -157,9 +178,9 @@ func (g *Generator) loadPhotos() ([]Photo, error) {
|
|||||||
// thumbnail to public/thumbs/ plus an aspect-preserved medium image to
|
// thumbnail to public/thumbs/ plus an aspect-preserved medium image to
|
||||||
// public/medium/. Outputs are skipped when already newer than the source.
|
// public/medium/. Outputs are skipped when already newer than the source.
|
||||||
func (g *Generator) processImage(p *Photo) error {
|
func (g *Generator) processImage(p *Photo) error {
|
||||||
src := filepath.Join(g.contentDir, filepath.Base(p.File))
|
src := filepath.Join(g.contentDir, filepath.Base(p.File))
|
||||||
dstImg := filepath.Join(g.outputDir, p.File)
|
dstImg := filepath.Join(g.outputDir, p.File)
|
||||||
dstThumb := filepath.Join(g.outputDir, p.Thumb)
|
dstThumb := filepath.Join(g.outputDir, p.Thumb)
|
||||||
dstMedium := filepath.Join(g.outputDir, p.Medium)
|
dstMedium := filepath.Join(g.outputDir, p.Medium)
|
||||||
|
|
||||||
if !upToDate(src, dstImg) {
|
if !upToDate(src, dstImg) {
|
||||||
@@ -272,9 +293,17 @@ func generateMedium(src, dst string, maxDim int) error {
|
|||||||
// renderIndex writes public/index.html.
|
// renderIndex writes public/index.html.
|
||||||
func (g *Generator) renderIndex(photos []Photo) error {
|
func (g *Generator) renderIndex(photos []Photo) error {
|
||||||
return g.render("index.html", filepath.Join(g.outputDir, "index.html"), map[string]any{
|
return g.render("index.html", filepath.Join(g.outputDir, "index.html"), map[string]any{
|
||||||
"Photos": photos,
|
"Photos": photos,
|
||||||
"BaseURL": g.baseURL,
|
"BaseURL": g.baseURL,
|
||||||
"Year": time.Now().Year(),
|
"Year": time.Now().Year(),
|
||||||
|
"Author": g.author,
|
||||||
|
"Handle": g.handle,
|
||||||
|
"Bio": g.bio,
|
||||||
|
"BioAlt": g.bioAlt,
|
||||||
|
"Initial": g.initial(),
|
||||||
|
"FontsURL": g.fontsURL,
|
||||||
|
"SerifFamily": g.serifFamily,
|
||||||
|
"MlFamily": g.mlFamily,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,9 +314,15 @@ func (g *Generator) renderPhotoPage(p Photo) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return g.render("photo.html", filepath.Join(dir, "index.html"), map[string]any{
|
return g.render("photo.html", filepath.Join(dir, "index.html"), map[string]any{
|
||||||
"Photo": p,
|
"Photo": p,
|
||||||
"BaseURL": g.baseURL,
|
"BaseURL": g.baseURL,
|
||||||
"Year": time.Now().Year(),
|
"Year": time.Now().Year(),
|
||||||
|
"Author": g.author,
|
||||||
|
"Handle": g.handle,
|
||||||
|
"Initial": g.initial(),
|
||||||
|
"FontsURL": g.fontsURL,
|
||||||
|
"SerifFamily": g.serifFamily,
|
||||||
|
"MlFamily": g.mlFamily,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ func main() {
|
|||||||
baseURL := flag.String("base-url", "http://localhost:8080", "public base URL, no trailing slash")
|
baseURL := flag.String("base-url", "http://localhost:8080", "public base URL, no trailing slash")
|
||||||
port := flag.String("port", "8080", "port to listen on")
|
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)")
|
serve := flag.Bool("serve", false, "serve the generated site on the same port (for local development)")
|
||||||
|
author := flag.String("author", "Ashik Salahudeen", "author name shown on the profile header, photo pages and in the copyright footer")
|
||||||
|
handle := flag.String("handle", "@puttaalu@inflo.ws", "handle line shown under the author name on the profile header")
|
||||||
|
bio := flag.String("bio", "Personal photo journal.", "primary bio line shown on the profile header")
|
||||||
|
bioAlt := flag.String("bio-alt", "എന്തിനോ വേണ്ടി തിളയ്ക്കുന്ന സാമ്പാർ", "secondary bio line rendered on a new line in the alt-script font")
|
||||||
|
fontsURL := flag.String("fonts-url", "https://fonts.googleapis.com/css2?family=Crimson+Pro:ital,wght@0,400;0,600;0,700;1,400&family=Manjari:wght@100;400;700&display=swap", "stylesheet URL loading the two web fonts")
|
||||||
|
serifFamily := flag.String("serif-family", "Crimson Pro", "CSS font-family for the primary serif text")
|
||||||
|
mlFamily := flag.String("ml-family", "Manjari", "CSS font-family for the alt-script (Malayalam) bio line")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
for _, d := range []string{*contentDir, *outputDir} {
|
for _, d := range []string{*contentDir, *outputDir} {
|
||||||
@@ -20,7 +27,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
g := NewGenerator(*contentDir, *outputDir, *baseURL)
|
g := NewGenerator(*contentDir, *outputDir, *baseURL, *author, *handle, *bio, *bioAlt, *fontsURL, *serifFamily, *mlFamily)
|
||||||
|
|
||||||
if err := g.Build(); err != nil {
|
if err := g.Build(); err != nil {
|
||||||
log.Printf("initial build: %v", err)
|
log.Printf("initial build: %v", err)
|
||||||
|
|||||||
+49
@@ -34,6 +34,48 @@ in {
|
|||||||
description = "Upload API port. Always bound to 127.0.0.1.";
|
description = "Upload API port. Always bound to 127.0.0.1.";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
author = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "Ashik Salahudeen";
|
||||||
|
description = "Author name shown on the profile header, photo pages and in the copyright footer.";
|
||||||
|
};
|
||||||
|
|
||||||
|
handle = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "@puttaalu@inflo.ws";
|
||||||
|
description = "Handle line shown under the author name on the profile header.";
|
||||||
|
};
|
||||||
|
|
||||||
|
bio = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "Personal photo journal.";
|
||||||
|
description = "Primary bio line shown on the profile header.";
|
||||||
|
};
|
||||||
|
|
||||||
|
bioAlt = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "എന്തിനോ വേണ്ടി തിളയ്ക്കുന്ന സാമ്പാർ";
|
||||||
|
description = "Secondary bio line rendered on a new line in the alt-script font. Set to \"\" to omit.";
|
||||||
|
};
|
||||||
|
|
||||||
|
fontsURL = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "https://fonts.googleapis.com/css2?family=Crimson+Pro:ital,wght@0,400;0,600;0,700;1,400&family=Manjari:wght@100;400;700&display=swap";
|
||||||
|
description = "Stylesheet URL that loads the primary serif font and the alt-script font.";
|
||||||
|
};
|
||||||
|
|
||||||
|
serifFamily = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "Crimson Pro";
|
||||||
|
description = "CSS font-family for the primary serif text. Must match a family loaded by fontsURL.";
|
||||||
|
};
|
||||||
|
|
||||||
|
mlFamily = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "Manjari";
|
||||||
|
description = "CSS font-family for the alt-script (Malayalam) bio line. Must match a family loaded by fontsURL.";
|
||||||
|
};
|
||||||
|
|
||||||
nginx = {
|
nginx = {
|
||||||
enable = lib.mkEnableOption "nginx virtual host for photogallery";
|
enable = lib.mkEnableOption "nginx virtual host for photogallery";
|
||||||
|
|
||||||
@@ -92,6 +134,13 @@ in {
|
|||||||
"--output" cfg.outputDir
|
"--output" cfg.outputDir
|
||||||
"--base-url" cfg.baseURL
|
"--base-url" cfg.baseURL
|
||||||
"--port" (toString cfg.port)
|
"--port" (toString cfg.port)
|
||||||
|
"--author" cfg.author
|
||||||
|
"--handle" cfg.handle
|
||||||
|
"--bio" cfg.bio
|
||||||
|
"--bio-alt" cfg.bioAlt
|
||||||
|
"--fonts-url" cfg.fontsURL
|
||||||
|
"--serif-family" cfg.serifFamily
|
||||||
|
"--ml-family" cfg.mlFamily
|
||||||
];
|
];
|
||||||
|
|
||||||
Restart = "on-failure";
|
Restart = "on-failure";
|
||||||
|
|||||||
@@ -4,21 +4,22 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Photo Gallery</title>
|
<title>Photo Gallery</title>
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Crimson+Pro:ital,wght@0,400;0,600;0,700;1,400&family=Manjari:wght@100;400;700&display=swap">
|
<link rel="stylesheet" href="{{.FontsURL}}">
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<style>:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }</style>
|
||||||
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Photo Feed">
|
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Photo Feed">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="profile-hdr">
|
<header class="profile-hdr">
|
||||||
<div class="profile-inner">
|
<div class="profile-inner">
|
||||||
<div class="avatar-ring">
|
<div class="avatar-ring">
|
||||||
<span class="avatar-initials">A</span>
|
<span class="avatar-initials">{{.Initial}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pinfo">
|
<div class="pinfo">
|
||||||
<h1>Ashik Salahudeen</h1>
|
<h1>{{.Author}}</h1>
|
||||||
<p class="handle">@puttaalu@inflo.ws</p>
|
<p class="handle">{{.Handle}}</p>
|
||||||
<p class="pcount"><strong>{{len .Photos}}</strong> posts</p>
|
<p class="pcount"><strong>{{len .Photos}}</strong> posts</p>
|
||||||
<p class="pbio">Personal photo journal.<br><span class="ml">എന്തിനോ വേണ്ടി തിളയ്ക്കുന്ന സാമ്പാർ</span></p>
|
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
|
||||||
<a href="/feed.xml" class="rss-a">
|
<a href="/feed.xml" class="rss-a">
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true">
|
<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"/>
|
<circle cx="2.5" cy="11.5" r="1.5"/>
|
||||||
@@ -44,7 +45,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
© 2000–{{.Year}} Ashik Salahudeen. All rights reserved.
|
© 2000–{{.Year}} {{.Author}}. All rights reserved.
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{.Photo.Caption}}</title>
|
<title>{{.Photo.Caption}}</title>
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Crimson+Pro:ital,wght@0,400;0,600;0,700;1,400&family=Manjari:wght@100;400;700&display=swap">
|
<link rel="stylesheet" href="{{.FontsURL}}">
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<style>:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }</style>
|
||||||
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Photo Feed">
|
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Photo Feed">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -19,8 +20,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="dsidebar">
|
<div class="dsidebar">
|
||||||
<div class="dauthor">
|
<div class="dauthor">
|
||||||
<div class="mini-avatar"><span>A</span></div>
|
<div class="mini-avatar"><span>{{.Initial}}</span></div>
|
||||||
<span class="dauthor-name">Ashik Salahudeen</span>
|
<span class="dauthor-name">{{.Author}}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="dcaption">{{.Photo.Caption}}</p>
|
<p class="dcaption">{{.Photo.Caption}}</p>
|
||||||
<p class="ddate">{{.Photo.Date.Format "Jan 02, 2006"}}</p>
|
<p class="ddate">{{.Photo.Date.Format "Jan 02, 2006"}}</p>
|
||||||
@@ -30,7 +31,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
© 2000–{{.Year}} Ashik Salahudeen. All rights reserved.
|
© 2000–{{.Year}} {{.Author}}. All rights reserved.
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user