feat(manage): delete photos from /manage, prune leftover pages

/manage/delete/{slug} asks for confirmation, 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.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
fox
2026-09-30 05:53:12 +00:00
committed by fox
co-authored by Claude Fable 5.1
parent 52ff47217a
commit ab0bd5b65a
9 changed files with 428 additions and 22 deletions
+5 -5
View File
@@ -19,7 +19,7 @@ The binary does three things at once:
- Serves a multipart upload API at `POST /upload` - Serves a multipart upload API at `POST /upload`
- In dev mode (`--serve`), also serves `public/` as static files - In dev mode (`--serve`), also serves `public/` as static files
Routes: `GET /upload` (HTML upload form), `POST /upload` (multipart), `GET /manage` (list of photos), `GET`/`POST /manage/edit/{slug}` (edit caption and date), `GET /health`. Routes: `GET /upload` (HTML upload form), `POST /upload` (multipart), `GET /manage` (list of photos), `GET`/`POST /manage/edit/{slug}` (edit caption and date), `GET`/`POST /manage/delete/{slug}` (confirm, then delete), `GET /health`.
In production nginx sits in front: serves `public/` directly, proxies `/upload` and `/manage` to localhost, htpasswd on both. Note `/health` is **not** proxied — nginx only forwards `location /upload` and `location /manage`, so anything outside the box can only reach those two. In production nginx sits in front: serves `public/` directly, proxies `/upload` and `/manage` to localhost, htpasswd on both. Note `/health` is **not** proxied — nginx only forwards `location /upload` and `location /manage`, so anything outside the box can only reach those two.
@@ -30,7 +30,8 @@ In production nginx sits in front: serves `public/` directly, proxies `/upload`
- An edit only rewrites the `.toml` sidecar. Any key other than `caption` and `date` in a hand-written sidecar is dropped, same as on upload. - An edit only rewrites the `.toml` sidecar. Any key other than `caption` and `date` in a hand-written sidecar is dropped, same as on upload.
- Slugs from the URL are matched against the photos actually in `content/`, never joined into a path. - Slugs from the URL are matched against the photos actually in `content/`, never joined into a path.
- `sameOrigin` compares the `Origin` header against `-base-url`, because behind nginx the `Host` header is the upstream address. A wrong `-base-url` therefore breaks saving in browsers that send no `Sec-Fetch-Site`. - `sameOrigin` compares the `Origin` header against `-base-url`, because behind nginx the `Host` header is the upstream address. A wrong `-base-url` therefore breaks saving in browsers that send no `Sec-Fetch-Site`.
- Changing a date moves the photo to another day page, but the old `public/day/<date>/` is not removed — the generator never prunes `public/`. - Delete is permanent and takes two steps: `GET /manage/delete/{slug}` only shows a confirmation page, the `POST` does the work. `Generator.Delete` removes the image and sidecar from `content/` first, then the four renditions and `public/photo/<slug>/`, all under the build mutex. There is no trash folder — recovery means restoring from a backup.
- Listing pages are put right by the rebuild that follows, which also prunes what a delete or a date change leaves behind: `renderDayPages` drops `public/day/<date>/` for days without photos, and `prunePages` drops pager pages past the last one. Renditions of photos removed from `content/` by hand (not via `/manage`) are still **not** pruned.
## Stack ## Stack
@@ -58,7 +59,7 @@ Missing sidecar → filename as caption, today as date. Photos sort newest first
- **Timeline is the site root by default** (`/`, `/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). - **Timeline is the site root by default** (`/`, `/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. - **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. - 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.
- Which view owns the site root is set by `-default-view timeline|grid` (NixOS `services.photogallery.defaultView`). The other view moves under `/<name>/`: with `-default-view grid` the grid is `/` and the timeline is `/timeline/`. `viewBase` and `pageURL` in `generator.go` derive every page path and pager link from that, and the templates take the cross-links as `GridURL` / `TimelineURL` — never hardcode `/grid/` or `/`. On each build the directory named after the default view is removed, since it can only be a leftover from before a switch. Root-level `/page/N/` beyond the new page count is *not* pruned. - Which view owns the site root is set by `-default-view timeline|grid` (NixOS `services.photogallery.defaultView`). The other view moves under `/<name>/`: with `-default-view grid` the grid is `/` and the timeline is `/timeline/`. `viewBase` and `pageURL` in `generator.go` derive every page path and pager link from that, and the templates take the cross-links as `GridURL` / `TimelineURL` — never hardcode `/grid/` or `/`. On each build the directory named after the default view is removed, since it can only be a leftover from before a switch.
- Single photo page with sidebar and newer/older navigation - Single photo page with sidebar and newer/older navigation
### Link previews ### Link previews
@@ -175,8 +176,7 @@ nix build # succeeds
- ActivityPub federation (planned later — start with RSS/Atom) - ActivityPub federation (planned later — start with RSS/Atom)
- Multi-image upload - Multi-image upload
- Delete via UI (edit is done, see Managing photos) - Pruning renditions and photo pages of photos deleted from `content/` by hand (delete via `/manage` cleans up after itself)
- Pruning `public/` when a photo is deleted or its date changes
- Any auth beyond nginx htpasswd - Any auth beyond nginx htpasswd
- The Android app has never been run on real hardware - The Android app has never been run on real hardware
- Photo-page swipe has never been tested on a real touchscreen; the iOS Safari edge-gesture interaction is the untested part - Photo-page swipe has never been tested on a real touchscreen; the iOS Safari edge-gesture interaction is the untested part
+5 -1
View File
@@ -141,12 +141,16 @@ curl -u user:password \
Or open `https://photos.yourdomain.tld/upload` in a browser. Or open `https://photos.yourdomain.tld/upload` in a browser.
## Editing a photo ## Editing and deleting photos
Open `https://photos.yourdomain.tld/manage` in a browser. It lists every Open `https://photos.yourdomain.tld/manage` in a browser. It lists every
photo; *Edit* opens a form for the caption and date, and saving rewrites the photo; *Edit* opens a form for the caption and date, and saving rewrites the
photo's `.toml` sidecar, which triggers a rebuild. photo's `.toml` sidecar, which triggers a rebuild.
*Delete* asks for confirmation, then removes the image, its sidecar and
everything generated from them. This is permanent — there is no trash folder,
so keep a backup of the content directory.
`/manage` sits behind the same htpasswd as `/upload`, and nothing on the `/manage` sits behind the same htpasswd as `/upload`, and nothing on the
public site links to it. If you run your own nginx instead of the NixOS public site links to it. If you run your own nginx instead of the NixOS
module, add a `location /manage` block alongside the `/upload` one — without module, add a `location /manage` block alongside the `/upload` one — without
+90 -9
View File
@@ -199,6 +199,48 @@ func (g *Generator) Build() error {
return nil 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. // loadPhotos scans the content directory and returns photos sorted newest first.
func (g *Generator) loadPhotos() ([]Photo, error) { func (g *Generator) loadPhotos() ([]Photo, error) {
entries, err := os.ReadDir(g.contentDir) 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) return os.WriteFile(dst, buf.Bytes(), 0644)
} }
// renderGridPages writes the paginated square-grid view under public/grid/: // renderGridPages writes the paginated square-grid view: index.html and
// grid/index.html and grid/page/N/index.html, chunking photos into pageSize // page/N/index.html under the grid's base (the site root when the grid is the
// slices for progressive pagination. The grid is the secondary view; the // default view, /grid/ otherwise), chunking photos into pageSize slices for
// timeline is the site root. // progressive pagination.
func (g *Generator) renderGridPages(photos []Photo) error { func (g *Generator) renderGridPages(photos []Photo) error {
total := len(photos) total := len(photos)
totalPages := (total + g.pageSize - 1) / g.pageSize totalPages := (total + g.pageSize - 1) / g.pageSize
@@ -499,7 +541,7 @@ func (g *Generator) renderGridPages(photos []Photo) error {
return err return err
} }
} }
return nil return g.prunePages(base, totalPages)
} }
// renderPhotoPage writes public/photo/<slug>/index.html. // renderPhotoPage writes public/photo/<slug>/index.html.
@@ -571,8 +613,9 @@ func chunkDayGroups(groups []DayGroup, target int) [][]DayGroup {
return pages return pages
} }
// renderTimelinePages writes the day-grouped timeline as the site root: // renderTimelinePages writes the day-grouped timeline: index.html and
// index.html and page/N/index.html. Photos are grouped by day, then packed // 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. // into pages of roughly pageSize without splitting a day across pages.
func (g *Generator) renderTimelinePages(photos []Photo) error { func (g *Generator) renderTimelinePages(photos []Photo) error {
pages := chunkDayGroups(groupByDay(photos), g.pageSize) pages := chunkDayGroups(groupByDay(photos), g.pageSize)
@@ -619,13 +662,51 @@ func (g *Generator) renderTimelinePages(photos []Photo) error {
return err return err
} }
} }
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 return nil
} }
if err := os.RemoveAll(dir); err != nil {
return err
}
}
}
// renderDayPages writes one page per calendar day under public/day/<ISO>/, // 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 { 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) dir := filepath.Join(g.outputDir, "day", grp.day)
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
return err return err
+33
View File
@@ -123,3 +123,36 @@ func TestSwitchingDefaultViewDropsOldViewDir(t *testing.T) {
t.Errorf("site root is still the timeline") t.Errorf("site root is still the timeline")
} }
} }
func TestShrinkingSiteDropsLeftoverPages(t *testing.T) {
output := t.TempDir()
buildSite(t, output, viewTimeline) // three photos, page size two
content := t.TempDir()
var img bytes.Buffer
if err := jpeg.Encode(&img, image.NewRGBA(image.Rect(0, 0, 8, 8)), nil); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(content, "one.jpg"), img.Bytes())
writeFile(t, filepath.Join(content, "one.toml"),
[]byte("caption = \"one\"\ndate = \"2026-06-14\"\n"))
// The same output directory, now fed a single photo: one page per view,
// one day.
g := NewGenerator(content, output, "https://photos.example.com",
"Ada", "", "", "", "", "Crimson Pro", "Manjari", 2, viewTimeline)
if err := g.Build(); err != nil {
t.Fatal(err)
}
for _, gone := range []string{"page/2", "grid/page/2", "day/2026-06-10"} {
if _, err := os.Stat(filepath.Join(output, filepath.FromSlash(gone))); !os.IsNotExist(err) {
t.Errorf("leftover %s survived", gone)
}
}
for _, kept := range []string{"index.html", "grid/index.html", "day/2026-06-14/index.html"} {
if _, err := os.Stat(filepath.Join(output, filepath.FromSlash(kept))); err != nil {
t.Errorf("%s is missing: %v", kept, err)
}
}
}
+52 -6
View File
@@ -13,14 +13,14 @@ import (
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
) )
// The /manage pages edit photos that are already published. Like /upload they // The /manage pages edit and delete photos that are already published. Like
// carry no auth of their own: in production nginx puts them behind htpasswd, // /upload they carry no auth of their own: in production nginx puts them
// and nothing on the public site links to them. // behind htpasswd, and nothing on the public site links to them.
// maxEditBody caps the edit form. It carries a caption and a date, nothing else. // maxEditBody caps the edit form. It carries a caption and a date, nothing else.
const maxEditBody = 64 << 10 const maxEditBody = 64 << 10
// handleManagePage lists every photo with a link to its edit form. // handleManagePage lists every photo with links to edit and delete it.
func (s *Server) handleManagePage(w http.ResponseWriter, r *http.Request) { func (s *Server) handleManagePage(w http.ResponseWriter, r *http.Request) {
photos, err := s.generator.loadPhotos() photos, err := s.generator.loadPhotos()
if err != nil { if err != nil {
@@ -38,6 +38,7 @@ func (s *Server) handleManagePage(w http.ResponseWriter, r *http.Request) {
} }
} }
} }
data["Deleted"] = r.URL.Query().Get("deleted")
s.renderManage(w, "manage.html", data) s.renderManage(w, "manage.html", data)
} }
@@ -111,6 +112,51 @@ func (s *Server) handleEdit(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/manage?saved="+url.QueryEscape(photo.Slug), http.StatusSeeOther) http.Redirect(w, r, "/manage?saved="+url.QueryEscape(photo.Slug), http.StatusSeeOther)
} }
// handleDeletePage asks for confirmation before a photo is deleted. Deleting
// is a separate page rather than a button in the list so that it always takes
// two deliberate steps.
func (s *Server) handleDeletePage(w http.ResponseWriter, r *http.Request) {
photo, ok := s.findPhoto(r.PathValue("slug"))
if !ok {
http.NotFound(w, r)
return
}
data := s.managePageData()
data["Photo"] = photo
s.renderManage(w, "delete.html", data)
}
// handleDelete removes a photo for good: image, sidecar and everything
// generated from them. There is no undo.
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
if !s.sameOrigin(r) {
http.Error(w, "cross-site request refused", http.StatusForbidden)
return
}
photo, ok := s.findPhoto(r.PathValue("slug"))
if !ok {
http.NotFound(w, r)
return
}
if err := s.generator.Delete(photo.Slug); err != nil {
log.Printf("delete %s: %v", photo.Slug, err)
http.Error(w, "could not delete photo", http.StatusInternalServerError)
return
}
log.Printf("deleted %s (%q)", photo.Slug, photo.Caption)
// 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 delete: %v", err)
}
}()
http.Redirect(w, r, "/manage?deleted="+url.QueryEscape(photo.Caption), http.StatusSeeOther)
}
// findPhoto looks slug up among the photos in the content directory. Matching // findPhoto looks slug up among the photos in the content directory. Matching
// against real entries, rather than building a path from the URL, is what // against real entries, rather than building a path from the URL, is what
// keeps a crafted slug from reaching outside the content directory. // keeps a crafted slug from reaching outside the content directory.
@@ -130,8 +176,8 @@ func (s *Server) findPhoto(slug string) (Photo, bool) {
// sameOrigin reports whether a form post came from the gallery's own pages. // sameOrigin reports whether a form post came from the gallery's own pages.
// Basic auth credentials are replayed by the browser on every request to the // Basic auth credentials are replayed by the browser on every request to the
// host, so without this check any other website could submit the edit form // host, so without this check any other website could submit the edit or
// on a logged-in user's behalf. // delete form on a logged-in user's behalf.
// //
// The Origin header is compared against -base-url as well as the Host header: // The Origin header is compared against -base-url as well as the Host header:
// behind nginx the Host that reaches this server is the proxy's upstream // behind nginx the Host that reaches this server is the proxy's upstream
+130
View File
@@ -193,3 +193,133 @@ func TestEditAcceptsOwnOrigin(t *testing.T) {
}) })
} }
} }
// twoPhotoServer returns a built site with "sunrise" plus "dusk", which has a
// day of its own, so deleting it must also take that day's page away.
func twoPhotoServer(t *testing.T) *Server {
t.Helper()
s := newTestServer(t)
var img bytes.Buffer
if err := jpeg.Encode(&img, image.NewRGBA(image.Rect(0, 0, 8, 8)), nil); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(s.contentDir, "dusk.jpg"), img.Bytes())
writeFile(t, filepath.Join(s.contentDir, "dusk.toml"),
[]byte("caption = \"Dusk\"\ndate = \"2026-07-01\"\n"))
if err := s.generator.Build(); err != nil {
t.Fatal(err)
}
return s
}
// photoFiles lists every file and directory that belongs to one photo.
func photoFiles(s *Server, slug, day string) []string {
return []string{
filepath.Join(s.contentDir, slug+".jpg"),
filepath.Join(s.contentDir, slug+".toml"),
filepath.Join(s.outputDir, "images", slug+".jpg"),
filepath.Join(s.outputDir, "thumbs", slug+".jpg"),
filepath.Join(s.outputDir, "medium", slug+".jpg"),
filepath.Join(s.outputDir, "og", slug+".jpg"),
filepath.Join(s.outputDir, "photo", slug),
filepath.Join(s.outputDir, "day", day),
}
}
func postDelete(s *Server, slug string, headers map[string]string) *httptest.ResponseRecorder {
r := httptest.NewRequest("POST", "/manage/delete/"+slug, nil)
for k, v := range headers {
r.Header.Set(k, v)
}
r.SetPathValue("slug", slug)
w := httptest.NewRecorder()
s.handleDelete(w, r)
return w
}
func TestDeletePageAsksFirst(t *testing.T) {
s := twoPhotoServer(t)
r := httptest.NewRequest("GET", "/manage/delete/dusk", nil)
r.SetPathValue("slug", "dusk")
w := httptest.NewRecorder()
s.handleDeletePage(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if !strings.Contains(w.Body.String(), `action="/manage/delete/dusk"`) {
t.Errorf("confirmation page has no delete form")
}
// Looking at the confirmation page must not delete anything.
for _, f := range photoFiles(s, "dusk", "2026-07-01") {
if _, err := os.Stat(f); err != nil {
t.Errorf("%s is gone after a GET: %v", f, err)
}
}
}
func TestDeleteRemovesPhotoEverywhere(t *testing.T) {
s := twoPhotoServer(t)
w := postDelete(s, "dusk", nil)
if w.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303: %s", w.Code, w.Body)
}
if loc := w.Header().Get("Location"); loc != "/manage?deleted=Dusk" {
t.Errorf("Location = %q", loc)
}
// The handler rebuilds in the background; build here to have a
// finished site to look at.
if err := s.generator.Build(); err != nil {
t.Fatal(err)
}
for _, f := range photoFiles(s, "dusk", "2026-07-01") {
if _, err := os.Stat(f); !os.IsNotExist(err) {
t.Errorf("%s survived the delete", f)
}
}
for _, f := range photoFiles(s, "sunrise", "2026-06-14") {
if _, err := os.Stat(f); err != nil {
t.Errorf("the other photo lost %s: %v", f, err)
}
}
for _, page := range []string{"index.html", "grid/index.html", "feed.xml"} {
data, err := os.ReadFile(filepath.Join(s.outputDir, page))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), "dusk") {
t.Errorf("%s still mentions the deleted photo", page)
}
}
}
func TestDeleteRejectsBadInput(t *testing.T) {
tests := []struct {
name string
slug string
headers map[string]string
want int
}{
{"unknown photo", "nope", nil, http.StatusNotFound},
{"path traversal", "../dusk", nil, http.StatusNotFound},
{"cross-site fetch", "dusk", map[string]string{"Sec-Fetch-Site": "cross-site"}, http.StatusForbidden},
{"foreign origin", "dusk", map[string]string{"Origin": "https://evil.example"}, http.StatusForbidden},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := twoPhotoServer(t)
if w := postDelete(s, tt.slug, tt.headers); w.Code != tt.want {
t.Errorf("status = %d, want %d", w.Code, tt.want)
}
for _, slug := range []string{"dusk", "sunrise"} {
if _, err := os.Stat(filepath.Join(s.contentDir, slug+".jpg")); err != nil {
t.Errorf("%s was deleted: %v", slug, err)
}
}
})
}
}
+2
View File
@@ -38,6 +38,8 @@ func (s *Server) Listen() error {
mux.HandleFunc("GET /manage", s.handleManagePage) mux.HandleFunc("GET /manage", s.handleManagePage)
mux.HandleFunc("GET /manage/edit/{slug}", s.handleEditPage) mux.HandleFunc("GET /manage/edit/{slug}", s.handleEditPage)
mux.HandleFunc("POST /manage/edit/{slug}", s.handleEdit) mux.HandleFunc("POST /manage/edit/{slug}", s.handleEdit)
mux.HandleFunc("GET /manage/delete/{slug}", s.handleDeletePage)
mux.HandleFunc("POST /manage/delete/{slug}", s.handleDelete)
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok") fmt.Fprintln(w, "ok")
}) })
+107
View File
@@ -0,0 +1,107 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex">
<title>Delete — {{.Photo.Caption}}</title>
<link rel="stylesheet" href="{{.FontsURL}}">
<link rel="stylesheet" href="/static/style.css">
<style>
:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }
.delete-page {
max-width: 520px;
margin: 0 auto;
padding: 32px 20px;
}
.delete-page .back-btn {
color: var(--fg3);
text-decoration: none;
font-size: 15px;
}
.delete-page .back-btn:hover { color: var(--fg); }
.delete-page h2 {
font-family: var(--serif);
font-size: 22px;
font-weight: 600;
color: var(--fg);
margin: 16px 0 24px;
}
.delete-img {
display: block;
width: 100%;
max-height: 340px;
object-fit: contain;
background: var(--bg0);
border: 1px solid var(--bg2);
margin-bottom: 12px;
}
.delete-caption {
font-size: 18px;
color: var(--fg);
overflow-wrap: anywhere;
}
.delete-meta {
font-size: 12px;
color: var(--fg4);
font-family: monospace;
margin: 4px 0 24px;
overflow-wrap: anywhere;
}
.delete-warning {
font-size: 13px;
font-family: monospace;
line-height: 1.5;
padding: 10px 12px;
margin-bottom: 20px;
background: #2e1d1d;
color: #fb4934;
border: 1px solid #5a2828;
}
.delete-btn {
width: 100%;
background: #2e1d1d;
border: 1px solid #5a2828;
color: #fb4934;
font-family: var(--serif);
font-size: 16px;
font-weight: 600;
padding: 12px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.delete-btn:hover { background: #5a2828; color: var(--fg); }
.cancel-btn {
display: block;
margin-top: 12px;
padding: 12px;
text-align: center;
background: var(--bg1);
border: 1px solid var(--bg2);
color: var(--fg2);
font-size: 16px;
font-weight: 600;
text-decoration: none;
}
.cancel-btn:hover { background: var(--bg2); color: var(--fg); }
</style>
</head>
<body>
<div class="delete-page">
<a href="/manage" class="back-btn">← Manage</a>
<h2>Delete photo</h2>
<img class="delete-img" src="/{{.Photo.Medium}}" alt="">
<p class="delete-caption">{{.Photo.Caption}}</p>
<p class="delete-meta">{{.Photo.Date.Format "2006-01-02"}} · {{.Photo.Slug}}</p>
<p class="delete-warning">This removes the photo, its caption and every
page and image generated from it. It cannot be undone.</p>
<form method="post" action="/manage/delete/{{.Photo.Slug}}">
<button class="delete-btn" type="submit">Delete permanently</button>
</form>
<a href="/manage" class="cancel-btn">Cancel</a>
</div>
</body>
</html>
+3
View File
@@ -79,6 +79,7 @@
.mactions a { color: var(--fg3); text-decoration: none; } .mactions a { color: var(--fg3); text-decoration: none; }
.mactions a:hover { color: var(--fg); } .mactions a:hover { color: var(--fg); }
.mactions .medit { color: var(--aqua); } .mactions .medit { color: var(--aqua); }
.mactions .mdelete { color: #fb4934; }
.mempty { color: var(--fg4); font-size: 15px; } .mempty { color: var(--fg4); font-size: 15px; }
</style> </style>
</head> </head>
@@ -89,6 +90,7 @@
<p class="manage-count">{{len .Photos}} {{if eq (len .Photos) 1}}photo{{else}}photos{{end}}</p> <p class="manage-count">{{len .Photos}} {{if eq (len .Photos) 1}}photo{{else}}photos{{end}}</p>
{{if .Saved}}<p class="saved">✓ saved “{{.Saved}}”</p>{{end}} {{if .Saved}}<p class="saved">✓ saved “{{.Saved}}”</p>{{end}}
{{if .Deleted}}<p class="saved">✓ deleted “{{.Deleted}}”</p>{{end}}
{{if .Photos}} {{if .Photos}}
<ul class="mlist"> <ul class="mlist">
@@ -102,6 +104,7 @@
<div class="mactions"> <div class="mactions">
<a href="/photo/{{.Slug}}/">View</a> <a href="/photo/{{.Slug}}/">View</a>
<a href="/manage/edit/{{.Slug}}" class="medit">Edit</a> <a href="/manage/edit/{{.Slug}}" class="medit">Edit</a>
<a href="/manage/delete/{{.Slug}}" class="mdelete">Delete</a>
</div> </div>
</li> </li>
{{end}} {{end}}