Compare commits

..
18 Commits
Author SHA1 Message Date
puttaalu 43075f00d4 Merge pull request 'Add /manage (edit, delete) and -default-view' (#1) from manage-endpoint into trunk-ng
Reviewed-on: #1
2026-10-03 11:15:40 +00:00
foxandClaude Fable 5.1 3f719359de 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>
2026-09-30 08:17:17 +02:00
foxandClaude Fable 5.1 f1abd45f24 feat(generator): add -default-view to choose timeline or grid as site root
The chosen view is published at /, the other under /timeline/ or
/grid/. Page paths, pager links and the cross-links between the views
are derived from the setting. The directory named after the default
view is removed on build, since it can only be a leftover from before
a switch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 08:16:50 +02:00
puttaalu f33bdd55b1 Relicense to the public domain (Unlicense)
Release the project into the public domain instead of MIT.
2026-09-26 14:06:20 +02:00
puttaalu 91fbf4f93b Name the desktop binary photog
The CMake target stays 'uploader' but the output binary is now 'photog',
matching the package and app, so 'nix run' and the desktop entry resolve
it (nix run looked for bin/photog and failed on bin/uploader). Also set
meta.mainProgram.
2026-09-26 10:51:39 +02:00
puttaalu 17222f7120 Make the desktop flake build on macOS
Wayland is Linux-only and refuses to evaluate on darwin, so qtwayland is
now added only on Linux (macOS uses the Cocoa QPA plugin in qtbase). The
package, dev shell, and plugin search path all build from one platform-
guarded module list. Evaluates on aarch64-darwin; Linux is unchanged.
2026-09-26 10:44:23 +02:00
puttaalu 591eaed6a3 Add app icons across web, Android, and the desktop app
- Web: favicon.ico + png icons under static/, served from the site root
  and linked from every page head.
- Android: the artwork becomes the launcher icon — the 432px adaptive
  foreground over the gruvbox background, replacing the placeholder vector
  drawables.
- Desktop: the icon is the Qt window/taskbar icon (embedded via app.qrc),
  and the app is now an installable package — a wrapped binary, a
  photog.desktop entry, and hicolor icons — so `nix profile install
  ./desktop` puts "Photog" in the application menu.
2026-09-26 10:33:22 +02:00
puttaalu a7783671f2 Add bulk multi-photo upload to the desktop app
Mirror the Android bulk feature in the Qt uploader: choose several photos
at once, each a card with its own caption, date, and upload status, and
post the batch.

- Per-photo cards (PhotoItemWidget) in a scrollable list; retry re-posts
  only the failures.
- One upload per photo with a per-item UUID slug suffix so a batch posted
  in the same second cannot collide.
- Image prep runs off the UI thread via QtConcurrent so the window stays
  responsive.
- A "Simultaneous uploads" setting (1–8, default 3) caps how many photos
  are prepared/uploaded at once; the rest queue and fill freed slots.
2026-09-26 10:17:18 +02:00
puttaalu 1d81ec4511 Add a source-code link to the gallery header
Next to the RSS feed link on the timeline and grid headers, pointing at
the public repo so visitors can find the source.
2026-09-26 10:17:18 +02:00
puttaalu dc47619937 Rename the Android app to Photog 2026-09-26 10:17:18 +02:00
puttaalu c404aac66c add icons 2026-09-26 10:16:17 +02:00
puttaalu 23e14f5aa8 Merge desktop-qt-uploader: Qt6 desktop uploader 2026-08-27 08:44:35 +02:00
puttaalu 6af0eb115c Add multi-photo bulk upload to the Android app
Pick or share several photos at once and post them in one batch, still
against the existing single-image /upload endpoint — no server change.

- Unify the post screen on a list model (PickedItem): a single-photo post
  or share is just a batch of one, so there is one code path.
- Multi-select via PickMultipleVisualMedia and multi-share via
  ACTION_SEND_MULTIPLE (manifest filter added).
- Per-photo caption and EXIF-prefilled date; each card shows its own
  upload status, with a summary and retry-only-failures on re-post.
- Enqueue one WorkManager worker per photo, reusing the durable retry
  queue. UploadQueue now appends a per-item suffix to the slug so a burst
  enqueued in the same second cannot collide and be dropped by
  enqueueUniqueWork.
2026-08-27 08:43:24 +02:00
puttaalu d07e3eb0dc Add Qt6 desktop uploader
A native Qt6/C++ desktop client for the gallery's POST /upload API, in
desktop/ with its own flake (kept separate from the Go server module the
same way android/ is). Single-photo parity with the Android app:

- multipart upload with preemptive HTTP Basic auth and retry-aware outcome
  classification (uploadclient)
- JPEG/PNG metadata stripped byte-for-byte to drop embedded GPS, EXIF
  orientation baked in, and a quality/scale valve for the 20M nginx cap;
  HEIC decodes via the qtimageformats plugin (imageprep)
- base URL / username in QSettings, password in the system keychain via
  qtkeychain; https required and a trailing /upload stripped (settings)
- settings and post screens in a QStackedWidget (mainwindow)

Native Wayland or xcb is selected at runtime. Bulk upload, EXIF
capture-date prefill, and moving image prep off the UI thread are left as
follow-ups.
2026-08-26 00:28:35 +02:00
puttaalu d4fec5b0ff Add Open Graph link previews to photo pages
Shared links now unfurl with an image in WhatsApp, Telegram and anything
else that reads Open Graph, instead of showing the bare URL.

A dedicated og/ rendition is generated per photo: 1200px longest side,
quality laddered down until it fits under ~250 KB. WhatsApp drops the
preview entirely once the image passes ~300 KB, and the medium rendition
can exceed that, so it is not reused. Dimensions for og:image:width and
og:image:height are read back from the encoded file with DecodeConfig, so
they are emitted even when the rendition itself was skipped as up to date.

The og:image and og:url values are absolute via BaseURL; both crawlers
ignore relative image paths. Previews therefore only work against the
deployed base URL, not http://localhost.
2026-08-24 13:59:49 +02:00
puttaalu eb6dba6eb0 Add single-day pages linked from timeline date headings
Each timeline date heading now links to /day/<YYYY-MM-DD>/, a page
holding just that day's photos with a back link to the timeline.
renderDayPages writes one per groupByDay run; the ISO label doubles
as the URL slug.
2026-08-20 00:13:49 +02:00
puttaalu 4ddf5adf24 Make the timeline the default view, paginated and configurable
The day-grouped timeline is now the site root (/, /page/N/); the flat
square grid moves to /grid/ (/grid/page/N/). Both are linked from the
profile header, separated by a divider.

Timeline pages pack whole day-groups until they reach the page size,
never splitting a day across a page boundary, so appended pages never
repeat a date heading — the infinite-scroll handler relies on this.

Page size is configurable via -page-size (default 60), threaded through
the Generator and exposed as services.photogallery.pageSize in the
NixOS module.
2026-08-20 00:11:08 +02:00
puttaalu 9b6fcb482b Add a timeline page grouping photos by day
New /timeline/ page renders photos grouped into consecutive same-day
runs (ISO date heading plus per-day count), reusing the square grid.
groupByDay collapses the newest-first photo list; the page is not
paginated. Linked from the profile header.
2026-08-19 23:46:28 +02:00
91 changed files with 3591 additions and 288 deletions
+1
View File
@@ -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
+38 -5
View File
@@ -19,15 +19,25 @@ 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 /health`. Routes: `GET /upload` (HTML upload form), `POST /upload` (multipart), `GET /health`, and with `-manage`: `GET /manage` (list of photos), `GET`/`POST /manage/edit/{slug}` (edit caption and date), `GET`/`POST /manage/delete/{slug}` (confirm, then delete).
In production nginx sits in front: serves `public/` directly, proxies `/upload` to localhost, htpasswd on the upload endpoint. Note `/health` is **not** proxied — nginx only forwards `location /upload`, so anything outside the box can only reach `/upload`. 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.
### Managing photos
`manage.go` holds the `/manage` handlers, kept apart from the upload code in `server.go`. They are only registered when the binary runs with `-manage` (NixOS `services.photogallery.manage.enable`, which also adds the nginx location); without it the paths 404 and there is no edit or delete code path at all. The pages are rendered on request from the sidecars (not generated into `public/`), and nothing on the public site links to them. Constraints worth knowing before touching it:
- 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.
- `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`.
- 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
- **Go** — stdlib + `BurntSushi/toml`, `fsnotify/fsnotify`, `golang.org/x/image` - **Go** — stdlib + `BurntSushi/toml`, `fsnotify/fsnotify`, `golang.org/x/image`
- **Templates** — `html/template`, embedded via `embed.FS` - **Templates** — `html/template`, embedded via `embed.FS`
- **Images** — three renditions per photo: original copy, 600×600 centre-cropped square thumbnail, and a 1600px-longest-side "medium" for the detail page. Catmull-Rom resize. Outputs are skipped when newer than the source. - **Images** — four renditions per photo: original copy, 600×600 centre-cropped square thumbnail, a 1600px-longest-side "medium" for the detail page, and a 1200px "og" for link previews. Catmull-Rom resize. Outputs are skipped when newer than the source.
- **Feed** — Atom (`encoding/xml`) - **Feed** — Atom (`encoding/xml`)
- **NixOS** — `flake.nix` + `module.nix`, exports `nixosModules.default` - **NixOS** — `flake.nix` + `module.nix`, exports `nixosModules.default`
@@ -45,9 +55,26 @@ 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 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.
- 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.
- Single photo page with sidebar and newer/older navigation - Single photo page with sidebar and newer/older navigation
### Link previews
Photo pages carry Open Graph + Twitter-card meta tags so a shared link unfurls
in WhatsApp, Telegram, etc. `generateOG` writes a dedicated `public/og/`
rendition — 1200px longest side, quality laddered down until under ~250 KB,
because WhatsApp drops the preview above ~300 KB and the medium rendition can
exceed that. The `og:image` URL is absolute (`{{.BaseURL}}/...`); both crawlers
ignore relative image paths, and previews only work against the deployed
`-base-url`, never `http://localhost`. `og:image:width`/`height` come from
`DecodeConfig` on the OG file, read even when the file itself was skipped as up
to date. Both platforms cache per URL — after a deploy, re-scrape via
`@webpagebot` (Telegram) or Facebook's sharing debugger (WhatsApp).
### Photo page navigation ### Photo page navigation
`static/photo-nav.js` handles arrow keys and touch swipe. Both read their `static/photo-nav.js` handles arrow keys and touch swipe. Both read their
@@ -127,9 +154,15 @@ nix build # succeeds
| `main.go` | Flags, wires generator + watcher + server | | `main.go` | Flags, wires generator + watcher + server |
| `generator.go` | Scans content dir, renders HTML + Atom feed, generates thumbs/mediums | | `generator.go` | Scans content dir, renders HTML + Atom feed, generates thumbs/mediums |
| `server.go` | Upload API + optional static file serving | | `server.go` | Upload API + optional static file serving |
| `manage.go` | `/manage` list and edit handlers |
| `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 |
| `templates/manage.html` | `/manage`: list of photos with edit links |
| `templates/edit.html` | `/manage/edit/{slug}`: caption and date form |
| `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) |
| `flake.nix` | `buildGoModule` package + devShell + nixosModules | | `flake.nix` | `buildGoModule` package + devShell + nixosModules |
@@ -143,7 +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/edit via UI - Pruning renditions and photo pages of photos deleted from `content/` by hand (delete via `/manage` cleans up after itself)
- 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
+20 -17
View File
@@ -1,21 +1,24 @@
MIT License This is free and unencumbered software released into the public domain.
Copyright (c) 2026 Ashik S Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
Permission is hereby granted, free of charge, to any person obtaining a copy In jurisdictions that recognize copyright laws, the author or authors
of this software and associated documentation files (the "Software"), to deal of this software dedicate any and all copyright interest in the
in the Software without restriction, including without limitation the rights software to the public domain. We make this dedication for the benefit
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell of the public at large and to the detriment of our heirs and
copies of the Software, and to permit persons to whom the Software is successors. We intend this dedication to be an overt act of
furnished to do so, subject to the following conditions: relinquishment in perpetuity of all present and future rights to this
software under copyright law.
The above copyright notice and this permission notice shall be included in all THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
copies or substantial portions of the Software. EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR For more information, please refer to <https://unlicense.org>
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+24 -1
View File
@@ -69,6 +69,9 @@ services.photogallery = {
serifFamily = "EB Garamond"; serifFamily = "EB Garamond";
mlFamily = "Manjari"; mlFamily = "Manjari";
# Optional: edit and delete published photos at /manage (off by default)
manage.enable = true;
# Optional: built-in nginx vhost # Optional: built-in nginx vhost
nginx = { nginx = {
enable = true; enable = true;
@@ -101,9 +104,12 @@ nix-shell -p apacheHttpd --run \
| `fontsURL` | Crimson Pro + Manjari | Stylesheet URL loading both web fonts | | `fontsURL` | Crimson Pro + Manjari | Stylesheet URL loading both web fonts |
| `serifFamily` | `Crimson Pro` | CSS font-family for the primary serif | | `serifFamily` | `Crimson Pro` | CSS font-family for the primary serif |
| `mlFamily` | `Manjari` | CSS font-family for the alt-script bio | | `mlFamily` | `Manjari` | CSS font-family for the alt-script bio |
| `pageSize` | `60` | Photos per page on the timeline and grid views |
| `defaultView` | `timeline` | View at the site root, `timeline` or `grid`; the other is served under `/timeline/` or `/grid/` |
| `manage.enable` | `false` | Serve the `/manage` pages (edit, delete) and, with `nginx.enable`, protect them with the same htpasswd |
| `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` and `/manage` |
| `nginx.enableACME` | `true` | Enable Let's Encrypt | | `nginx.enableACME` | `true` | Enable Let's Encrypt |
--- ---
@@ -138,3 +144,20 @@ 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 and deleting photos
The pages are off by default: start the binary with `-manage` (NixOS:
`services.photogallery.manage.enable = true`). Then 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'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
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
it the path is not proxied and returns 404.
+1
View File
@@ -32,6 +32,7 @@
<!-- Share sheet target: pick a photo in Google Photos, share here. --> <!-- Share sheet target: pick a photo in Google Photos, share here. -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEND" /> <action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" /> <data android:mimeType="image/*" />
</intent-filter> </intent-filter>
@@ -39,8 +39,8 @@ class MainActivity : ComponentActivity() {
var showSettings by rememberSaveable { mutableStateOf(!viewModel.isConfigured) } var showSettings by rememberSaveable { mutableStateOf(!viewModel.isConfigured) }
val picker = rememberLauncherForActivityResult( val picker = rememberLauncherForActivityResult(
ActivityResultContracts.PickVisualMedia(), ActivityResultContracts.PickMultipleVisualMedia(),
) { uri -> uri?.let(viewModel::selectImage) } ) { uris -> if (uris.isNotEmpty()) viewModel.selectImages(uris) }
val notificationPermission = rememberLauncherForActivityResult( val notificationPermission = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(), ActivityResultContracts.RequestPermission(),
@@ -73,7 +73,7 @@ class MainActivity : ComponentActivity() {
} else { } else {
PostScreen( PostScreen(
state = viewModel.post, state = viewModel.post,
onPickImage = { onPickImages = {
picker.launch( picker.launch(
PickVisualMediaRequest( PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.ImageOnly, ActivityResultContracts.PickVisualMedia.ImageOnly,
@@ -82,6 +82,7 @@ class MainActivity : ComponentActivity() {
}, },
onCaptionChange = viewModel::setCaption, onCaptionChange = viewModel::setCaption,
onDateChange = viewModel::setDate, onDateChange = viewModel::setDate,
onRemove = viewModel::removeItem,
onSubmit = viewModel::submit, onSubmit = viewModel::submit,
onReset = viewModel::clear, onReset = viewModel::clear,
onOpenSettings = { showSettings = true }, onOpenSettings = { showSettings = true },
@@ -98,16 +99,31 @@ class MainActivity : ComponentActivity() {
consumeShareIntent(intent) consumeShareIntent(intent)
} }
/** Picks up a photo handed over by the share sheet. */ /** Picks up one or several photos handed over by the share sheet. */
private fun consumeShareIntent(intent: Intent?) { private fun consumeShareIntent(intent: Intent?) {
if (intent?.action != Intent.ACTION_SEND) return val uris: List<Uri> = when (intent?.action) {
Intent.ACTION_SEND -> {
val uri: Uri? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val uri: Uri? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_STREAM) intent.getParcelableExtra(Intent.EXTRA_STREAM)
} }
uri?.let(viewModel::selectImage) listOfNotNull(uri)
}
Intent.ACTION_SEND_MULTIPLE -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
}.orEmpty()
}
else -> return
}
if (uris.isNotEmpty()) viewModel.selectImages(uris)
} }
private fun openUrl(url: String) { private fun openUrl(url: String) {
@@ -1,17 +1,20 @@
package ws.inflo.photogallery.ui package ws.inflo.photogallery.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.layout.size
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePicker
@@ -36,8 +39,6 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.ZoneId import java.time.ZoneId
@@ -48,111 +49,104 @@ private val DISPLAY_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMM
@Composable @Composable
fun PostScreen( fun PostScreen(
state: PostUiState, state: PostUiState,
onPickImage: () -> Unit, onPickImages: () -> Unit,
onCaptionChange: (String) -> Unit, onCaptionChange: (id: String, value: String) -> Unit,
onDateChange: (LocalDate) -> Unit, onDateChange: (id: String, value: LocalDate) -> Unit,
onRemove: (id: String) -> Unit,
onSubmit: () -> Unit, onSubmit: () -> Unit,
onReset: () -> Unit, onReset: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onOpenUrl: (String) -> Unit, onOpenUrl: (String) -> Unit,
) { ) {
var datePickerOpen by remember { mutableStateOf(false) } // Which item's date is being edited, if any.
var dateEditFor by remember { mutableStateOf<PickedItem?>(null) }
val pendingCount = state.items.count { it.status !is ItemStatus.Success }
val canPost = state.hasItems && !state.posting && pendingCount > 0
Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxSize()) { Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxSize()) {
Column( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp), .padding(20.dp),
) { ) {
item {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text("Post a photo", style = MaterialTheme.typography.headlineSmall, color = GruvGreen) Text("Post photos", style = MaterialTheme.typography.headlineSmall, color = GruvGreen)
TextButton(onClick = onOpenSettings) { Text("Settings") } TextButton(onClick = onOpenSettings) { Text("Settings") }
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
}
item {
OutlinedButton(onClick = onPickImages, modifier = Modifier.fillMaxWidth()) {
Text(if (state.hasItems) "Choose different photos" else "Choose photos")
}
Spacer(Modifier.height(16.dp))
}
if (!state.hasItems) {
item {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(1f) .height(160.dp)
.background(GruvBg1), .background(GruvBg1),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
val preview = state.preview Text("No photos selected", color = GruvFg4)
when { }
preview != null -> Image( }
bitmap = preview.asImageBitmap(), }
contentDescription = null,
contentScale = ContentScale.Fit, items(state.items, key = { it.id }) { item ->
modifier = Modifier.fillMaxSize(), ItemCard(
item = item,
onCaptionChange = { onCaptionChange(item.id, it) },
onEditDate = { dateEditFor = item },
onRemove = { onRemove(item.id) },
onOpenUrl = onOpenUrl,
) )
state.imageUri != null -> CircularProgressIndicator(color = GruvAqua)
else -> Text("No photo selected", color = GruvFg4)
}
}
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
OutlinedButton(onClick = onPickImage, modifier = Modifier.fillMaxWidth()) {
Text(if (state.imageUri == null) "Choose photo" else "Choose a different photo")
} }
Spacer(Modifier.height(16.dp)) if (state.hasItems) {
item {
OutlinedTextField( Spacer(Modifier.height(8.dp))
value = state.caption,
onValueChange = onCaptionChange,
label = { Text("Caption") },
placeholder = { Text("Defaults to the filename if left blank") },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = { datePickerOpen = true },
modifier = Modifier.fillMaxWidth(),
) {
Text("Date: ${state.date.format(DISPLAY_DATE)}")
}
Spacer(Modifier.height(20.dp))
Button( Button(
onClick = onSubmit, onClick = onSubmit,
enabled = state.imageUri != null && state.status !is PostStatus.Uploading, enabled = canPost,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text(if (state.status is PostStatus.Uploading) "Posting…" else "Post") Text(
when {
state.posting -> "Posting… (${state.succeeded}/${state.items.size})"
pendingCount == 0 -> "All posted"
pendingCount == 1 -> "Post 1 photo"
else -> "Post $pendingCount photos"
},
)
} }
Spacer(Modifier.height(16.dp)) if (state.allDone) {
Spacer(Modifier.height(12.dp))
when (val status = state.status) { val summary = buildString {
is PostStatus.Error -> Text(status.message, color = GruvRed) append("${state.succeeded} posted")
if (state.failed > 0) append(", ${state.failed} failed")
is PostStatus.Success -> Column {
Text("Posted.", color = GruvAqua)
status.note?.let {
Spacer(Modifier.height(4.dp))
Text("Note: $it", color = GruvFg4, style = MaterialTheme.typography.bodySmall)
} }
Text(summary, color = if (state.failed > 0) GruvRed else GruvAqua)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Row { TextButton(onClick = onReset) { Text("Clear") }
TextButton(onClick = { onOpenUrl(status.url) }) { Text("Open") }
TextButton(onClick = onReset) { Text("Post another") }
} }
} }
else -> Unit
} }
state.lastPost?.let { last -> state.lastPost?.let { last ->
item {
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
HorizontalDivider(color = GruvBg2) HorizontalDivider(color = GruvBg2)
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
@@ -173,19 +167,96 @@ fun PostScreen(
} }
} }
} }
}
if (datePickerOpen) { dateEditFor?.let { editing ->
DatePickerModal( DatePickerModal(
initial = state.date, initial = editing.date,
onDismiss = { datePickerOpen = false }, onDismiss = { dateEditFor = null },
onConfirm = { onConfirm = {
onDateChange(it) onDateChange(editing.id, it)
datePickerOpen = false dateEditFor = null
}, },
) )
} }
} }
@Composable
private fun ItemCard(
item: PickedItem,
onCaptionChange: (String) -> Unit,
onEditDate: () -> Unit,
onRemove: () -> Unit,
onOpenUrl: (String) -> Unit,
) {
Row(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.size(84.dp)
.background(GruvBg1),
contentAlignment = Alignment.Center,
) {
val preview = item.preview
when {
preview != null -> Image(
bitmap = preview.asImageBitmap(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
else -> CircularProgressIndicator(color = GruvAqua, modifier = Modifier.size(24.dp))
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
OutlinedTextField(
value = item.caption,
onValueChange = onCaptionChange,
label = { Text("Caption") },
singleLine = true,
enabled = item.status !is ItemStatus.Uploading,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(6.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedButton(
onClick = onEditDate,
enabled = item.status !is ItemStatus.Uploading,
) {
Text(item.date.format(DISPLAY_DATE))
}
Spacer(Modifier.width(8.dp))
ItemStatusLabel(item.status, onOpenUrl)
}
}
Spacer(Modifier.width(8.dp))
when (item.status) {
is ItemStatus.Uploading ->
CircularProgressIndicator(color = GruvAqua, modifier = Modifier.size(20.dp))
is ItemStatus.Success -> Unit
else -> TextButton(onClick = onRemove) { Text("✕", color = GruvFg4) }
}
}
}
@Composable
private fun ItemStatusLabel(status: ItemStatus, onOpenUrl: (String) -> Unit) {
when (status) {
is ItemStatus.Pending -> Unit
is ItemStatus.Uploading -> Text("Uploading…", color = GruvFg4, style = MaterialTheme.typography.bodySmall)
is ItemStatus.Success -> TextButton(onClick = { onOpenUrl(status.url) }) { Text("View") }
is ItemStatus.Error -> Text(
status.message,
color = GruvRed,
style = MaterialTheme.typography.bodySmall,
)
}
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun DatePickerModal( private fun DatePickerModal(
@@ -24,22 +24,41 @@ import java.io.File
import java.time.LocalDate import java.time.LocalDate
import java.util.UUID import java.util.UUID
sealed interface PostStatus { /** Per-photo status within a batch. */
data object Idle : PostStatus sealed interface ItemStatus {
data object Uploading : PostStatus data object Pending : ItemStatus
data class Success(val url: String, val note: String?) : PostStatus data object Uploading : ItemStatus
data class Error(val message: String) : PostStatus data class Success(val url: String, val note: String?) : ItemStatus
data class Error(val message: String) : ItemStatus
} }
data class PostUiState( /**
val imageUri: Uri? = null, * One photo in the batch. A single-photo post (picker or share) is just a
* batch of one, so the whole screen runs a single code path.
*/
data class PickedItem(
val id: String = UUID.randomUUID().toString(),
val source: Uri,
val preview: Bitmap? = null, val preview: Bitmap? = null,
val caption: String = "", val caption: String = "",
val date: LocalDate = LocalDate.now(), val date: LocalDate = LocalDate.now(),
val status: PostStatus = PostStatus.Idle, val status: ItemStatus = ItemStatus.Pending,
val lastPost: LastPost? = null,
) )
data class PostUiState(
val items: List<PickedItem> = emptyList(),
val lastPost: LastPost? = null,
) {
val posting: Boolean get() = items.any { it.status is ItemStatus.Uploading }
val hasItems: Boolean get() = items.isNotEmpty()
val succeeded: Int get() = items.count { it.status is ItemStatus.Success }
val failed: Int get() = items.count { it.status is ItemStatus.Error }
/** True once every item has reached a terminal state and at least one ran. */
val allDone: Boolean
get() = items.isNotEmpty() &&
items.all { it.status is ItemStatus.Success || it.status is ItemStatus.Error }
}
data class SettingsUiState( data class SettingsUiState(
val baseUrl: String = "", val baseUrl: String = "",
val username: String = "", val username: String = "",
@@ -69,57 +88,81 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) {
// ── Post screen ───────────────────────────────────────────────────────── // ── Post screen ─────────────────────────────────────────────────────────
fun selectImage(uri: Uri) { /** Replaces the current selection with [uris] and loads each preview. */
post = post.copy(imageUri = uri, status = PostStatus.Idle, preview = null) fun selectImages(uris: List<Uri>) {
viewModelScope.launch { if (uris.isEmpty()) return
val items = uris.map { PickedItem(source = it) }
post = post.copy(items = items)
val context = getApplication<Application>() val context = getApplication<Application>()
val preview = withContext(Dispatchers.IO) { ImagePrep.preview(context, uri) } for (item in items) {
val captured = withContext(Dispatchers.IO) { ImagePrep.captureDate(context, uri) } viewModelScope.launch {
post = post.copy(preview = preview, date = captured ?: LocalDate.now()) val preview = withContext(Dispatchers.IO) {
ImagePrep.preview(context, item.source, maxDimension = 256)
}
val captured = withContext(Dispatchers.IO) {
ImagePrep.captureDate(context, item.source)
}
updateItem(item.id) {
it.copy(preview = preview, date = captured ?: it.date)
}
}
} }
} }
fun setCaption(value: String) { fun setCaption(id: String, value: String) {
post = post.copy(caption = value) updateItem(id) { it.copy(caption = value) }
} }
fun setDate(value: LocalDate) { fun setDate(id: String, value: LocalDate) {
post = post.copy(date = value) updateItem(id) { it.copy(date = value) }
}
fun removeItem(id: String) {
// A photo already uploading keeps its worker; just drop it from the UI.
post = post.copy(items = post.items.filterNot { it.id == id })
} }
fun clear() { fun clear() {
post = PostUiState(lastPost = settings.lastPost) post = PostUiState(lastPost = settings.lastPost)
} }
/** Uploads every item not already posted. Re-running retries only failures. */
fun submit() { fun submit() {
val uri = post.imageUri ?: return
if (!settings.isConfigured) { if (!settings.isConfigured) {
post = post.copy(status = PostStatus.Error("Set the server URL and credentials first")) post = post.copy(
items = post.items.map {
if (it.status is ItemStatus.Success) it
else it.copy(status = ItemStatus.Error("Set the server URL and credentials first"))
},
)
return return
} }
post = post.copy(status = PostStatus.Uploading) val pending = post.items.filter { it.status !is ItemStatus.Success }
if (pending.isEmpty()) return
viewModelScope.launch {
val context = getApplication<Application>() val context = getApplication<Application>()
for (item in pending) {
// Stage the bytes into our own cache before handing the job to updateItem(item.id) { it.copy(status = ItemStatus.Uploading) }
// WorkManager. A content:// URI from the share sheet is only viewModelScope.launch {
// readable while the grant lasts, and a retry can fire long after val staged = withContext(Dispatchers.IO) { stage(item.source) }
// the activity that received it is gone.
val staged = withContext(Dispatchers.IO) { stage(uri) }
if (staged == null) { if (staged == null) {
post = post.copy(status = PostStatus.Error("Could not read the selected image")) updateItem(item.id) {
it.copy(status = ItemStatus.Error("Could not read the selected image"))
}
return@launch return@launch
} }
val current = post.items.firstOrNull { it.id == item.id } ?: return@launch
val id = UploadQueue.enqueue( val workId = UploadQueue.enqueue(
context = context, context = context,
uri = Uri.fromFile(staged), uri = Uri.fromFile(staged),
caption = post.caption, caption = current.caption,
date = post.date, date = current.date,
uniqueSuffix = item.id.take(6),
) )
observe(id) observe(item.id, workId)
}
} }
} }
@@ -136,32 +179,35 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) {
} }
} }
private fun observe(id: UUID) { private fun observe(itemId: String, workId: UUID) {
viewModelScope.launch { viewModelScope.launch {
WorkManager.getInstance(getApplication()).getWorkInfoByIdFlow(id).collect { info -> WorkManager.getInstance(getApplication()).getWorkInfoByIdFlow(workId).collect { info ->
if (info == null) return@collect if (info == null) return@collect
when (info.state) { when (info.state) {
WorkInfo.State.SUCCEEDED -> { WorkInfo.State.SUCCEEDED -> {
post = post.copy( updateItem(itemId) {
status = PostStatus.Success( it.copy(
status = ItemStatus.Success(
url = info.outputData.getString(UploadWorker.KEY_RESULT_URL).orEmpty(), url = info.outputData.getString(UploadWorker.KEY_RESULT_URL).orEmpty(),
note = info.outputData.getString(UploadWorker.KEY_RESULT_NOTE), note = info.outputData.getString(UploadWorker.KEY_RESULT_NOTE),
), ),
lastPost = settings.lastPost,
) )
} }
post = post.copy(lastPost = settings.lastPost)
}
WorkInfo.State.FAILED -> { WorkInfo.State.FAILED -> updateItem(itemId) {
post = post.copy( it.copy(
status = PostStatus.Error( status = ItemStatus.Error(
info.outputData.getString(UploadWorker.KEY_RESULT_ERROR) info.outputData.getString(UploadWorker.KEY_RESULT_ERROR)
?: "Upload failed", ?: "Upload failed",
), ),
) )
} }
WorkInfo.State.CANCELLED -> WorkInfo.State.CANCELLED -> updateItem(itemId) {
post = post.copy(status = PostStatus.Error("Upload cancelled")) it.copy(status = ItemStatus.Error("Upload cancelled"))
}
else -> Unit else -> Unit
} }
@@ -169,6 +215,10 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) {
} }
} }
private inline fun updateItem(id: String, transform: (PickedItem) -> PickedItem) {
post = post.copy(items = post.items.map { if (it.id == id) transform(it) else it })
}
// ── Settings screen ───────────────────────────────────────────────────── // ── Settings screen ─────────────────────────────────────────────────────
fun setBaseUrl(value: String) { fun setBaseUrl(value: String) {
@@ -18,9 +18,11 @@ object UploadQueue {
/** /**
* Slug format. Uses the enqueue wall-clock time rather than the photo's * Slug format. Uses the enqueue wall-clock time rather than the photo's
* capture time: a burst of frames shot in the same second would collide on * capture time: capture time collides across a burst. A [uniqueSuffix] is
* capture time, whereas posts are made one at a time. The real capture date * appended so that a bulk post — several photos enqueued in the same second
* still travels in the `date` field and lands in the .toml sidecar. * — cannot collide either; without it enqueueUniqueWork would silently drop
* the duplicates. The real capture date still travels in the `date` field
* and lands in the .toml sidecar.
*/ */
private val SLUG_FORMAT: DateTimeFormatter = private val SLUG_FORMAT: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd-HHmmss") DateTimeFormatter.ofPattern("yyyy-MM-dd-HHmmss")
@@ -32,8 +34,9 @@ object UploadQueue {
uri: Uri, uri: Uri,
caption: String, caption: String,
date: LocalDate, date: LocalDate,
uniqueSuffix: String,
): UUID { ): UUID {
val slug = LocalDateTime.now().format(SLUG_FORMAT) val slug = LocalDateTime.now().format(SLUG_FORMAT) + "-" + uniqueSuffix
val request = OneTimeWorkRequestBuilder<UploadWorker>() val request = OneTimeWorkRequestBuilder<UploadWorker>()
.setInputData( .setInputData(
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#1d2021"
android:pathData="M0,0h108v108h-108z" />
</vector>
@@ -1,28 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Aperture blades in Gruvbox aqua on the adaptive-icon 108dp canvas.
The safe zone is the middle 72dp, so the art is inset accordingly. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#8ec07c"
android:pathData="M54,30 A24,24 0 0,1 78,54 L54,54 Z" />
<path
android:fillColor="#689d6a"
android:pathData="M78,54 A24,24 0 0,1 54,78 L54,54 Z" />
<path
android:fillColor="#8ec07c"
android:pathData="M54,78 A24,24 0 0,1 30,54 L54,54 Z" />
<path
android:fillColor="#689d6a"
android:pathData="M30,54 A24,24 0 0,1 54,30 L54,54 Z" />
<path
android:strokeColor="#ebdbb2"
android:strokeWidth="3"
android:fillColor="#00000000"
android:pathData="M54,28 A26,26 0 1,1 53.99,28 Z" />
</vector>
@@ -1,7 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- minSdk is 28, so every device resolves the v26 adaptive icon and no <!-- minSdk is 28, so every device resolves the v26 adaptive icon and no
density-bucket PNG fallbacks are needed. --> density-bucket PNG fallbacks are needed. The foreground is the full circular
app icon (ic_launcher_foreground.png); the launcher mask crops the disc to
its shape, and the solid gruvbox background fills the transparent corners. -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" /> <background android:drawable="@color/gruvbox_bg" />
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon> </adaptive-icon>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" /> <background android:drawable="@color/gruvbox_bg" />
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon> </adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

+1 -1
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Gallery Upload</string> <string name="app_name">Photog</string>
<string name="upload_channel_name">Uploads</string> <string name="upload_channel_name">Uploads</string>
</resources> </resources>
+3
View File
@@ -0,0 +1,3 @@
/build/
/.direnv/
.envrc
+42
View File
@@ -0,0 +1,42 @@
cmake_minimum_required(VERSION 3.20)
project(photogallery_uploader LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
find_package(Qt6 REQUIRED COMPONENTS Widgets Network Concurrent)
find_package(Qt6Keychain REQUIRED)
add_executable(uploader
main.cpp
mainwindow.cpp
photoitemwidget.cpp
settings.cpp
uploadclient.cpp
imageprep.cpp
app.qrc
)
target_link_libraries(uploader PRIVATE
Qt6::Widgets
Qt6::Network
Qt6::Concurrent
Qt6Keychain::Qt6Keychain
)
# The binary is "photog" (matching the app + package), so `nix run` and the
# desktop entry find it; the CMake target stays "uploader".
set_target_properties(uploader PROPERTIES OUTPUT_NAME photog)
# Installation — the binary, a desktop entry, and the app icon in the hicolor
# theme, so `nix profile install ./desktop` puts "Photog" in the app menu.
include(GNUInstallDirs)
install(TARGETS uploader RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
install(FILES photog.desktop DESTINATION ${CMAKE_INSTALL_DATADIR}/applications)
foreach(sz 32 128 256 512)
install(FILES icons/app-${sz}.png
DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/${sz}x${sz}/apps
RENAME photog.png)
endforeach()
+9
View File
@@ -0,0 +1,9 @@
<!DOCTYPE RCC>
<RCC version="1.0">
<qresource prefix="/">
<file>icons/app-32.png</file>
<file>icons/app-128.png</file>
<file>icons/app-256.png</file>
<file>icons/app-512.png</file>
</qresource>
</RCC>
+61
View File
@@ -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": 1787498568,
"narHash": "sha256-9i/VTdusq/+NM/tz+J1Re+ojkMB8MBf0QshnYfzHz30=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "56c02bc00adcf003215cc4bd996d6efaf4cff188",
"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
}
+55
View File
@@ -0,0 +1,55 @@
{
description = "photogallery uploader — Qt6 C++ desktop app";
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 = import nixpkgs { inherit system; };
inherit (pkgs) lib stdenv;
# Wayland is Linux-only; macOS uses the Cocoa QPA plugin inside qtbase,
# so qtwayland is added only on Linux (it refuses to evaluate on darwin).
qtModules = [ pkgs.qt6.qtbase pkgs.qt6.qtimageformats ]
++ lib.optionals stdenv.hostPlatform.isLinux [ pkgs.qt6.qtwayland ];
qtPlugins = lib.makeSearchPathOutput "out" "lib/qt-6/plugins" qtModules;
in {
# Installable build: `nix profile install ./desktop` drops "Photog" into
# the application menu with its icon. wrapQtAppsHook wraps the binary so
# it finds the Qt platform (Cocoa on macOS; xcb + wayland on Linux) and
# image-format plugins on its own, without the dev shell.
packages.default = stdenv.mkDerivation {
pname = "photog";
version = "0.1.0";
src = ./.;
nativeBuildInputs = with pkgs; [
cmake
ninja
pkg-config
qt6.wrapQtAppsHook
];
buildInputs = [ pkgs.qt6Packages.qtkeychain ] ++ qtModules;
meta.mainProgram = "photog";
};
devShells.default = pkgs.mkShell {
packages = with pkgs; [
cmake
ninja
pkg-config
qt6Packages.qtkeychain # password → Keychain (macOS) / libsecret / kwallet
] ++ qtModules;
shellHook = ''
# Let a binary run straight from the build dir find Qt's platform and
# image-format plugins without wrapQtAppsHook.
export QT_PLUGIN_PATH="${qtPlugins}''${QT_PLUGIN_PATH:+:$QT_PLUGIN_PATH}"
'';
};
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

+237
View File
@@ -0,0 +1,237 @@
#include "imageprep.h"
#include <QBuffer>
#include <QDir>
#include <QFile>
#include <QImage>
#include <QImageReader>
#include <QImageWriter>
namespace {
// nginx is configured with client_max_body_size 20M; leave headroom.
constexpr qint64 kMaxUploadBytes = 18LL * 1024 * 1024;
constexpr int kJpegQuality = 92;
const int kQualityLadder[] = {92, 85, 75};
// Encodes to JPEG in memory at the given quality.
QByteArray encodeJpeg(const QImage &image, int quality)
{
QByteArray out;
QBuffer buffer(&out);
buffer.open(QIODevice::WriteOnly);
QImageWriter writer(&buffer, "jpeg");
writer.setQuality(quality);
writer.write(image);
return out;
}
// Decodes with EXIF orientation baked into the pixels. Works for HEIC too, via
// the qtimageformats plugin.
QImage decodeOriented(const QString &path)
{
QImageReader reader(path);
reader.setAutoTransform(true);
return reader.read();
}
} // namespace
ImagePrep::Prepared ImagePrep::prepare(const QString &sourcePath, const QString &cacheDir,
const QString &baseName)
{
Prepared result;
QFile in(sourcePath);
if (!in.open(QIODevice::ReadOnly)) {
result.error = QStringLiteral("Could not read the selected image");
return result;
}
const QByteArray source = in.readAll();
in.close();
QStringList notes;
// A rotation flag cannot survive metadata stripping, so bake it into the
// pixels instead, which costs a re-encode.
QImageReader orientationReader(sourcePath);
const bool mustRotate =
orientationReader.transformation() != QImageIOHandler::TransformationNone;
QByteArray bytes;
QString extension;
if (!mustRotate && isJpeg(source)) {
bytes = stripJpegSegments(source);
extension = QStringLiteral("jpg");
} else if (!mustRotate && isPng(source)) {
bytes = stripPngChunks(source);
extension = QStringLiteral("png");
}
if (bytes.isEmpty()) {
// Re-encode path: rotated originals, HEIC, or a JPEG/PNG whose marker
// structure did not parse.
const QImage image = decodeOriented(sourcePath);
if (image.isNull()) {
result.error = QStringLiteral("Could not decode the selected image");
return result;
}
if (mustRotate)
notes << QStringLiteral("rotated to match its EXIF orientation");
else if (!isJpeg(source) && !isPng(source))
notes << QStringLiteral("converted to JPEG");
bytes = encodeJpeg(image, kJpegQuality);
extension = QStringLiteral("jpg");
}
// Size valve. Only trips on genuinely huge originals; quality is stepped
// before touching resolution.
if (bytes.size() > kMaxUploadBytes) {
QImage image = decodeOriented(sourcePath);
if (image.isNull()) {
result.error = QStringLiteral("Could not decode the selected image");
return result;
}
QByteArray shrunk;
for (int quality : kQualityLadder) {
const QByteArray attempt = encodeJpeg(image, quality);
if (attempt.size() <= kMaxUploadBytes) {
shrunk = attempt;
notes << QStringLiteral("re-encoded at quality %1 to fit the 20M upload limit").arg(quality);
break;
}
}
if (shrunk.isEmpty()) {
double factor = 1.0;
while (factor > 0.2) {
factor *= 0.75;
const QImage scaled = image.scaledToWidth(
qMax(1, int(image.width() * factor)), Qt::SmoothTransformation);
const QByteArray attempt = encodeJpeg(scaled, kQualityLadder[2]);
if (attempt.size() <= kMaxUploadBytes) {
shrunk = attempt;
notes << QStringLiteral("scaled to %1×%2 to fit the 20M upload limit")
.arg(scaled.width()).arg(scaled.height());
break;
}
}
}
if (shrunk.isEmpty()) {
result.error = QStringLiteral("Image is too large to upload even after downscaling");
return result;
}
bytes = shrunk;
extension = QStringLiteral("jpg");
}
const QString outPath = QDir(cacheDir).filePath(baseName + QLatin1Char('.') + extension);
QFile out(outPath);
if (!out.open(QIODevice::WriteOnly) || out.write(bytes) != bytes.size()) {
result.error = QStringLiteral("Could not write the prepared image");
return result;
}
out.close();
result.filePath = outPath;
result.note = notes.join(QStringLiteral("; "));
return result;
}
bool ImagePrep::isJpeg(const QByteArray &b)
{
return b.size() > 3 && quint8(b[0]) == 0xFF && quint8(b[1]) == 0xD8 && quint8(b[2]) == 0xFF;
}
bool ImagePrep::isPng(const QByteArray &b)
{
return b.size() > 8 && quint8(b[0]) == 0x89 && quint8(b[1]) == 0x50 && quint8(b[2]) == 0x4E
&& quint8(b[3]) == 0x47;
}
// Drops APP1 (EXIF and XMP) and COM segments, keeping the compressed scan data
// byte-for-byte. APP0/JFIF and APP2/ICC stay: removing the colour profile would
// shift the rendered colours. Returns an empty array if the marker structure
// does not parse, so the caller falls back to a re-encode.
QByteArray ImagePrep::stripJpegSegments(const QByteArray &src)
{
const auto u8 = [&](int i) { return quint8(src[i]); };
QByteArray out;
out.append(char(0xFF));
out.append(char(0xD8));
int i = 2;
while (i + 1 < src.size()) {
if (u8(i) != 0xFF)
return {};
const int marker = u8(i + 1);
// Standalone markers: no length field, no payload.
if (marker == 0x01 || (marker >= 0xD0 && marker <= 0xD7)) {
out.append(char(0xFF));
out.append(char(marker));
i += 2;
continue;
}
// Start of scan: entropy-coded data runs to the end of the file.
if (marker == 0xDA) {
out.append(src.mid(i));
return out;
}
if (i + 3 >= src.size())
return {};
const int length = (u8(i + 2) << 8) | u8(i + 3);
if (length < 2 || i + 2 + length > src.size())
return {};
const bool isExifOrXmp = marker == 0xE1;
const bool isComment = marker == 0xFE;
if (!isExifOrXmp && !isComment)
out.append(src.mid(i, 2 + length));
i += 2 + length;
}
return {};
}
// Drops the metadata chunks, keeps everything else including the critical
// IHDR/PLTE/IDAT/IEND run and colour chunks (iCCP, sRGB). Whole chunks go, so no
// CRC needs recomputing.
QByteArray ImagePrep::stripPngChunks(const QByteArray &src)
{
static const QByteArray drop[] = {"eXIf", "tEXt", "zTXt", "iTXt", "tIME"};
const auto u8 = [&](int i) { return quint8(src[i]); };
if (src.size() < 8)
return {};
QByteArray out;
out.append(src.mid(0, 8));
int i = 8;
while (i + 8 <= src.size()) {
const qint64 length =
(qint64(u8(i)) << 24) | (qint64(u8(i + 1)) << 16) | (qint64(u8(i + 2)) << 8) | qint64(u8(i + 3));
if (length < 0)
return {};
const QByteArray type = src.mid(i + 4, 4);
const qint64 total = 12 + length;
if (i + total > src.size())
return {};
bool dropped = false;
for (const QByteArray &d : drop) {
if (type == d) {
dropped = true;
break;
}
}
if (!dropped)
out.append(src.mid(i, int(total)));
i += int(total);
if (type == "IEND")
return out;
}
return {};
}
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include <QByteArray>
#include <QString>
// Turns a picked image into a file the gallery's upload API accepts.
//
// Three things must be true of the result: no EXIF (the gallery publishes the
// uploaded file verbatim as its full-size download, so embedded GPS would be
// public), a .jpg or .png extension (server.go rejects anything else, HEIC
// included), and under nginx's 20M body cap.
//
// JPEG and PNG take a lossless route — whole metadata segments are dropped
// without touching the compressed image data — so the published file keeps the
// original sensor pixels. Anything else, or a file whose EXIF orientation must
// be baked in, is decoded and re-encoded as JPEG (HEIC via Qt's qtimageformats
// plugin). The strippers are a direct port of the Android app's.
class ImagePrep
{
public:
struct Prepared {
QString filePath;
QString note; // non-empty when the app departed from a byte-exact copy
QString error;
bool ok() const { return error.isEmpty(); }
};
// Writes the upload-ready file into cacheDir as baseName.(jpg|png).
static Prepared prepare(const QString &sourcePath, const QString &cacheDir,
const QString &baseName);
private:
static QByteArray stripJpegSegments(const QByteArray &src); // empty on parse failure
static QByteArray stripPngChunks(const QByteArray &src);
static bool isJpeg(const QByteArray &b);
static bool isPng(const QByteArray &b);
};
+25
View File
@@ -0,0 +1,25 @@
#include <QApplication>
#include <QIcon>
#include "mainwindow.h"
int main(int argc, char **argv)
{
QApplication app(argc, argv);
app.setApplicationName(QStringLiteral("photogallery-uploader"));
app.setOrganizationName(QStringLiteral("inflo.ws"));
// Window / taskbar / alt-tab icon, embedded via app.qrc. Several sizes so
// the small titlebar rendering stays crisp rather than downscaling 512px.
QIcon icon;
icon.addFile(QStringLiteral(":/icons/app-32.png"));
icon.addFile(QStringLiteral(":/icons/app-128.png"));
icon.addFile(QStringLiteral(":/icons/app-256.png"));
icon.addFile(QStringLiteral(":/icons/app-512.png"));
app.setWindowIcon(icon);
MainWindow window;
window.resize(440, 680);
window.show();
return app.exec();
}
+361
View File
@@ -0,0 +1,361 @@
#include "mainwindow.h"
#include "imageprep.h"
#include "photoitemwidget.h"
#include "uploadclient.h"
#include <QDateTime>
#include <QFileDialog>
#include <QFileInfo>
#include <QFormLayout>
#include <QFuture>
#include <QFutureWatcher>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QScrollArea>
#include <QSpinBox>
#include <QStackedWidget>
#include <QStandardPaths>
#include <QUuid>
#include <QVBoxLayout>
#include <QtConcurrent>
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent)
{
setWindowTitle(QStringLiteral("Photo Gallery Uploader"));
m_stack = new QStackedWidget(this);
m_stack->addWidget(buildSettingsPage()); // index 0
m_stack->addWidget(buildPostPage()); // index 1
setCentralWidget(m_stack);
if (m_settings.isConfigured())
showPost();
else
showSettings();
}
QWidget *MainWindow::buildSettingsPage()
{
auto *page = new QWidget;
auto *layout = new QVBoxLayout(page);
layout->setContentsMargins(24, 24, 24, 24);
auto *form = new QFormLayout;
m_baseUrl = new QLineEdit;
m_baseUrl->setPlaceholderText(QStringLiteral("https://photos.example.com"));
m_username = new QLineEdit;
m_password = new QLineEdit;
m_password->setEchoMode(QLineEdit::Password);
m_concurrency = new QSpinBox;
m_concurrency->setRange(1, Settings::kMaxConcurrency);
form->addRow(QStringLiteral("Base URL"), m_baseUrl);
form->addRow(QStringLiteral("Username"), m_username);
form->addRow(QStringLiteral("Password"), m_password);
form->addRow(QStringLiteral("Simultaneous uploads"), m_concurrency);
layout->addLayout(form);
m_saveButton = new QPushButton(QStringLiteral("Save && test connection"));
connect(m_saveButton, &QPushButton::clicked, this, &MainWindow::saveAndTest);
layout->addWidget(m_saveButton);
m_settingsStatus = new QLabel;
m_settingsStatus->setWordWrap(true);
layout->addWidget(m_settingsStatus);
m_settingsBack = new QPushButton(QStringLiteral("Back"));
connect(m_settingsBack, &QPushButton::clicked, this, &MainWindow::showPost);
layout->addWidget(m_settingsBack);
layout->addWidget(new QLabel(QStringLiteral(
"The password is stored in your system keychain, never in plain text. "
"https is required — Basic auth would otherwise send it in the clear.")));
layout->addStretch();
return page;
}
QWidget *MainWindow::buildPostPage()
{
auto *page = new QWidget;
auto *outer = new QVBoxLayout(page);
outer->setContentsMargins(24, 24, 24, 24);
auto *choose = new QPushButton(QStringLiteral("Choose photos…"));
connect(choose, &QPushButton::clicked, this, &MainWindow::chooseImages);
outer->addWidget(choose);
// Scrollable list of per-photo cards.
auto *scroll = new QScrollArea;
scroll->setWidgetResizable(true);
auto *container = new QWidget;
m_itemsLayout = new QVBoxLayout(container);
m_itemsLayout->setAlignment(Qt::AlignTop);
m_emptyHint = new QLabel(QStringLiteral("No photos selected"));
m_emptyHint->setAlignment(Qt::AlignCenter);
m_itemsLayout->addWidget(m_emptyHint);
scroll->setWidget(container);
outer->addWidget(scroll, 1);
m_postButton = new QPushButton(QStringLiteral("Post"));
m_postButton->setEnabled(false);
connect(m_postButton, &QPushButton::clicked, this, &MainWindow::submit);
outer->addWidget(m_postButton);
m_postSummary = new QLabel;
m_postSummary->setWordWrap(true);
outer->addWidget(m_postSummary);
m_lastPost = new QLabel;
m_lastPost->setOpenExternalLinks(true);
m_lastPost->setWordWrap(true);
outer->addWidget(m_lastPost);
auto *settingsButton = new QPushButton(QStringLiteral("Settings"));
connect(settingsButton, &QPushButton::clicked, this, &MainWindow::showSettings);
outer->addWidget(settingsButton);
return page;
}
void MainWindow::showSettings()
{
m_baseUrl->setText(m_settings.baseUrl());
m_username->setText(m_settings.username());
m_password->setText(m_settings.readPassword());
m_concurrency->setValue(m_settings.concurrency());
m_settingsStatus->clear();
m_settingsBack->setVisible(m_settings.isConfigured());
m_stack->setCurrentIndex(0);
}
void MainWindow::showPost()
{
const Settings::LastPost last = m_settings.lastPost();
if (last.valid()) {
m_lastPost->setText(QStringLiteral("Last post: <a href=\"%1\">%2</a>")
.arg(last.url, last.caption.isEmpty() ? last.slug : last.caption));
} else {
m_lastPost->clear();
}
m_stack->setCurrentIndex(1);
}
void MainWindow::saveAndTest()
{
// Concurrency is not a credential; persist it regardless of URL validity.
m_settings.setConcurrency(m_concurrency->value());
const QString urlError = Settings::validateBaseUrl(m_baseUrl->text());
if (!urlError.isEmpty()) {
m_settingsStatus->setText(urlError);
return;
}
if (m_username->text().trimmed().isEmpty() || m_password->text().isEmpty()) {
m_settingsStatus->setText(QStringLiteral("Username and password are required"));
return;
}
m_settings.setBaseUrl(m_baseUrl->text());
m_settings.setUsername(m_username->text());
m_settings.writePassword(m_password->text());
m_baseUrl->setText(m_settings.baseUrl());
m_saveButton->setEnabled(false);
m_settingsStatus->setText(QStringLiteral("Testing…"));
auto *client = new UploadClient(m_settings.baseUrl(), m_settings.username(),
m_settings.readPassword(), this);
connect(client, &UploadClient::testFinished, this, [this, client](const QString &error) {
m_saveButton->setEnabled(true);
m_settingsBack->setVisible(m_settings.isConfigured());
m_settingsStatus->setText(error.isEmpty()
? QStringLiteral("Saved — server reachable and credentials accepted")
: error);
client->deleteLater();
});
client->testConnection();
}
void MainWindow::chooseImages()
{
const QStringList paths = QFileDialog::getOpenFileNames(
this, QStringLiteral("Choose photos"), QString(),
QStringLiteral("Images (*.jpg *.jpeg *.png *.heic *.heif *.webp *.tif *.tiff *.bmp);;All files (*)"));
for (const QString &path : paths)
addItem(path);
refreshPosting();
}
void MainWindow::addItem(const QString &path)
{
m_emptyHint->hide();
auto *item = new PhotoItemWidget(path);
connect(item, &PhotoItemWidget::removeRequested, this, &MainWindow::removeItem);
m_items.append(item);
m_itemsLayout->addWidget(item);
}
void MainWindow::removeItem(PhotoItemWidget *item)
{
m_items.removeOne(item);
m_queue.removeOne(item); // may be waiting for a slot — drop it before deleting
item->deleteLater();
if (m_items.isEmpty())
m_emptyHint->show();
refreshPosting();
}
void MainWindow::clearItems()
{
for (PhotoItemWidget *item : std::as_const(m_items))
item->deleteLater();
m_items.clear();
m_queue.clear();
m_emptyHint->show();
m_postSummary->clear();
refreshPosting();
}
void MainWindow::submit()
{
if (m_items.isEmpty())
return;
if (!m_settings.isConfigured()) {
m_postSummary->setText(QStringLiteral("Set the server URL and credentials first"));
return;
}
// Capture the batch's credentials once, then feed the queue through a
// concurrency cap rather than firing every photo at the server at once.
m_batchCacheDir = QStandardPaths::writableLocation(QStandardPaths::TempLocation);
m_batchBaseUrl = m_settings.baseUrl();
m_batchUsername = m_settings.username();
m_batchPassword = m_settings.readPassword();
m_queue.clear();
for (PhotoItemWidget *item : std::as_const(m_items)) {
if (item->status() != PhotoItemWidget::Status::Success)
m_queue.append(item); // re-posting retries only failures
}
if (m_queue.isEmpty())
return;
m_postSummary->clear();
pumpQueue();
refreshPosting();
}
int MainWindow::inFlightCount() const
{
int n = 0;
for (PhotoItemWidget *item : std::as_const(m_items)) {
const PhotoItemWidget::Status s = item->status();
if (s == PhotoItemWidget::Status::Preparing || s == PhotoItemWidget::Status::Uploading)
++n;
}
return n;
}
void MainWindow::pumpQueue()
{
const int cap = m_settings.concurrency();
while (inFlightCount() < cap && !m_queue.isEmpty())
startItem(m_queue.takeFirst());
}
void MainWindow::startItem(PhotoItemWidget *item)
{
item->setStatus(PhotoItemWidget::Status::Preparing);
// Slug from wall-clock time plus a per-item suffix so a batch posted in the
// same second cannot collide (server derives the stored name from the
// uploaded filename). The real capture date rides in the date field.
const QString slug = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd-HHmmss"))
+ QLatin1Char('-') + QUuid::createUuid().toString(QUuid::Id128).left(6);
const QString source = item->sourcePath();
const QString cacheDir = m_batchCacheDir;
const QString baseUrl = m_batchBaseUrl;
const QString username = m_batchUsername;
const QString password = m_batchPassword;
auto *watcher = new QFutureWatcher<ImagePrep::Prepared>(this);
connect(watcher, &QFutureWatcher<ImagePrep::Prepared>::finished, this,
[this, watcher, item, baseUrl, username, password]() {
const ImagePrep::Prepared prepared = watcher->result();
watcher->deleteLater();
if (!prepared.ok()) {
item->setStatus(PhotoItemWidget::Status::Error, prepared.error);
pumpQueue(); // free the slot for the next photo
refreshPosting();
return;
}
item->setStatus(PhotoItemWidget::Status::Uploading);
const QString caption = item->caption();
auto *client = new UploadClient(baseUrl, username, password, this);
connect(client, &UploadClient::uploadFinished, this,
[this, client, item, prepared, caption](UploadClient::Result result,
const QString &slug, const QString &url,
const QString &message) {
QFile::remove(prepared.filePath); // no retry queue on the desktop
if (result == UploadClient::Result::Success) {
m_settings.setLastPost({slug, url, caption, QDateTime::currentDateTime()});
item->setStatus(PhotoItemWidget::Status::Success, {}, url);
} else {
item->setStatus(PhotoItemWidget::Status::Error, message);
}
client->deleteLater();
pumpQueue(); // free the slot for the next photo
refreshPosting();
});
client->upload(prepared.filePath, QFileInfo(prepared.filePath).fileName(),
caption, item->date());
});
watcher->setFuture(QtConcurrent::run([source, cacheDir, slug]() {
return ImagePrep::prepare(source, cacheDir, slug);
}));
}
void MainWindow::refreshPosting()
{
int pending = 0;
int inFlight = 0;
int succeeded = 0;
int failed = 0;
for (PhotoItemWidget *item : std::as_const(m_items)) {
switch (item->status()) {
case PhotoItemWidget::Status::Pending:
++pending;
break;
case PhotoItemWidget::Status::Preparing:
case PhotoItemWidget::Status::Uploading:
++inFlight;
break;
case PhotoItemWidget::Status::Success:
++succeeded;
break;
case PhotoItemWidget::Status::Error:
++failed;
++pending; // a failure is re-postable
break;
}
}
const bool posting = inFlight > 0;
m_postButton->setEnabled(!m_items.isEmpty() && !posting && pending > 0);
m_postButton->setText(posting ? QStringLiteral("Posting… (%1/%2)").arg(succeeded).arg(m_items.size())
: pending == 1 ? QStringLiteral("Post 1 photo")
: pending > 1 ? QStringLiteral("Post %1 photos").arg(pending)
: QStringLiteral("All posted"));
if (!posting && (succeeded + failed) > 0) {
QString summary = QStringLiteral("%1 posted").arg(succeeded);
if (failed > 0)
summary += QStringLiteral(", %1 failed").arg(failed);
m_postSummary->setText(summary);
}
}
+69
View File
@@ -0,0 +1,69 @@
#pragma once
#include <QList>
#include <QMainWindow>
#include "settings.h"
class QDateEdit;
class QLabel;
class QLineEdit;
class QPushButton;
class QSpinBox;
class QStackedWidget;
class QVBoxLayout;
class PhotoItemWidget;
class MainWindow : public QMainWindow
{
Q_OBJECT
public:
explicit MainWindow(QWidget *parent = nullptr);
private:
QWidget *buildSettingsPage();
QWidget *buildPostPage();
void showSettings();
void showPost();
void saveAndTest();
void chooseImages();
void addItem(const QString &path);
void removeItem(PhotoItemWidget *item);
void clearItems();
void submit();
void pumpQueue(); // starts queued items up to the concurrency cap
void startItem(PhotoItemWidget *item);
int inFlightCount() const; // items currently preparing or uploading
void refreshPosting(); // updates the Post button + summary from item states
Settings m_settings;
QStackedWidget *m_stack = nullptr;
// Settings page
QLineEdit *m_baseUrl = nullptr;
QLineEdit *m_username = nullptr;
QLineEdit *m_password = nullptr;
QSpinBox *m_concurrency = nullptr;
QPushButton *m_saveButton = nullptr;
QPushButton *m_settingsBack = nullptr;
QLabel *m_settingsStatus = nullptr;
// Post page
QVBoxLayout *m_itemsLayout = nullptr; // holds the PhotoItemWidgets
QList<PhotoItemWidget *> m_items;
QLabel *m_emptyHint = nullptr;
QPushButton *m_postButton = nullptr;
QLabel *m_postSummary = nullptr;
QLabel *m_lastPost = nullptr;
// Bulk-post state: items waiting for a free upload slot, plus the batch's
// credentials captured once at submit time.
QList<PhotoItemWidget *> m_queue;
QString m_batchBaseUrl;
QString m_batchUsername;
QString m_batchPassword;
QString m_batchCacheDir;
};
+9
View File
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Photog
GenericName=Photo Uploader
Comment=Upload photos to your gallery
Exec=photog
Icon=photog
Terminal=false
Categories=Graphics;Photography;Network;
+101
View File
@@ -0,0 +1,101 @@
#include "photoitemwidget.h"
#include <QDate>
#include <QDateEdit>
#include <QHBoxLayout>
#include <QImageReader>
#include <QLabel>
#include <QLineEdit>
#include <QPixmap>
#include <QPushButton>
#include <QVBoxLayout>
namespace {
constexpr int kThumbSize = 72;
}
PhotoItemWidget::PhotoItemWidget(QString sourcePath, QWidget *parent)
: QWidget(parent)
, m_sourcePath(std::move(sourcePath))
{
auto *row = new QHBoxLayout(this);
row->setContentsMargins(0, 4, 0, 4);
m_thumb = new QLabel;
m_thumb->setFixedSize(kThumbSize, kThumbSize);
m_thumb->setAlignment(Qt::AlignCenter);
m_thumb->setFrameShape(QFrame::Box);
{
QImageReader reader(m_sourcePath);
reader.setAutoTransform(true);
const QImage image = reader.read();
if (!image.isNull())
m_thumb->setPixmap(QPixmap::fromImage(image).scaled(
kThumbSize, kThumbSize, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation));
else
m_thumb->setText(QStringLiteral("?"));
}
row->addWidget(m_thumb);
auto *fields = new QVBoxLayout;
m_caption = new QLineEdit;
m_caption->setPlaceholderText(QStringLiteral("Caption (defaults to the filename)"));
fields->addWidget(m_caption);
m_date = new QDateEdit(QDate::currentDate());
m_date->setCalendarPopup(true);
m_date->setDisplayFormat(QStringLiteral("yyyy-MM-dd"));
fields->addWidget(m_date);
m_statusLabel = new QLabel;
m_statusLabel->setOpenExternalLinks(true);
m_statusLabel->setWordWrap(true);
fields->addWidget(m_statusLabel);
row->addLayout(fields, 1);
m_remove = new QPushButton(QStringLiteral("✕"));
m_remove->setFlat(true);
m_remove->setFixedWidth(32);
connect(m_remove, &QPushButton::clicked, this, [this]() { emit removeRequested(this); });
row->addWidget(m_remove, 0, Qt::AlignTop);
}
QString PhotoItemWidget::caption() const
{
return m_caption->text();
}
QString PhotoItemWidget::date() const
{
return m_date->date().toString(QStringLiteral("yyyy-MM-dd"));
}
void PhotoItemWidget::setStatus(Status status, const QString &message, const QString &url)
{
m_status = status;
// Inputs are frozen once the photo is in flight; the remove button stays
// usable except mid-transfer.
const bool editable = status == Status::Pending || status == Status::Error;
m_caption->setEnabled(editable);
m_date->setEnabled(editable);
m_remove->setEnabled(status != Status::Preparing && status != Status::Uploading);
switch (status) {
case Status::Pending:
m_statusLabel->clear();
break;
case Status::Preparing:
m_statusLabel->setText(QStringLiteral("Preparing…"));
break;
case Status::Uploading:
m_statusLabel->setText(QStringLiteral("Uploading…"));
break;
case Status::Success:
m_statusLabel->setText(QStringLiteral("Posted — <a href=\"%1\">View</a>").arg(url));
break;
case Status::Error:
m_statusLabel->setText(QStringLiteral("<span style=\"color:#fb4934\">%1</span>").arg(message));
break;
}
}
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <QString>
#include <QWidget>
class QDateEdit;
class QLabel;
class QLineEdit;
class QPushButton;
// One photo in the batch: thumbnail, caption, date, and its own upload status.
// A single-photo post is just a batch of one, so the window runs one code path.
class PhotoItemWidget : public QWidget
{
Q_OBJECT
public:
enum class Status { Pending, Preparing, Uploading, Success, Error };
explicit PhotoItemWidget(QString sourcePath, QWidget *parent = nullptr);
QString sourcePath() const { return m_sourcePath; }
QString caption() const;
QString date() const; // yyyy-MM-dd
Status status() const { return m_status; }
void setStatus(Status status, const QString &message = {}, const QString &url = {});
signals:
void removeRequested(PhotoItemWidget *self);
private:
QString m_sourcePath;
Status m_status = Status::Pending;
QLabel *m_thumb = nullptr;
QLineEdit *m_caption = nullptr;
QDateEdit *m_date = nullptr;
QLabel *m_statusLabel = nullptr;
QPushButton *m_remove = nullptr;
};
+126
View File
@@ -0,0 +1,126 @@
#include "settings.h"
#include <QEventLoop>
#include <QUrl>
#include <algorithm>
#include <qt6keychain/keychain.h>
namespace {
const QLatin1String kService("photogallery-uploader");
const QLatin1String kPasswordKey("password");
} // namespace
Settings::Settings()
: m_settings(QStringLiteral("inflo.ws"), QStringLiteral("photogallery-uploader"))
{
}
QString Settings::baseUrl() const
{
return m_settings.value(QStringLiteral("baseUrl")).toString();
}
void Settings::setBaseUrl(const QString &value)
{
m_settings.setValue(QStringLiteral("baseUrl"), normalizeBaseUrl(value));
}
QString Settings::username() const
{
return m_settings.value(QStringLiteral("username")).toString();
}
void Settings::setUsername(const QString &value)
{
m_settings.setValue(QStringLiteral("username"), value.trimmed());
}
int Settings::concurrency() const
{
const int v = m_settings.value(QStringLiteral("concurrency"), kDefaultConcurrency).toInt();
return std::clamp(v, 1, kMaxConcurrency);
}
void Settings::setConcurrency(int value)
{
m_settings.setValue(QStringLiteral("concurrency"), std::clamp(value, 1, kMaxConcurrency));
}
QString Settings::readPassword() const
{
QKeychain::ReadPasswordJob job(kService);
job.setAutoDelete(false);
job.setKey(kPasswordKey);
QEventLoop loop;
QObject::connect(&job, &QKeychain::Job::finished, &loop, &QEventLoop::quit);
job.start();
loop.exec();
if (job.error() != QKeychain::NoError)
return QString();
return job.textData();
}
void Settings::writePassword(const QString &value) const
{
QKeychain::WritePasswordJob job(kService);
job.setAutoDelete(false);
job.setKey(kPasswordKey);
job.setTextData(value);
QEventLoop loop;
QObject::connect(&job, &QKeychain::Job::finished, &loop, &QEventLoop::quit);
job.start();
loop.exec();
}
bool Settings::isConfigured() const
{
return !baseUrl().isEmpty() && !username().isEmpty() && !readPassword().isEmpty();
}
Settings::LastPost Settings::lastPost() const
{
LastPost p;
p.slug = m_settings.value(QStringLiteral("last/slug")).toString();
p.url = m_settings.value(QStringLiteral("last/url")).toString();
p.caption = m_settings.value(QStringLiteral("last/caption")).toString();
p.postedAt = m_settings.value(QStringLiteral("last/postedAt")).toDateTime();
return p;
}
void Settings::setLastPost(const LastPost &value)
{
m_settings.setValue(QStringLiteral("last/slug"), value.slug);
m_settings.setValue(QStringLiteral("last/url"), value.url);
m_settings.setValue(QStringLiteral("last/caption"), value.caption);
m_settings.setValue(QStringLiteral("last/postedAt"), value.postedAt);
}
QString Settings::validateBaseUrl(const QString &raw)
{
const QString trimmed = raw.trimmed();
if (trimmed.isEmpty())
return QStringLiteral("Base URL is required");
const QUrl url(trimmed);
if (!url.isValid() || url.host().isEmpty())
return QStringLiteral("Not a valid URL");
if (url.scheme() != QStringLiteral("https"))
return QStringLiteral("Must be https — plain http sends your password in the clear");
return QString();
}
QString Settings::normalizeBaseUrl(const QString &raw)
{
QString v = raw.trimmed();
while (v.endsWith(QLatin1Char('/')))
v.chop(1);
if (v.endsWith(QStringLiteral("/upload"), Qt::CaseInsensitive))
v.chop(QStringLiteral("/upload").size());
while (v.endsWith(QLatin1Char('/')))
v.chop(1);
return v;
}
+58
View File
@@ -0,0 +1,58 @@
#pragma once
#include <QDateTime>
#include <QSettings>
#include <QString>
// Persists the non-secret settings (base URL, username, last post) in a
// QSettings INI under ~/.config, and the password in the system keychain
// (libsecret / kwallet via QtKeychain). The password never lands on disk in the
// clear, mirroring the Android app's Keystore sealing.
class Settings
{
public:
struct LastPost {
QString slug;
QString url;
QString caption;
QDateTime postedAt;
bool valid() const { return !slug.isEmpty(); }
};
Settings();
QString baseUrl() const;
void setBaseUrl(const QString &value);
QString username() const;
void setUsername(const QString &value);
// Max photos uploaded at once in a bulk post, clamped to [1, kMaxConcurrency].
static constexpr int kMaxConcurrency = 8;
static constexpr int kDefaultConcurrency = 3;
int concurrency() const;
void setConcurrency(int value);
// Keychain access is synchronous here (a local event loop): the app does
// nothing until the credential is in hand, and there is no background retry
// worker as on Android that would need it off the UI thread.
QString readPassword() const;
void writePassword(const QString &value) const;
bool isConfigured() const;
LastPost lastPost() const;
void setLastPost(const LastPost &value);
// Returns an error message, or an empty string when the URL is usable.
// https is required: Basic auth would otherwise send the password in the
// clear.
static QString validateBaseUrl(const QString &raw);
// Trims trailing slashes and a trailing "/upload" (the endpoint path a user
// pasting the upload URL would leave on — the client appends it itself).
static QString normalizeBaseUrl(const QString &raw);
private:
mutable QSettings m_settings;
};
+126
View File
@@ -0,0 +1,126 @@
#include "uploadclient.h"
#include <QFile>
#include <QFileInfo>
#include <QHttpMultiPart>
#include <QHttpPart>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
UploadClient::UploadClient(QString baseUrl, QString username, QString password, QObject *parent)
: QObject(parent)
, m_base(std::move(baseUrl))
, m_username(std::move(username))
, m_password(std::move(password))
, m_net(new QNetworkAccessManager(this))
{
while (m_base.endsWith(QLatin1Char('/')))
m_base.chop(1);
}
QByteArray UploadClient::authHeader() const
{
const QByteArray raw = (m_username + QLatin1Char(':') + m_password).toUtf8();
return "Basic " + raw.toBase64();
}
void UploadClient::upload(const QString &filePath, const QString &fileName,
const QString &caption, const QString &date)
{
auto *file = new QFile(filePath);
if (!file->open(QIODevice::ReadOnly)) {
delete file;
emit uploadFinished(Result::Rejected, {}, {}, QStringLiteral("Could not read the prepared image"));
return;
}
auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
const QByteArray contentType =
fileName.endsWith(QStringLiteral(".png"), Qt::CaseInsensitive) ? "image/png" : "image/jpeg";
QHttpPart imagePart;
imagePart.setHeader(QNetworkRequest::ContentTypeHeader, QVariant(contentType));
imagePart.setHeader(QNetworkRequest::ContentDispositionHeader,
QVariant(QStringLiteral("form-data; name=\"image\"; filename=\"%1\"").arg(fileName)));
file->setParent(multiPart);
imagePart.setBodyDevice(file);
multiPart->append(imagePart);
const auto textPart = [](const QString &name, const QString &value) {
QHttpPart part;
part.setHeader(QNetworkRequest::ContentDispositionHeader,
QVariant(QStringLiteral("form-data; name=\"%1\"").arg(name)));
part.setBody(value.toUtf8());
return part;
};
multiPart->append(textPart(QStringLiteral("caption"), caption));
multiPart->append(textPart(QStringLiteral("date"), date));
QNetworkRequest request(QUrl(m_base + QStringLiteral("/upload")));
request.setRawHeader("Authorization", authHeader());
QNetworkReply *reply = m_net->post(request, multiPart);
multiPart->setParent(reply);
const QString fallbackSlug = QFileInfo(fileName).completeBaseName();
connect(reply, &QNetworkReply::finished, this, [this, reply, fallbackSlug]() {
reply->deleteLater();
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QString body = QString::fromUtf8(reply->readAll()).trimmed();
if (reply->error() == QNetworkReply::NoError && status >= 200 && status < 300) {
// Server replies "uploaded slug.ext"; recover the slug.
QString name = body;
if (name.startsWith(QStringLiteral("uploaded ")))
name = name.mid(QStringLiteral("uploaded ").size()).trimmed();
const int dot = name.lastIndexOf(QLatin1Char('.'));
QString slug = dot > 0 ? name.left(dot) : name;
if (slug.isEmpty())
slug = fallbackSlug;
emit uploadFinished(Result::Success, slug, m_base + QStringLiteral("/photo/") + slug + QLatin1Char('/'), {});
return;
}
if (status == 401 || status == 403) {
emit uploadFinished(Result::AuthFailed, {}, {}, QStringLiteral("Wrong username or password"));
return;
}
if (status >= 500 && status < 600) {
emit uploadFinished(Result::Transient, {}, {}, QStringLiteral("Server error %1").arg(status));
return;
}
if (status == 0) {
// No HTTP response — a socket/TLS failure. Worth retrying.
emit uploadFinished(Result::Transient, {}, {}, reply->errorString());
return;
}
emit uploadFinished(Result::Rejected, {}, {}, body.isEmpty() ? QStringLiteral("Upload rejected") : body);
});
}
void UploadClient::testConnection()
{
QNetworkRequest request(QUrl(m_base + QStringLiteral("/upload")));
request.setRawHeader("Authorization", authHeader());
QNetworkReply *reply = m_net->get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() == QNetworkReply::NoError && status >= 200 && status < 300) {
emit testFinished(QString());
return;
}
if (status == 401 || status == 403) {
emit testFinished(QStringLiteral("Wrong username or password"));
return;
}
if (status == 0) {
emit testFinished(reply->errorString());
return;
}
emit testFinished(QStringLiteral("Server returned %1").arg(status));
});
}
+51
View File
@@ -0,0 +1,51 @@
#pragma once
#include <QObject>
#include <QString>
class QNetworkAccessManager;
// Talks to the gallery's POST /upload endpoint, which sits behind nginx HTTP
// Basic auth. The credentials go out preemptively in the Authorization header
// on every request (never via QNetworkAccessManager's authenticationRequired
// signal, which only fires on a 401 and would replay a multipart body already
// streamed off disk).
//
// Asynchronous: results arrive via signals so the UI stays responsive during an
// upload over a slow link.
class UploadClient : public QObject
{
Q_OBJECT
public:
enum class Result {
Success, // uploaded; slug/url populated
AuthFailed, // wrong credentials — a retry will not help
Rejected, // server said no for a reason a retry won't change
Transient, // network trouble or a 5xx — worth retrying
};
UploadClient(QString baseUrl, QString username, QString password, QObject *parent = nullptr);
// The filename matters: server.go derives the slug from it, so re-sending
// the same name overwrites the same post instead of duplicating it.
void upload(const QString &filePath, const QString &fileName,
const QString &caption, const QString &date);
// Credential check via GET /upload (same Basic-auth gate the POST uses).
// /health is not usable: nginx only proxies location /upload.
void testConnection();
signals:
void uploadFinished(UploadClient::Result result, const QString &slug,
const QString &url, const QString &message);
// errorMessage is empty on success.
void testFinished(const QString &errorMessage);
private:
QByteArray authHeader() const;
QString m_base;
QString m_username;
QString m_password;
QNetworkAccessManager *m_net;
};
+1 -1
View File
@@ -22,7 +22,7 @@
meta = with pkgs.lib; { meta = with pkgs.lib; {
description = "Minimal static photo gallery"; description = "Minimal static photo gallery";
license = licenses.mit; license = licenses.unlicense;
mainProgram = "photogallery"; mainProgram = "photogallery";
platforms = platforms.linux; platforms = platforms.linux;
}; };
+383 -25
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"bytes"
"encoding/xml" "encoding/xml"
"html/template" "html/template"
"image" "image"
@@ -24,9 +25,25 @@ import (
const ( const (
thumbSize = 600 thumbSize = 600
mediumMaxSize = 1600 mediumMaxSize = 1600
pageSize = 60 defaultPageSize = 60
// The two paginated views. The one chosen as -default-view is published
// at the site root, the other under /<name>/.
viewTimeline = "timeline"
viewGrid = "grid"
// Open Graph preview image, shared to WhatsApp/Telegram/etc. Kept to a
// 1200px longest side and squeezed under ogTargetBytes: WhatsApp is widely
// reported to drop the preview entirely once the image passes ~300 KB, so
// the medium rendition (which can exceed that) is not reused here.
ogMaxSize = 1200
ogTargetBytes = 250 * 1024
) )
// ogQualityLadder is walked from best to worst until the encoded OG image
// fits under ogTargetBytes. The last entry is used as-is even if still over.
var ogQualityLadder = []int{85, 78, 70, 62}
// PhotoMeta is the structure of a .toml sidecar file. // PhotoMeta is the structure of a .toml sidecar file.
type PhotoMeta struct { type PhotoMeta struct {
Caption string `toml:"caption"` Caption string `toml:"caption"`
@@ -39,10 +56,21 @@ type Photo struct {
File string // relative path under public/, e.g. "images/foo.jpg" File string // relative path under public/, e.g. "images/foo.jpg"
Thumb string // relative path under public/, e.g. "thumbs/foo.jpg" Thumb string // relative path under public/, e.g. "thumbs/foo.jpg"
Medium string // relative path under public/, e.g. "medium/foo.jpg" Medium string // relative path under public/, e.g. "medium/foo.jpg"
OG string // relative path under public/, e.g. "og/foo.jpg"
OGWidth int // pixel dimensions of the OG image, for og:image:width/height
OGHeight int
Caption string Caption string
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 +82,20 @@ type Generator struct {
fontsURL string fontsURL string
serifFamily string serifFamily string
mlFamily string mlFamily string
pageSize int
defaultView string
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, defaultView string) *Generator {
tmpl := template.Must(template.ParseFS(assets, "templates/*.html")) tmpl := template.Must(template.ParseFS(assets, "templates/*.html"))
if pageSize < 1 {
pageSize = defaultPageSize
}
if defaultView != viewGrid {
defaultView = viewTimeline
}
return &Generator{ return &Generator{
contentDir: contentDir, contentDir: contentDir,
outputDir: outputDir, outputDir: outputDir,
@@ -71,6 +107,8 @@ func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, f
fontsURL: fontsURL, fontsURL: fontsURL,
serifFamily: serifFamily, serifFamily: serifFamily,
mlFamily: mlFamily, mlFamily: mlFamily,
pageSize: pageSize,
defaultView: defaultView,
tmpl: tmpl, tmpl: tmpl,
} }
} }
@@ -82,6 +120,23 @@ func (g *Generator) initial() string {
return "" return ""
} }
// viewBase returns the URL prefix a view is published under: "" for the
// default view, which owns the site root, and "/<view>" for the other one.
func (g *Generator) viewBase(view string) string {
if view == g.defaultView {
return ""
}
return "/" + view
}
// pageURL returns the URL of the 1-based page n of a view rooted at base.
func pageURL(base string, n int) string {
if n == 1 {
return base + "/"
}
return base + "/page/" + strconv.Itoa(n) + "/"
}
// 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 {
@@ -93,7 +148,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", "og", "photo", "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 +160,20 @@ func (g *Generator) Build() error {
} }
} }
if err := g.renderIndexPages(photos); err != nil { // The default view lives at the site root, so a directory named after it
// can only be left over from a run with the other -default-view. Drop it,
// or the old pages would stay reachable and go stale.
if err := os.RemoveAll(filepath.Join(g.outputDir, g.defaultView)); err != nil {
return err
}
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 {
@@ -131,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)
@@ -172,6 +282,7 @@ func (g *Generator) loadPhotos() ([]Photo, error) {
File: "images/" + name, File: "images/" + name,
Thumb: "thumbs/" + slug + ".jpg", Thumb: "thumbs/" + slug + ".jpg",
Medium: "medium/" + slug + ".jpg", Medium: "medium/" + slug + ".jpg",
OG: "og/" + slug + ".jpg",
Caption: meta.Caption, Caption: meta.Caption,
Date: date, Date: date,
}) })
@@ -191,6 +302,7 @@ func (g *Generator) processImage(p *Photo) error {
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)
dstOG := filepath.Join(g.outputDir, p.OG)
if !upToDate(src, dstImg) { if !upToDate(src, dstImg) {
if err := copyFile(src, dstImg); err != nil { if err := copyFile(src, dstImg); err != nil {
@@ -207,9 +319,36 @@ func (g *Generator) processImage(p *Photo) error {
return err return err
} }
} }
if !upToDate(src, dstOG) {
if err := generateOG(src, dstOG, ogMaxSize); err != nil {
return err
}
}
// og:image:width/height need the OG image's real dimensions, and they must
// be known even when the file above was skipped as up to date. DecodeConfig
// reads only the JPEG header, so this is cheap.
if cfg, err := decodeConfigFile(dstOG); err == nil {
p.OGWidth = cfg.Width
p.OGHeight = cfg.Height
} else {
log.Printf("og dimensions %s: %v", p.Slug, err)
}
return nil return nil
} }
// decodeConfigFile reads just the image header of path and returns its config
// (dimensions, colour model) without decoding the pixels.
func decodeConfigFile(path string) (image.Config, error) {
f, err := os.Open(path)
if err != nil {
return image.Config{}, err
}
defer f.Close()
cfg, _, err := image.DecodeConfig(f)
return cfg, err
}
// upToDate reports whether dst exists and is at least as new as src. // upToDate reports whether dst exists and is at least as new as src.
func upToDate(src, dst string) bool { func upToDate(src, dst string) bool {
si, err := os.Stat(src) si, err := os.Stat(src)
@@ -299,48 +438,89 @@ 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 // generateOG reads src, resizes it so its longest side is at most maxDim, and
// files, chunking photos into pageSize slices for progressive pagination. // writes a JPEG small enough for social link previews. It walks ogQualityLadder
func (g *Generator) renderIndexPages(photos []Photo) error { // from best to worst, stopping at the first quality whose output is under
// ogTargetBytes, and falls back to the lowest quality if none fits.
func generateOG(src, dst string, maxDim 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
}
b := img.Bounds()
w, h := b.Dx(), b.Dy()
nw, nh := w, h
if w > maxDim || h > maxDim {
if w >= h {
nw = maxDim
nh = h * maxDim / w
} else {
nh = maxDim
nw = w * maxDim / h
}
}
resized := image.NewRGBA(image.Rect(0, 0, nw, nh))
xdraw.CatmullRom.Scale(resized, resized.Bounds(), img, b, xdraw.Over, nil)
var buf bytes.Buffer
for i, q := range ogQualityLadder {
buf.Reset()
if err := jpeg.Encode(&buf, resized, &jpeg.Options{Quality: q}); err != nil {
return err
}
if buf.Len() <= ogTargetBytes || i == len(ogQualityLadder)-1 {
break
}
}
return os.WriteFile(dst, buf.Bytes(), 0644)
}
// 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) 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
} }
base := g.viewBase(viewGrid)
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") dir := filepath.Join(g.outputDir, filepath.FromSlash(pageURL(base, i+1)))
if i > 0 {
dir := filepath.Join(g.outputDir, "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
} }
dst = filepath.Join(dir, "index.html") dst := filepath.Join(dir, "index.html")
}
prev := "" prev := ""
next := "" next := ""
switch i { if i > 0 {
case 0: prev = pageURL(base, i)
// no prev
case 1:
prev = "/"
default:
prev = "/page/" + strconv.Itoa(i) + "/"
} }
if i+1 < totalPages { if i+1 < totalPages {
next = "/page/" + strconv.Itoa(i+2) + "/" next = pageURL(base, i+2)
} }
data := map[string]any{ data := map[string]any{
"Photos": slice, "Photos": slice,
"TimelineURL": pageURL(g.viewBase(viewTimeline), 1),
"Total": total, "Total": total,
"Page": i + 1, "Page": i + 1,
"TotalPages": totalPages, "TotalPages": totalPages,
@@ -357,11 +537,11 @@ 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
} }
} }
return nil return g.prunePages(base, totalPages)
} }
// renderPhotoPage writes public/photo/<slug>/index.html. // renderPhotoPage writes public/photo/<slug>/index.html.
@@ -385,6 +565,176 @@ 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: 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)
totalPages := len(pages)
base := g.viewBase(viewTimeline)
for i, groups := range pages {
dir := filepath.Join(g.outputDir, filepath.FromSlash(pageURL(base, i+1)))
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
dst := filepath.Join(dir, "index.html")
prev := ""
next := ""
if i > 0 {
prev = pageURL(base, i)
}
if i+1 < totalPages {
next = pageURL(base, i+2)
}
data := map[string]any{
"Groups": groups,
"GridURL": pageURL(g.viewBase(viewGrid), 1),
"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 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. Pages of
// days that no longer have photos, after a delete or a date change, are
// removed.
func (g *Generator) renderDayPages(photos []Photo) error {
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
}
err := g.render("day.html", filepath.Join(dir, "index.html"), map[string]any{
"Date": grp.Label,
"TimelineURL": pageURL(g.viewBase(viewTimeline), 1),
"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 {
@@ -467,6 +817,14 @@ func (g *Generator) copyStatic() error {
if err := os.WriteFile(dst, data, 0644); err != nil { if err := os.WriteFile(dst, data, 0644); err != nil {
return err return err
} }
// Also serve the favicon from the site root, where browsers and
// crawlers request /favicon.ico by default without a <link> tag.
if e.Name() == "favicon.ico" {
if err := os.WriteFile(filepath.Join(g.outputDir, e.Name()), data, 0644); err != nil {
return err
}
}
} }
return nil return nil
} }
+165
View File
@@ -0,0 +1,165 @@
package main
import (
"bytes"
"image"
"image/jpeg"
"os"
"path/filepath"
"strings"
"testing"
)
// buildSite builds a site of three photos spread over two days, with a page
// size of two so both views need a second page, and returns the output dir.
func buildSite(t *testing.T, output, defaultView string) {
t.Helper()
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)
}
for slug, date := range map[string]string{
"one": "2026-06-14",
"two": "2026-06-14",
"three": "2026-06-10",
} {
writeFile(t, filepath.Join(content, slug+".jpg"), img.Bytes())
writeFile(t, filepath.Join(content, slug+".toml"),
[]byte("caption = \""+slug+"\"\ndate = \""+date+"\"\n"))
}
g := NewGenerator(content, output, "https://photos.example.com",
"Ada", "", "", "", "", "Crimson Pro", "Manjari", 2, defaultView)
if err := g.Build(); err != nil {
t.Fatal(err)
}
}
func writeFile(t *testing.T, path string, data []byte) {
t.Helper()
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
}
func readPage(t *testing.T, output, rel string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(output, filepath.FromSlash(rel), "index.html"))
if err != nil {
t.Fatal(err)
}
return string(data)
}
// Timeline pages carry day sections, grid pages do not.
func isTimeline(page string) bool {
return strings.Contains(page, `class="tl-day"`)
}
func TestDefaultViewOwnsSiteRoot(t *testing.T) {
tests := []struct {
defaultView string
gridAt string // URL of the grid's first page
timelineAt string // URL of the timeline's first page
}{
{viewTimeline, "/grid/", "/"},
{viewGrid, "/", "/timeline/"},
{"nonsense", "/grid/", "/"}, // unknown values fall back to the timeline
}
for _, tt := range tests {
t.Run(tt.defaultView, func(t *testing.T) {
output := t.TempDir()
buildSite(t, output, tt.defaultView)
timeline := readPage(t, output, tt.timelineAt)
grid := readPage(t, output, tt.gridAt)
if !isTimeline(timeline) {
t.Errorf("%s is not the timeline", tt.timelineAt)
}
if isTimeline(grid) {
t.Errorf("%s is not the grid", tt.gridAt)
}
// The views link to each other, and to their own second page.
for page, want := range map[string]string{
timeline: `href="` + tt.gridAt + `" class="rss-a">Grid`,
grid: `href="` + tt.timelineAt + `" class="rss-a">Timeline`,
} {
if !strings.Contains(page, want) {
t.Errorf("missing link %q", want)
}
}
for first, base := range map[string]string{
timeline: strings.TrimSuffix(tt.timelineAt, "/"),
grid: strings.TrimSuffix(tt.gridAt, "/"),
} {
next := base + "/page/2/"
if !strings.Contains(first, `href="`+next+`" data-load-more`) {
t.Errorf("first page does not link to %s", next)
}
second := readPage(t, output, next)
if !strings.Contains(second, `class="pager-newer" href="`+base+`/"`) {
t.Errorf("%s does not link back to %s/", next, base)
}
}
// Day pages lead back to the timeline, wherever it lives.
day := readPage(t, output, "/day/2026-06-14/")
if !strings.Contains(day, `href="`+tt.timelineAt+`" class="rss-a">← Timeline`) {
t.Errorf("day page does not link to the timeline at %s", tt.timelineAt)
}
})
}
}
func TestSwitchingDefaultViewDropsOldViewDir(t *testing.T) {
output := t.TempDir()
buildSite(t, output, viewTimeline)
if _, err := os.Stat(filepath.Join(output, "grid", "index.html")); err != nil {
t.Fatalf("grid view missing before the switch: %v", err)
}
buildSite(t, output, viewGrid)
if _, err := os.Stat(filepath.Join(output, "grid")); !os.IsNotExist(err) {
t.Errorf("stale /grid/ survived the switch to -default-view grid")
}
if isTimeline(readPage(t, output, "/")) {
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)
}
}
}
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

