feat(manage): add /manage pages to edit and delete photos, behind -manage
/manage lists every photo; /manage/edit/{slug} rewrites the photo's
.toml sidecar (caption, date), /manage/delete/{slug} asks for
confirmation and then removes the image, its sidecar and everything
generated from them. Builds now drop day pages of days without photos
and pager pages past the last one, so a delete or a date change leaves
nothing behind.
The pages are off by default: the routes are only registered with
-manage, so an install that does not opt in has no such code path and
answers 404. NixOS gets services.photogallery.manage.enable, which
also adds the nginx location only when on. Like /upload, the pages
carry no auth of their own and are not linked from the public site.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+91
-10
@@ -199,6 +199,48 @@ func (g *Generator) Build() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a photo for good: its image and sidecar in the content
|
||||
// directory, then everything generated from them. Pages that list the photo
|
||||
// are put right by the next Build. It returns os.ErrNotExist when no photo
|
||||
// has that slug.
|
||||
func (g *Generator) Delete(slug string) error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
photos, err := g.loadPhotos()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, p := range photos {
|
||||
if p.Slug != slug {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
// The source goes first: if it cannot be removed, the photo stays
|
||||
// fully intact rather than half deleted.
|
||||
if err := os.Remove(filepath.Join(g.contentDir, filepath.Base(p.File))); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rel := range []string{p.File, p.Thumb, p.Medium, p.OG} {
|
||||
err := os.Remove(filepath.Join(g.outputDir, filepath.FromSlash(rel)))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
|
||||
// slug is safe to join from here on: it matched a directory entry.
|
||||
if err := os.Remove(filepath.Join(g.contentDir, slug+".toml")); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return os.RemoveAll(filepath.Join(g.outputDir, "photo", slug))
|
||||
}
|
||||
|
||||
// loadPhotos scans the content directory and returns photos sorted newest first.
|
||||
func (g *Generator) loadPhotos() ([]Photo, error) {
|
||||
entries, err := os.ReadDir(g.contentDir)
|
||||
@@ -441,10 +483,10 @@ func generateOG(src, dst string, maxDim int) error {
|
||||
return os.WriteFile(dst, buf.Bytes(), 0644)
|
||||
}
|
||||
|
||||
// renderGridPages writes the paginated square-grid view under public/grid/:
|
||||
// grid/index.html and grid/page/N/index.html, chunking photos into pageSize
|
||||
// slices for progressive pagination. The grid is the secondary view; the
|
||||
// timeline is the site root.
|
||||
// renderGridPages writes the paginated square-grid view: index.html and
|
||||
// page/N/index.html under the grid's base (the site root when the grid is the
|
||||
// default view, /grid/ otherwise), chunking photos into pageSize slices for
|
||||
// progressive pagination.
|
||||
func (g *Generator) renderGridPages(photos []Photo) error {
|
||||
total := len(photos)
|
||||
totalPages := (total + g.pageSize - 1) / g.pageSize
|
||||
@@ -499,7 +541,7 @@ func (g *Generator) renderGridPages(photos []Photo) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return g.prunePages(base, totalPages)
|
||||
}
|
||||
|
||||
// renderPhotoPage writes public/photo/<slug>/index.html.
|
||||
@@ -571,8 +613,9 @@ func chunkDayGroups(groups []DayGroup, target int) [][]DayGroup {
|
||||
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
|
||||
// renderTimelinePages writes the day-grouped timeline: index.html and
|
||||
// page/N/index.html under the timeline's base (the site root when the
|
||||
// timeline is the default view). 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)
|
||||
@@ -619,13 +662,51 @@ func (g *Generator) renderTimelinePages(photos []Photo) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return g.prunePages(base, totalPages)
|
||||
}
|
||||
|
||||
// prunePages removes the pager pages of a view past its last one. They are
|
||||
// left behind when the photo count shrinks or the views swap places, and
|
||||
// would keep showing photos that are gone.
|
||||
func (g *Generator) prunePages(base string, totalPages int) error {
|
||||
// Page 1 is the view's own root, never a leftover: start past it even
|
||||
// if a caller reports no pages at all.
|
||||
for n := max(totalPages, 1) + 1; ; n++ {
|
||||
dir := filepath.Join(g.outputDir, filepath.FromSlash(pageURL(base, n)))
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
return nil
|
||||
}
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// renderDayPages writes one page per calendar day under public/day/<ISO>/,
|
||||
// holding just that day's photos. Timeline date headings link here.
|
||||
// holding just that day's photos. Timeline date headings link here. Pages of
|
||||
// days that no longer have photos, after a delete or a date change, are
|
||||
// removed.
|
||||
func (g *Generator) renderDayPages(photos []Photo) error {
|
||||
for _, grp := range groupByDay(photos) {
|
||||
groups := groupByDay(photos)
|
||||
|
||||
live := make(map[string]bool, len(groups))
|
||||
for _, grp := range groups {
|
||||
live[grp.day] = true
|
||||
}
|
||||
dayDir := filepath.Join(g.outputDir, "day")
|
||||
entries, err := os.ReadDir(dayDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && !live[e.Name()] {
|
||||
if err := os.RemoveAll(filepath.Join(dayDir, e.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, grp := range groups {
|
||||
dir := filepath.Join(g.outputDir, "day", grp.day)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
|
||||
Reference in New Issue
Block a user