Compare commits
3 Commits
c169c030d6
..
trunk
| Author | SHA1 | Date | |
|---|---|---|---|
| eb6dba6eb0 | |||
| 4ddf5adf24 | |||
| 9b6fcb482b |
@@ -1,5 +1,6 @@
|
|||||||
/content/
|
/content/
|
||||||
/public/
|
/public/
|
||||||
|
/.demo/
|
||||||
# Anchored: unanchored these would also match the Kotlin package directory
|
# Anchored: unanchored these would also match the Kotlin package directory
|
||||||
# android/app/src/main/java/ws/inflo/photogallery/ and any nested "result".
|
# android/app/src/main/java/ws/inflo/photogallery/ and any nested "result".
|
||||||
/photogallery
|
/photogallery
|
||||||
|
|||||||
@@ -45,7 +45,10 @@ Missing sidecar → filename as caption, today as date. Photos sort newest first
|
|||||||
- Gruvbox dark hard colour scheme
|
- Gruvbox dark hard colour scheme
|
||||||
- Crimson Pro (serif) for headings and captions — configurable via `-serif-family` / `-fonts-url`
|
- Crimson Pro (serif) for headings and captions — configurable via `-serif-family` / `-fonts-url`
|
||||||
- Manjari for Malayalam text (bio line), via `-ml-family`
|
- Manjari for Malayalam text (bio line), via `-ml-family`
|
||||||
- 3-column square grid → 2-column on mobile, 60 photos per page (`/`, `/page/2/`, …)
|
- Two views of the same photos, both 3-column square grid → 2-column on mobile, both with infinite-scroll load-more. Page size is configurable via `-page-size` (default 60; NixOS `services.photogallery.pageSize`):
|
||||||
|
- **Timeline is the site root** (`/`, `/page/2/`, …). Photos grouped by calendar day: one ISO date heading (`2026-06-14`) + count per day, then the square grid. `groupByDay` collapses the newest-first list into consecutive same-day runs; `chunkDayGroups` packs whole days into pages targeting `pageSize`, never splitting a day across a page boundary (so appended pages never duplicate a heading — the load-more JS relies on this).
|
||||||
|
- **Grid** (`/grid/`, `/grid/page/2/`, …) is the flat square grid, linked from the profile header.
|
||||||
|
- Each timeline date heading links to a **single-day page** (`/day/<YYYY-MM-DD>/`) holding just that day's photos. `renderDayPages` writes one per `groupByDay` run; the ISO label doubles as the URL slug.
|
||||||
- Single photo page with sidebar and newer/older navigation
|
- Single photo page with sidebar and newer/older navigation
|
||||||
|
|
||||||
### Photo page navigation
|
### Photo page navigation
|
||||||
@@ -129,6 +132,9 @@ nix build # succeeds
|
|||||||
| `server.go` | Upload API + optional static file serving |
|
| `server.go` | Upload API + optional static file serving |
|
||||||
| `watcher.go` | fsnotify watcher with 500ms debounce |
|
| `watcher.go` | fsnotify watcher with 500ms debounce |
|
||||||
| `assets.go` | `//go:embed` for templates and static |
|
| `assets.go` | `//go:embed` for templates and static |
|
||||||
|
| `templates/timeline.html` | Site root: photos grouped by day, paginated |
|
||||||
|
| `templates/grid.html` | Flat square grid at `/grid/`, paginated |
|
||||||
|
| `templates/day.html` | Single-day page at `/day/<date>/` |
|
||||||
| `templates/photo.html` | Single photo page, neighbour links and prefetch |
|
| `templates/photo.html` | Single photo page, neighbour links and prefetch |
|
||||||
| `static/photo-nav.js` | Arrow-key and swipe navigation |
|
| `static/photo-nav.js` | Arrow-key and swipe navigation |
|
||||||
| `module.nix` | NixOS module (systemd service, tmpfiles, nginx vhost) |
|
| `module.nix` | NixOS module (systemd service, tmpfiles, nginx vhost) |
|
||||||
|
|||||||
+175
-18
@@ -22,9 +22,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
thumbSize = 600
|
thumbSize = 600
|
||||||
mediumMaxSize = 1600
|
mediumMaxSize = 1600
|
||||||
pageSize = 60
|
defaultPageSize = 60
|
||||||
)
|
)
|
||||||
|
|
||||||
// PhotoMeta is the structure of a .toml sidecar file.
|
// PhotoMeta is the structure of a .toml sidecar file.
|
||||||
@@ -43,6 +43,14 @@ type Photo struct {
|
|||||||
Date time.Time
|
Date time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DayGroup is a run of photos sharing the same calendar day, for the timeline.
|
||||||
|
type DayGroup struct {
|
||||||
|
day string // "2006-01-02", grouping key (unexported: not for templates)
|
||||||
|
Label string // "14 June 2026"
|
||||||
|
Count int
|
||||||
|
Photos []Photo
|
||||||
|
}
|
||||||
|
|
||||||
type Generator struct {
|
type Generator struct {
|
||||||
contentDir string
|
contentDir string
|
||||||
outputDir string
|
outputDir string
|
||||||
@@ -54,12 +62,16 @@ type Generator struct {
|
|||||||
fontsURL string
|
fontsURL string
|
||||||
serifFamily string
|
serifFamily string
|
||||||
mlFamily string
|
mlFamily string
|
||||||
|
pageSize int
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
tmpl *template.Template
|
tmpl *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, fontsURL, serifFamily, mlFamily string) *Generator {
|
func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, fontsURL, serifFamily, mlFamily string, pageSize int) *Generator {
|
||||||
tmpl := template.Must(template.ParseFS(assets, "templates/*.html"))
|
tmpl := template.Must(template.ParseFS(assets, "templates/*.html"))
|
||||||
|
if pageSize < 1 {
|
||||||
|
pageSize = defaultPageSize
|
||||||
|
}
|
||||||
return &Generator{
|
return &Generator{
|
||||||
contentDir: contentDir,
|
contentDir: contentDir,
|
||||||
outputDir: outputDir,
|
outputDir: outputDir,
|
||||||
@@ -71,6 +83,7 @@ func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, f
|
|||||||
fontsURL: fontsURL,
|
fontsURL: fontsURL,
|
||||||
serifFamily: serifFamily,
|
serifFamily: serifFamily,
|
||||||
mlFamily: mlFamily,
|
mlFamily: mlFamily,
|
||||||
|
pageSize: pageSize,
|
||||||
tmpl: tmpl,
|
tmpl: tmpl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,7 +106,7 @@ func (g *Generator) Build() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, d := range []string{"images", "thumbs", "medium", "photo", "static"} {
|
for _, d := range []string{"images", "thumbs", "medium", "photo", "grid", "day", "static"} {
|
||||||
if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -105,7 +118,13 @@ func (g *Generator) Build() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := g.renderIndexPages(photos); err != nil {
|
if err := g.renderTimelinePages(photos); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := g.renderGridPages(photos); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := g.renderDayPages(photos); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for i := range photos {
|
for i := range photos {
|
||||||
@@ -299,26 +318,29 @@ func generateMedium(src, dst string, maxDim int) error {
|
|||||||
return jpeg.Encode(out, resized, &jpeg.Options{Quality: 88})
|
return jpeg.Encode(out, resized, &jpeg.Options{Quality: 88})
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderIndexPages writes public/index.html and public/page/N/index.html
|
// renderGridPages writes the paginated square-grid view under public/grid/:
|
||||||
// files, chunking photos into pageSize slices for progressive pagination.
|
// grid/index.html and grid/page/N/index.html, chunking photos into pageSize
|
||||||
func (g *Generator) renderIndexPages(photos []Photo) error {
|
// slices for progressive pagination. The grid is the secondary view; the
|
||||||
|
// timeline is the site root.
|
||||||
|
func (g *Generator) renderGridPages(photos []Photo) error {
|
||||||
total := len(photos)
|
total := len(photos)
|
||||||
totalPages := (total + pageSize - 1) / pageSize
|
totalPages := (total + g.pageSize - 1) / g.pageSize
|
||||||
if totalPages == 0 {
|
if totalPages == 0 {
|
||||||
totalPages = 1
|
totalPages = 1
|
||||||
}
|
}
|
||||||
|
gridDir := filepath.Join(g.outputDir, "grid")
|
||||||
|
|
||||||
for i := 0; i < totalPages; i++ {
|
for i := 0; i < totalPages; i++ {
|
||||||
start := i * pageSize
|
start := i * g.pageSize
|
||||||
end := start + pageSize
|
end := start + g.pageSize
|
||||||
if end > total {
|
if end > total {
|
||||||
end = total
|
end = total
|
||||||
}
|
}
|
||||||
slice := photos[start:end]
|
slice := photos[start:end]
|
||||||
|
|
||||||
dst := filepath.Join(g.outputDir, "index.html")
|
dst := filepath.Join(gridDir, "index.html")
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
dir := filepath.Join(g.outputDir, "page", strconv.Itoa(i+1))
|
dir := filepath.Join(gridDir, "page", strconv.Itoa(i+1))
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -331,12 +353,12 @@ func (g *Generator) renderIndexPages(photos []Photo) error {
|
|||||||
case 0:
|
case 0:
|
||||||
// no prev
|
// no prev
|
||||||
case 1:
|
case 1:
|
||||||
prev = "/"
|
prev = "/grid/"
|
||||||
default:
|
default:
|
||||||
prev = "/page/" + strconv.Itoa(i) + "/"
|
prev = "/grid/page/" + strconv.Itoa(i) + "/"
|
||||||
}
|
}
|
||||||
if i+1 < totalPages {
|
if i+1 < totalPages {
|
||||||
next = "/page/" + strconv.Itoa(i+2) + "/"
|
next = "/grid/page/" + strconv.Itoa(i+2) + "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
@@ -357,7 +379,7 @@ func (g *Generator) renderIndexPages(photos []Photo) error {
|
|||||||
"SerifFamily": g.serifFamily,
|
"SerifFamily": g.serifFamily,
|
||||||
"MlFamily": g.mlFamily,
|
"MlFamily": g.mlFamily,
|
||||||
}
|
}
|
||||||
if err := g.render("index.html", dst, data); err != nil {
|
if err := g.render("grid.html", dst, data); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -385,6 +407,141 @@ func (g *Generator) renderPhotoPage(p Photo, newer, older *Photo) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// groupByDay collapses photos (already sorted newest first) into per-day runs,
|
||||||
|
// preserving order. Two photos land in the same group iff their dates share a
|
||||||
|
// calendar day.
|
||||||
|
func groupByDay(photos []Photo) []DayGroup {
|
||||||
|
var groups []DayGroup
|
||||||
|
for _, p := range photos {
|
||||||
|
day := p.Date.Format("2006-01-02")
|
||||||
|
if n := len(groups); n > 0 && groups[n-1].day == day {
|
||||||
|
groups[n-1].Photos = append(groups[n-1].Photos, p)
|
||||||
|
groups[n-1].Count++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
groups = append(groups, DayGroup{
|
||||||
|
day: day,
|
||||||
|
Label: day, // ISO 2006-01-02
|
||||||
|
Count: 1,
|
||||||
|
Photos: []Photo{p},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
// chunkDayGroups packs day groups into pages, filling each page until it holds
|
||||||
|
// at least target photos, then starting a new one. A single day is never split
|
||||||
|
// across a page boundary, so page sizes vary slightly around target. Always
|
||||||
|
// returns at least one page (empty when there are no photos) so the site root
|
||||||
|
// renders.
|
||||||
|
func chunkDayGroups(groups []DayGroup, target int) [][]DayGroup {
|
||||||
|
var pages [][]DayGroup
|
||||||
|
var cur []DayGroup
|
||||||
|
count := 0
|
||||||
|
for _, grp := range groups {
|
||||||
|
cur = append(cur, grp)
|
||||||
|
count += grp.Count
|
||||||
|
if count >= target {
|
||||||
|
pages = append(pages, cur)
|
||||||
|
cur, count = nil, 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cur) > 0 {
|
||||||
|
pages = append(pages, cur)
|
||||||
|
}
|
||||||
|
if len(pages) == 0 {
|
||||||
|
pages = [][]DayGroup{nil}
|
||||||
|
}
|
||||||
|
return pages
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderTimelinePages writes the day-grouped timeline as the site root:
|
||||||
|
// index.html and page/N/index.html. Photos are grouped by day, then packed
|
||||||
|
// into pages of roughly pageSize without splitting a day across pages.
|
||||||
|
func (g *Generator) renderTimelinePages(photos []Photo) error {
|
||||||
|
pages := chunkDayGroups(groupByDay(photos), g.pageSize)
|
||||||
|
totalPages := len(pages)
|
||||||
|
|
||||||
|
for i, groups := range pages {
|
||||||
|
dst := filepath.Join(g.outputDir, "index.html")
|
||||||
|
if i > 0 {
|
||||||
|
dir := filepath.Join(g.outputDir, "page", strconv.Itoa(i+1))
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dst = filepath.Join(dir, "index.html")
|
||||||
|
}
|
||||||
|
|
||||||
|
prev := ""
|
||||||
|
next := ""
|
||||||
|
switch i {
|
||||||
|
case 0:
|
||||||
|
// no prev
|
||||||
|
case 1:
|
||||||
|
prev = "/"
|
||||||
|
default:
|
||||||
|
prev = "/page/" + strconv.Itoa(i) + "/"
|
||||||
|
}
|
||||||
|
if i+1 < totalPages {
|
||||||
|
next = "/page/" + strconv.Itoa(i+2) + "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]any{
|
||||||
|
"Groups": groups,
|
||||||
|
"Total": len(photos),
|
||||||
|
"Page": i + 1,
|
||||||
|
"TotalPages": totalPages,
|
||||||
|
"PrevURL": prev,
|
||||||
|
"NextURL": next,
|
||||||
|
"BaseURL": g.baseURL,
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
if err := g.render("timeline.html", dst, data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderDayPages writes one page per calendar day under public/day/<ISO>/,
|
||||||
|
// holding just that day's photos. Timeline date headings link here.
|
||||||
|
func (g *Generator) renderDayPages(photos []Photo) error {
|
||||||
|
for _, grp := range groupByDay(photos) {
|
||||||
|
dir := filepath.Join(g.outputDir, "day", grp.day)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err := g.render("day.html", filepath.Join(dir, "index.html"), map[string]any{
|
||||||
|
"Date": grp.Label,
|
||||||
|
"Count": grp.Count,
|
||||||
|
"Photos": grp.Photos,
|
||||||
|
"Total": len(photos),
|
||||||
|
"BaseURL": g.baseURL,
|
||||||
|
"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,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (g *Generator) render(tmpl, dst string, data any) error {
|
func (g *Generator) render(tmpl, dst string, data any) error {
|
||||||
f, err := os.Create(dst)
|
f, err := os.Create(dst)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ func main() {
|
|||||||
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")
|
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")
|
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")
|
mlFamily := flag.String("ml-family", "Manjari", "CSS font-family for the alt-script (Malayalam) bio line")
|
||||||
|
pageSize := flag.Int("page-size", 60, "photos per page on the timeline and grid views (timeline packs whole days, so pages vary slightly around this)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
for _, d := range []string{*contentDir, *outputDir} {
|
for _, d := range []string{*contentDir, *outputDir} {
|
||||||
@@ -27,7 +28,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
g := NewGenerator(*contentDir, *outputDir, *baseURL, *author, *handle, *bio, *bioAlt, *fontsURL, *serifFamily, *mlFamily)
|
g := NewGenerator(*contentDir, *outputDir, *baseURL, *author, *handle, *bio, *bioAlt, *fontsURL, *serifFamily, *mlFamily, *pageSize)
|
||||||
|
|
||||||
if err := g.Build(); err != nil {
|
if err := g.Build(); err != nil {
|
||||||
log.Printf("initial build: %v", err)
|
log.Printf("initial build: %v", err)
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ in {
|
|||||||
description = "CSS font-family for the alt-script (Malayalam) bio line. Must match a family loaded by fontsURL.";
|
description = "CSS font-family for the alt-script (Malayalam) bio line. Must match a family loaded by fontsURL.";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pageSize = lib.mkOption {
|
||||||
|
type = lib.types.ints.positive;
|
||||||
|
default = 60;
|
||||||
|
description = "Photos per page on the timeline and grid views. The timeline packs whole days, so pages vary slightly around this.";
|
||||||
|
};
|
||||||
|
|
||||||
nginx = {
|
nginx = {
|
||||||
enable = lib.mkEnableOption "nginx virtual host for photogallery";
|
enable = lib.mkEnableOption "nginx virtual host for photogallery";
|
||||||
|
|
||||||
@@ -141,6 +147,7 @@ in {
|
|||||||
"--fonts-url" cfg.fontsURL
|
"--fonts-url" cfg.fontsURL
|
||||||
"--serif-family" cfg.serifFamily
|
"--serif-family" cfg.serifFamily
|
||||||
"--ml-family" cfg.mlFamily
|
"--ml-family" cfg.mlFamily
|
||||||
|
"--page-size" (toString cfg.pageSize)
|
||||||
];
|
];
|
||||||
|
|
||||||
Restart = "on-failure";
|
Restart = "on-failure";
|
||||||
|
|||||||
@@ -130,6 +130,16 @@ body {
|
|||||||
color: var(--fg2);
|
color: var(--fg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Divider between adjacent header links, e.g. GRID | RSS FEED. The pipe is a
|
||||||
|
flex child of the second link, so its right margin adds to that link's
|
||||||
|
gap:5px — 9px + 5px matches the 14px left margin for even spacing. */
|
||||||
|
.rss-a + .rss-a::before {
|
||||||
|
content: "|";
|
||||||
|
margin-left: 14px;
|
||||||
|
margin-right: 9px;
|
||||||
|
color: var(--fg4);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Photo grid ────────────────────────────────────────────── */
|
/* ── Photo grid ────────────────────────────────────────────── */
|
||||||
|
|
||||||
.grid-wrap {
|
.grid-wrap {
|
||||||
@@ -187,6 +197,43 @@ body {
|
|||||||
.pager-newer { margin-right: auto; }
|
.pager-newer { margin-right: auto; }
|
||||||
.pager-older { margin-left: auto; }
|
.pager-older { margin-left: auto; }
|
||||||
|
|
||||||
|
/* ── Timeline ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.tl-day {
|
||||||
|
margin-bottom: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-date {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg2);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 0 3px 10px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-date a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-date a:hover {
|
||||||
|
color: var(--aqua);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-count {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--fg4);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Single photo page ─────────────────────────────────────── */
|
/* ── Single photo page ─────────────────────────────────────── */
|
||||||
|
|
||||||
.photo-page {
|
.photo-page {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{.Date}} · Photo Gallery</title>
|
||||||
|
<link rel="stylesheet" href="{{.FontsURL}}">
|
||||||
|
<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">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="profile-hdr">
|
||||||
|
<div class="profile-inner">
|
||||||
|
<div class="avatar-ring">
|
||||||
|
<span class="avatar-initials">{{.Initial}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pinfo">
|
||||||
|
<h1>{{.Author}}</h1>
|
||||||
|
<p class="handle">{{.Handle}}</p>
|
||||||
|
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
|
||||||
|
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
|
||||||
|
<a href="/" class="rss-a">← Timeline</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="grid-wrap">
|
||||||
|
<section class="tl-day">
|
||||||
|
<h2 class="tl-date">{{.Date}}<span class="tl-count">{{.Count}}</span></h2>
|
||||||
|
<div class="pgrid">
|
||||||
|
{{range .Photos}}
|
||||||
|
<a href="/photo/{{.Slug}}/" class="ptile">
|
||||||
|
<img src="/{{.Thumb}}" alt="{{.Caption}}" loading="lazy">
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
© 2000–{{.Year}} {{.Author}}. All rights reserved.
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
<p class="handle">{{.Handle}}</p>
|
<p class="handle">{{.Handle}}</p>
|
||||||
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
|
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
|
||||||
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
|
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
|
||||||
|
<a href="/" class="rss-a">Timeline</a>
|
||||||
<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"/>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<!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="{{.FontsURL}}">
|
||||||
|
<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">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="profile-hdr">
|
||||||
|
<div class="profile-inner">
|
||||||
|
<div class="avatar-ring">
|
||||||
|
<span class="avatar-initials">{{.Initial}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pinfo">
|
||||||
|
<h1>{{.Author}}</h1>
|
||||||
|
<p class="handle">{{.Handle}}</p>
|
||||||
|
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
|
||||||
|
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
|
||||||
|
<a href="/grid/" class="rss-a">Grid</a>
|
||||||
|
<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">
|
||||||
|
{{range .Groups}}
|
||||||
|
<section class="tl-day">
|
||||||
|
<h2 class="tl-date"><a href="/day/{{.Label}}/">{{.Label}}</a><span class="tl-count">{{.Count}}</span></h2>
|
||||||
|
<div class="pgrid">
|
||||||
|
{{range .Photos}}
|
||||||
|
<a href="/photo/{{.Slug}}/" class="ptile">
|
||||||
|
<img src="/{{.Thumb}}" alt="{{.Caption}}" loading="lazy">
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if or .PrevURL .NextURL}}
|
||||||
|
<nav class="pager">
|
||||||
|
{{if .PrevURL}}<a class="pager-newer" href="{{.PrevURL}}">← Newer</a>{{end}}
|
||||||
|
{{if .NextURL}}<a class="pager-older" href="{{.NextURL}}" data-load-more>Older →</a>{{end}}
|
||||||
|
</nav>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
© 2000–{{.Year}} {{.Author}}. All rights reserved.
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const btn = document.querySelector('[data-load-more]');
|
||||||
|
if (!btn) return;
|
||||||
|
const nav = btn.closest('.pager');
|
||||||
|
const wrap = nav.parentNode;
|
||||||
|
let loading = false;
|
||||||
|
|
||||||
|
async function loadMore(url) {
|
||||||
|
if (loading) return;
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const html = await res.text();
|
||||||
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
// Days never split across pages, so appended sections never duplicate
|
||||||
|
// a heading already on the page.
|
||||||
|
doc.querySelectorAll('.tl-day').forEach(s => wrap.insertBefore(s, nav));
|
||||||
|
const nextBtn = doc.querySelector('[data-load-more]');
|
||||||
|
if (nextBtn) btn.href = nextBtn.getAttribute('href');
|
||||||
|
else btn.remove();
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
loadMore(btn.href);
|
||||||
|
});
|
||||||
|
|
||||||
|
const io = new IntersectionObserver((entries) => {
|
||||||
|
if (entries[0].isIntersecting && document.body.contains(btn)) {
|
||||||
|
loadMore(btn.href);
|
||||||
|
}
|
||||||
|
}, { rootMargin: '400px' });
|
||||||
|
io.observe(btn);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user