+9 -2
View File
@@ -19,15 +19,22 @@ 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)")
manage := flag.Bool("manage", false, "serve the /manage pages for editing and deleting published photos (off by default; put them behind auth in nginx)")
defaultView := flag.String("default-view", viewTimeline, "view published at the site root: \"timeline\" or \"grid\"; the other one is published under /<name>/")
flag.Parse() flag.Parse()
if *defaultView != viewTimeline && *defaultView != viewGrid {
log.Fatalf("-default-view must be %q or %q, got %q", viewTimeline, viewGrid, *defaultView)
}
for _, d := range []string{*contentDir, *outputDir} { for _, d := range []string{*contentDir, *outputDir} {
if err := os.MkdirAll(d, 0755); err != nil { if err := os.MkdirAll(d, 0755); err != nil {
log.Fatalf("mkdir %s: %v", d, err) log.Fatalf("mkdir %s: %v", d, err)
} }
} }
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, *defaultView)
if err := g.Build(); err != nil { if err := g.Build(); err != nil {
log.Printf("initial build: %v", err) log.Printf("initial build: %v", err)
@@ -40,6 +47,6 @@ func main() {
defer w.Close() defer w.Close()
go w.Watch() go w.Watch()
s := NewServer(*port, *contentDir, *outputDir, *serve, g) s := NewServer(*port, *contentDir, *outputDir, *serve, *manage, g)
log.Fatal(s.Listen()) log.Fatal(s.Listen())
} }
+224
View File
@@ -0,0 +1,224 @@
package main
import (
"bytes"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/BurntSushi/toml"
)
// The /manage pages edit and delete photos that are already published. Like
// /upload they carry no auth of their own: in production nginx puts 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.
const maxEditBody = 64 << 10
// handleManagePage lists every photo with links to edit and delete it.
func (s *Server) handleManagePage(w http.ResponseWriter, r *http.Request) {
photos, err := s.generator.loadPhotos()
if err != nil {
log.Printf("manage: %v", err)
http.Error(w, "could not read photos", http.StatusInternalServerError)
return
}
data := s.managePageData()
data["Photos"] = photos
if saved := r.URL.Query().Get("saved"); saved != "" {
for _, p := range photos {
if p.Slug == saved {
data["Saved"] = p.Caption
}
}
}
data["Deleted"] = r.URL.Query().Get("deleted")
s.renderManage(w, "manage.html", data)
}
// handleEditPage shows the edit form for one photo, prefilled from its sidecar.
func (s *Server) handleEditPage(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, "edit.html", data)
}
// handleEdit accepts the edit form with fields:
//
// caption — human-readable caption (empty falls back to the slug, as on upload)
// date — YYYY-MM-DD
//
// It rewrites the photo's .toml sidecar; the image itself is never touched.
func (s *Server) handleEdit(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
}
r.Body = http.MaxBytesReader(w, r.Body, maxEditBody)
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
caption := strings.TrimSpace(r.PostFormValue("caption"))
if caption == "" {
caption = photo.Slug
}
dateStr := r.PostFormValue("date")
if _, err := time.Parse("2006-01-02", dateStr); err != nil {
http.Error(w, "date must be YYYY-MM-DD", http.StatusBadRequest)
return
}
// Encode first and write in one go, so a rebuild racing this request
// never reads a half-written sidecar.
var buf bytes.Buffer
if err := toml.NewEncoder(&buf).Encode(PhotoMeta{Caption: caption, Date: dateStr}); err != nil {
http.Error(w, "could not write metadata", http.StatusInternalServerError)
return
}
tomlPath := filepath.Join(s.contentDir, photo.Slug+".toml")
if err := os.WriteFile(tomlPath, buf.Bytes(), 0644); err != nil {
log.Printf("write %s: %v", tomlPath, err)
http.Error(w, "could not save 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 edit: %v", err)
}
}()
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
// against real entries, rather than building a path from the URL, is what
// keeps a crafted slug from reaching outside the content directory.
func (s *Server) findPhoto(slug string) (Photo, bool) {
photos, err := s.generator.loadPhotos()
if err != nil {
log.Printf("manage: %v", err)
return Photo{}, false
}
for _, p := range photos {
if p.Slug == slug {
return p, true
}
}
return Photo{}, false
}
// 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
// host, so without this check any other website could submit the edit or
// delete form on a logged-in user's behalf.
//
// 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
// address, not the public name. Requests with neither header come from
// non-browser clients such as curl, which cannot be driven cross-site.
func (s *Server) sameOrigin(r *http.Request) bool {
if site := r.Header.Get("Sec-Fetch-Site"); site != "" {
return site == "same-origin" || site == "none"
}
origin := r.Header.Get("Origin")
if origin == "" {
return true
}
if origin == s.generator.baseURL {
return true
}
u, err := url.Parse(origin)
return err == nil && u.Host == r.Host
}
// managePageData returns the template fields shared by the manage pages.
func (s *Server) managePageData() map[string]any {
g := s.generator
return map[string]any{
"FontsURL": g.fontsURL,
"SerifFamily": g.serifFamily,
"MlFamily": g.mlFamily,
}
}
// renderManage renders into a buffer first so a template error becomes a
// clean 500 instead of a truncated page.
func (s *Server) renderManage(w http.ResponseWriter, tmpl string, data map[string]any) {
var buf bytes.Buffer
if err := s.generator.tmpl.ExecuteTemplate(&buf, tmpl, data); err != nil {
log.Printf("render %s: %v", tmpl, err)
http.Error(w, "could not render page", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// Always reflect the sidecars as they are now, never a cached copy.
w.Header().Set("Cache-Control", "no-store")
buf.WriteTo(w)
}
+355
View File
@@ -0,0 +1,355 @@
package main
import (
"bytes"
"image"
"image/jpeg"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/BurntSushi/toml"
)
// newTestServer returns a server over a content directory holding one photo,
// "sunrise", with a sidecar.
func newTestServer(t *testing.T) *Server {
t.Helper()
// Not t.TempDir(): an edit kicks off a background rebuild that can still
// be writing when the test ends, and TempDir fails the test if its
// cleanup finds the directory busy.
root, err := os.MkdirTemp("", "photog-test-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(root) })
content := filepath.Join(root, "content")
output := filepath.Join(root, "public")
for _, d := range []string{content, output} {
if err := os.MkdirAll(d, 0755); err != nil {
t.Fatal(err)
}
}
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, "sunrise.jpg"), img.Bytes())
writeFile(t, filepath.Join(content, "sunrise.toml"),
[]byte("caption = \"Tempelhof at sunrise\"\ndate = \"2026-06-14\"\n"))
g := NewGenerator(content, output, "https://photos.example.com",
"Ada", "", "", "", "", "Crimson Pro", "Manjari", 60, viewTimeline)
return NewServer("0", content, output, false, true, g)
}
func readMeta(t *testing.T, s *Server, slug string) PhotoMeta {
t.Helper()
var meta PhotoMeta
if _, err := toml.DecodeFile(filepath.Join(s.contentDir, slug+".toml"), &meta); err != nil {
t.Fatal(err)
}
return meta
}
// postEdit submits the edit form for slug and returns the response.
func postEdit(s *Server, slug string, form url.Values, headers map[string]string) *httptest.ResponseRecorder {
r := httptest.NewRequest("POST", "/manage/edit/"+slug, strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for k, v := range headers {
r.Header.Set(k, v)
}
r.SetPathValue("slug", slug)
w := httptest.NewRecorder()
s.handleEdit(w, r)
return w
}
func TestManagePageListsPhotos(t *testing.T) {
s := newTestServer(t)
w := httptest.NewRecorder()
s.handleManagePage(w, httptest.NewRequest("GET", "/manage", nil))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
for _, want := range []string{"Tempelhof at sunrise", "2026-06-14", `href="/manage/edit/sunrise"`} {
if !strings.Contains(body, want) {
t.Errorf("manage page is missing %q", want)
}
}
}
func TestEditPagePrefillsForm(t *testing.T) {
s := newTestServer(t)
r := httptest.NewRequest("GET", "/manage/edit/sunrise", nil)
r.SetPathValue("slug", "sunrise")
w := httptest.NewRecorder()
s.handleEditPage(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
for _, want := range []string{`value="Tempelhof at sunrise"`, `value="2026-06-14"`} {
if !strings.Contains(body, want) {
t.Errorf("edit page is missing %q", want)
}
}
}
func TestEditRewritesSidecar(t *testing.T) {
s := newTestServer(t)
form := url.Values{"caption": {`Tempelhof, "golden hour"`}, "date": {"2026-06-15"}}
w := postEdit(s, "sunrise", form, 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?saved=sunrise" {
t.Errorf("Location = %q", loc)
}
meta := readMeta(t, s, "sunrise")
if meta.Caption != `Tempelhof, "golden hour"` || meta.Date != "2026-06-15" {
t.Errorf("sidecar = %+v", meta)
}
}
func TestEditEmptyCaptionFallsBackToSlug(t *testing.T) {
s := newTestServer(t)
w := postEdit(s, "sunrise", url.Values{"caption": {" "}, "date": {"2026-06-14"}}, nil)
if w.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", w.Code)
}
if meta := readMeta(t, s, "sunrise"); meta.Caption != "sunrise" {
t.Errorf("caption = %q, want the slug", meta.Caption)
}
}
func TestEditRejectsBadInput(t *testing.T) {
valid := url.Values{"caption": {"changed"}, "date": {"2026-06-15"}}
tests := []struct {
name string
slug string
form url.Values
headers map[string]string
want int
}{
{"unknown photo", "nope", valid, nil, http.StatusNotFound},
{"path traversal", "../sunrise", valid, nil, http.StatusNotFound},
{"malformed date", "sunrise", url.Values{"caption": {"changed"}, "date": {"15.06.2026"}}, nil, http.StatusBadRequest},
{"missing date", "sunrise", url.Values{"caption": {"changed"}}, nil, http.StatusBadRequest},
{"cross-site fetch", "sunrise", valid, map[string]string{"Sec-Fetch-Site": "cross-site"}, http.StatusForbidden},
{"foreign origin", "sunrise", valid, map[string]string{"Origin": "https://evil.example"}, http.StatusForbidden},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestServer(t)
w := postEdit(s, tt.slug, tt.form, tt.headers)
if w.Code != tt.want {
t.Errorf("status = %d, want %d", w.Code, tt.want)
}
if meta := readMeta(t, s, "sunrise"); meta.Caption != "Tempelhof at sunrise" || meta.Date != "2026-06-14" {
t.Errorf("sidecar was changed: %+v", meta)
}
})
}
}
func TestEditAcceptsOwnOrigin(t *testing.T) {
form := url.Values{"caption": {"changed"}, "date": {"2026-06-15"}}
tests := []struct {
name string
headers map[string]string
}{
{"same-origin fetch metadata", map[string]string{"Sec-Fetch-Site": "same-origin"}},
// Behind nginx the Host header is the upstream address, so only
// -base-url can vouch for the public origin.
{"origin equal to base URL", map[string]string{"Origin": "https://photos.example.com"}},
{"origin equal to host", map[string]string{"Origin": "http://example.com"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestServer(t)
if w := postEdit(s, "sunrise", form, tt.headers); w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303: %s", w.Code, w.Body)
}
})
}
}
// 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)
}
}
})
}
}
func TestManageRoutesNeedTheFlag(t *testing.T) {
for _, manage := range []bool{false, true} {
s := newTestServer(t)
s.manage = manage
mux := s.routes()
want := http.StatusNotFound
if manage {
want = http.StatusOK
}
for _, path := range []string{"/manage", "/manage/edit/sunrise", "/manage/delete/sunrise"} {
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
if w.Code != want {
t.Errorf("manage=%v: GET %s = %d, want %d", manage, path, w.Code, want)
}
}
// The delete POST only does something with the flag: without it the
// photo must survive, with it (no Origin header, as from curl) it goes.
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("POST", "/manage/delete/sunrise", nil))
_, err := os.Stat(filepath.Join(s.contentDir, "sunrise.jpg"))
present := err == nil
if present == manage {
t.Errorf("manage=%v: POST delete = %d, photo present afterwards = %v", manage, w.Code, present)
}
// /upload and /health are there regardless.
for _, path := range []string{"/upload", "/health"} {
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
if w.Code != http.StatusOK {
t.Errorf("manage=%v: GET %s = %d, want 200", manage, path, w.Code)
}
}
}
}
+25 -3
View File
@@ -76,6 +76,20 @@ 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.";
};
defaultView = lib.mkOption {
type = lib.types.enum [ "timeline" "grid" ];
default = "timeline";
description = "View published at the site root. The other one is published under /timeline/ or /grid/.";
};
manage.enable = lib.mkEnableOption "the /manage pages for editing and deleting published photos (served behind the same htpasswd as /upload)";
nginx = { nginx = {
enable = lib.mkEnableOption "nginx virtual host for photogallery"; enable = lib.mkEnableOption "nginx virtual host for photogallery";
@@ -88,7 +102,7 @@ in {
htpasswdFile = lib.mkOption { htpasswdFile = lib.mkOption {
type = lib.types.path; type = lib.types.path;
example = "/etc/nginx/.htpasswd-gallery"; example = "/etc/nginx/.htpasswd-gallery";
description = "Path to htpasswd file protecting the /upload endpoint."; description = "Path to htpasswd file protecting the /upload and /manage endpoints.";
}; };
enableACME = lib.mkOption { enableACME = lib.mkOption {
@@ -128,7 +142,7 @@ in {
User = "photogallery"; User = "photogallery";
Group = "photogallery"; Group = "photogallery";
ExecStart = lib.escapeShellArgs [ ExecStart = lib.escapeShellArgs ([
"${pkg}/bin/photogallery" "${pkg}/bin/photogallery"
"--content" cfg.contentDir "--content" cfg.contentDir
"--output" cfg.outputDir "--output" cfg.outputDir
@@ -141,7 +155,9 @@ 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)
"--default-view" cfg.defaultView
] ++ lib.optional cfg.manage.enable "--manage");
Restart = "on-failure"; Restart = "on-failure";
RestartSec = "5s"; RestartSec = "5s";
@@ -177,6 +193,12 @@ in {
basicAuthFile = cfg.nginx.htpasswdFile; basicAuthFile = cfg.nginx.htpasswdFile;
extraConfig = "client_max_body_size 20M;"; extraConfig = "client_max_body_size 20M;";
}; };
locations."/manage" = lib.mkIf cfg.manage.enable {
proxyPass = "http://127.0.0.1:${toString cfg.port}";
basicAuth = "gallery";
basicAuthFile = cfg.nginx.htpasswdFile;
};
}; };
}; };
}) })
+19 -2
View File
@@ -18,26 +18,43 @@ type Server struct {
contentDir string contentDir string
outputDir string outputDir string
serve bool serve bool
manage bool
generator *Generator generator *Generator
} }
func NewServer(port, contentDir, outputDir string, serve bool, g *Generator) *Server { func NewServer(port, contentDir, outputDir string, serve, manage bool, g *Generator) *Server {
return &Server{ return &Server{
port: port, port: port,
contentDir: contentDir, contentDir: contentDir,
outputDir: outputDir, outputDir: outputDir,
serve: serve, serve: serve,
manage: manage,
generator: g, generator: g,
} }
} }
func (s *Server) Listen() error { // routes builds the handler. The /manage pages are only registered with
// -manage, so a service without the flag has no edit or delete code path at
// all and answers 404 there.
func (s *Server) routes() *http.ServeMux {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /upload", s.handleUploadPage) mux.HandleFunc("GET /upload", s.handleUploadPage)
mux.HandleFunc("POST /upload", s.handleUpload) mux.HandleFunc("POST /upload", s.handleUpload)
if s.manage {
mux.HandleFunc("GET /manage", s.handleManagePage)
mux.HandleFunc("GET /manage/edit/{slug}", s.handleEditPage)
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")
}) })
return mux
}
func (s *Server) Listen() error {
mux := s.routes()
if s.serve { if s.serve {
// Serve the generated site on the same port — for local development only. // Serve the generated site on the same port — for local development only.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

+47
View File
@@ -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 {
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

+50
View File
@@ -0,0 +1,50 @@
<!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">
<link rel="icon" href="/static/favicon.ico" sizes="any">
<link rel="icon" type="image/png" href="/static/web-32.png" sizes="32x32">
<link rel="apple-touch-icon" href="/static/web-192.png">
<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="{{.TimelineURL}}" 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>
+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>
+110
View File
@@ -0,0 +1,110 @@
<!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>Edit — {{.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; }
.edit-page {
max-width: 520px;
margin: 0 auto;
padding: 32px 20px;
}
.edit-page .back-btn {
color: var(--fg3);
text-decoration: none;
font-size: 15px;
}
.edit-page .back-btn:hover { color: var(--fg); }
.edit-page h2 {
font-family: var(--serif);
font-size: 22px;
font-weight: 600;
color: var(--fg);
margin: 16px 0 24px;
}
.edit-img {
display: block;
width: 100%;
max-height: 340px;
object-fit: contain;
background: var(--bg0);
border: 1px solid var(--bg2);
margin-bottom: 8px;
}
.edit-slug {
font-size: 12px;
color: var(--fg4);
font-family: monospace;
margin-bottom: 24px;
overflow-wrap: anywhere;
}
.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(--serif); }
.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(--serif);
font-size: 16px;
font-weight: 600;
padding: 12px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.submit-btn:hover { background: var(--bg2); color: var(--fg); }
</style>
</head>
<body>
<div class="edit-page">
<a href="/manage" class="back-btn">← Manage</a>
<h2>Edit photo</h2>
<img class="edit-img" src="/{{.Photo.Medium}}" alt="">
<p class="edit-slug">{{.Photo.Slug}}</p>
<!-- A plain form post, no script: the server answers with a redirect
back to /manage, so a reload never resubmits the edit. -->
<form method="post" action="/manage/edit/{{.Photo.Slug}}">
<div class="field">
<label for="caption">Caption</label>
<input type="text" id="caption" name="caption" value="{{.Photo.Caption}}" autocomplete="off" autofocus>
</div>
<div class="field">
<label for="date">Date</label>
<input type="date" id="date" name="date" value="{{.Photo.Date.Format "2006-01-02"}}" required>
</div>
<button class="submit-btn" type="submit">Save changes</button>
</form>
</div>
</body>
</html>
@@ -6,6 +6,9 @@
<title>Photo Gallery</title> <title>Photo Gallery</title>
<link rel="stylesheet" href="{{.FontsURL}}"> <link rel="stylesheet" href="{{.FontsURL}}">
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<link rel="icon" href="/static/favicon.ico" sizes="any">
<link rel="icon" type="image/png" href="/static/web-32.png" sizes="32x32">
<link rel="apple-touch-icon" href="/static/web-192.png">
<style>:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }</style> <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>
@@ -20,6 +23,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="{{.TimelineURL}}" 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"/>
@@ -28,6 +32,12 @@
</svg> </svg>
RSS feed RSS feed
</a> </a>
<a href="https://git.inflo.ws/aashiks/photog" class="rss-a" target="_blank" rel="noopener">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M5.7 4.3 2 8l3.7 3.7 1-1L4 8l2.7-2.7zM10.3 4.3l-1 1L12 8l-2.7 2.7 1 1L14 8z"/>
</svg>
Source
</a>
</div> </div>
</div> </div>
</header> </header>
+117
View File
@@ -0,0 +1,117 @@
<!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>Manage — 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; }
.manage-page {
max-width: 720px;
margin: 0 auto;
padding: 32px 20px;
}
.manage-page .back-btn {
color: var(--fg3);
text-decoration: none;
font-size: 15px;
}
.manage-page .back-btn:hover { color: var(--fg); }
.manage-page h2 {
font-family: var(--serif);
font-size: 22px;
font-weight: 600;
color: var(--fg);
margin: 16px 0 6px;
}
.manage-count {
font-size: 12px;
color: var(--fg4);
font-family: monospace;
letter-spacing: 0.04em;
margin-bottom: 24px;
}
.saved {
margin-bottom: 20px;
font-size: 13px;
font-family: monospace;
padding: 10px 12px;
background: #1d2e1d;
color: #b8bb26;
border: 1px solid #344d28;
}
.mlist { list-style: none; }
.mrow {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 0;
border-top: 1px solid var(--line);
}
.mrow img {
width: 72px;
height: 72px;
object-fit: cover;
background: var(--bg0);
flex: none;
}
.minfo { flex: 1; min-width: 0; }
.mcaption {
font-size: 17px;
color: var(--fg);
overflow-wrap: anywhere;
}
.mdate {
font-size: 12px;
color: var(--fg4);
font-family: monospace;
margin-top: 4px;
}
.mactions {
flex: none;
display: flex;
gap: 14px;
font-size: 15px;
}
.mactions a { color: var(--fg3); text-decoration: none; }
.mactions a:hover { color: var(--fg); }
.mactions .medit { color: var(--aqua); }
.mactions .mdelete { color: #fb4934; }
.mempty { color: var(--fg4); font-size: 15px; }
</style>
</head>
<body>
<div class="manage-page">
<a href="/" class="back-btn">← Gallery</a>
<h2>Manage photos</h2>
<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 .Deleted}}<p class="saved">✓ deleted “{{.Deleted}}”</p>{{end}}
{{if .Photos}}
<ul class="mlist">
{{range .Photos}}
<li class="mrow">
<img src="/{{.Thumb}}" alt="" loading="lazy">
<div class="minfo">
<p class="mcaption">{{.Caption}}</p>
<p class="mdate">{{.Date.Format "2006-01-02"}}</p>
</div>
<div class="mactions">
<a href="/photo/{{.Slug}}/">View</a>
<a href="/manage/edit/{{.Slug}}" class="medit">Edit</a>
<a href="/manage/delete/{{.Slug}}" class="mdelete">Delete</a>
</div>
</li>
{{end}}
</ul>
{{else}}
<p class="mempty">No photos yet.</p>
{{end}}
</div>
</body>
</html>
+22
View File
@@ -4,8 +4,30 @@
<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>
<!-- Open Graph + Twitter card: the preview shown when the link is shared to
WhatsApp, Telegram, etc. URLs are absolute because both crawlers ignore
relative image paths. Served from public/og/, kept under ~250 KB. -->
<meta property="og:type" content="article">
<meta property="og:site_name" content="{{.Author}}">
<meta property="og:title" content="{{.Photo.Caption}}">
<meta property="og:description" content="{{.Author}} — {{.Photo.Date.Format "January 2, 2006"}}">
<meta property="og:url" content="{{.BaseURL}}/photo/{{.Photo.Slug}}/">
<meta property="og:image" content="{{.BaseURL}}/{{.Photo.OG}}">
<meta property="og:image:type" content="image/jpeg">
{{if .Photo.OGWidth}}<meta property="og:image:width" content="{{.Photo.OGWidth}}">
<meta property="og:image:height" content="{{.Photo.OGHeight}}">{{end}}
<meta property="og:image:alt" content="{{.Photo.Caption}}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{.Photo.Caption}}">
<meta name="twitter:description" content="{{.Author}} — {{.Photo.Date.Format "January 2, 2006"}}">
<meta name="twitter:image" content="{{.BaseURL}}/{{.Photo.OG}}">
<link rel="stylesheet" href="{{.FontsURL}}"> <link rel="stylesheet" href="{{.FontsURL}}">
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<link rel="icon" href="/static/favicon.ico" sizes="any">
<link rel="icon" type="image/png" href="/static/web-32.png" sizes="32x32">
<link rel="apple-touch-icon" href="/static/web-192.png">
<style>:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }</style> <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">
{{if .Newer}}<link rel="prefetch" href="/photo/{{.Newer.Slug}}/"> {{if .Newer}}<link rel="prefetch" href="/photo/{{.Newer.Slug}}/">
+114
View File
@@ -0,0 +1,114 @@
<!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">
<link rel="icon" href="/static/favicon.ico" sizes="any">
<link rel="icon" type="image/png" href="/static/web-32.png" sizes="32x32">
<link rel="apple-touch-icon" href="/static/web-192.png">
<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="{{.GridURL}}" 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>
<a href="https://git.inflo.ws/aashiks/photog" class="rss-a" target="_blank" rel="noopener">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M5.7 4.3 2 8l3.7 3.7 1-1L4 8l2.7-2.7zM10.3 4.3l-1 1L12 8l-2.7 2.7 1 1L14 8z"/>
</svg>
Source
</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>
+3
View File
@@ -6,6 +6,9 @@
<title>Upload — Photo Gallery</title> <title>Upload — 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="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="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<link rel="icon" href="/static/favicon.ico" sizes="any">
<link rel="icon" type="image/png" href="/static/web-32.png" sizes="32x32">
<link rel="apple-touch-icon" href="/static/web-192.png">
<style> <style>
.upload-page { .upload-page {
max-width: 520px; max-width: 520px;