Compare commits

..
3 Commits
Author SHA1 Message Date
foxandClaude Fable 5.1 185324c69c feat(manage): delete photos from /manage, prune leftover pages
/manage/delete/{slug} asks for confirmation, then removes the image,
its sidecar and everything generated from them. Builds now drop day
pages of days without photos and pager pages past the last one, so a
delete or a date change leaves nothing behind.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 08:22:18 +02:00
foxandClaude Fable 5.1 b7fdc8a3bc 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-28 07:10:29 +02:00
foxandClaude Fable 5.1 0268a7d4c9 feat(manage): add /manage pages to edit caption and date
/manage lists every photo; /manage/edit/{slug} rewrites the photo's
.toml sidecar, which triggers a rebuild. The pages sit behind the same
htpasswd as /upload and are not linked from the public site.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 06:43:44 +02:00
89 changed files with 1496 additions and 1850 deletions
+18 -4
View File
@@ -19,9 +19,19 @@ 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 /manage` (list of photos), `GET`/`POST /manage/edit/{slug}` (edit caption and date), `GET`/`POST /manage/delete/{slug}` (confirm, then delete), `GET /health`.
In production nginx sits in front: serves `public/` directly, proxies `/upload` 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`. 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
@@ -46,9 +56,10 @@ Missing sidecar → filename as caption, today as date. Photos sort newest first
- 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`
- 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`): - Two views of the same photos, both 3-column square grid → 2-column on mobile, both with infinite-scroll load-more. Page size is configurable via `-page-size` (default 60; NixOS `services.photogallery.pageSize`):
- **Timeline is the site root** (`/`, `/page/2/`, …). Photos grouped by calendar day: one ISO date heading (`2026-06-14`) + count per day, then the square grid. `groupByDay` collapses the newest-first list into consecutive same-day runs; `chunkDayGroups` packs whole days into pages targeting `pageSize`, never splitting a day across a page boundary (so appended pages never duplicate a heading — the load-more JS relies on this). - **Timeline is the site root by default** (`/`, `/page/2/`, …). Photos grouped by calendar day: one ISO date heading (`2026-06-14`) + count per day, then the square grid. `groupByDay` collapses the newest-first list into consecutive same-day runs; `chunkDayGroups` packs whole days into pages targeting `pageSize`, never splitting a day across a page boundary (so appended pages never duplicate a heading — the load-more JS relies on this).
- **Grid** (`/grid/`, `/grid/page/2/`, …) is the flat square grid, linked from the profile header. - **Grid** (`/grid/`, `/grid/page/2/`, …) is the flat square grid, linked from the profile header.
- Each timeline date heading links to a **single-day page** (`/day/<YYYY-MM-DD>/`) holding just that day's photos. `renderDayPages` writes one per `groupByDay` run; the ISO label doubles as the URL slug. - Each timeline date heading links to a **single-day page** (`/day/<YYYY-MM-DD>/`) holding just that day's photos. `renderDayPages` writes one per `groupByDay` run; the ISO label doubles as the URL slug.
- Which view owns the site root is set by `-default-view timeline|grid` (NixOS `services.photogallery.defaultView`). The other view moves under `/<name>/`: with `-default-view grid` the grid is `/` and the timeline is `/timeline/`. `viewBase` and `pageURL` in `generator.go` derive every page path and pager link from that, and the templates take the cross-links as `GridURL` / `TimelineURL` — never hardcode `/grid/` or `/`. On each build the directory named after the default view is removed, since it can only be a leftover from before a switch.
- Single photo page with sidebar and newer/older navigation - Single photo page with sidebar and newer/older navigation
### Link previews ### Link previews
@@ -143,12 +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/timeline.html` | Site root: photos grouped by day, paginated |
| `templates/grid.html` | Flat square grid at `/grid/`, paginated | | `templates/grid.html` | Flat square grid at `/grid/`, paginated |
| `templates/day.html` | Single-day page at `/day/<date>/` | | `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 |
@@ -162,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
+17 -20
View File
@@ -1,24 +1,21 @@
This is free and unencumbered software released into the public domain. MIT License
Anyone is free to copy, modify, publish, use, compile, sell, or Copyright (c) 2026 Ashik S
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors Permission is hereby granted, free of charge, to any person obtaining a copy
of this software dedicate any and all copyright interest in the of this software and associated documentation files (the "Software"), to deal
software to the public domain. We make this dedication for the benefit in the Software without restriction, including without limitation the rights
of the public at large and to the detriment of our heirs and to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
successors. We intend this dedication to be an overt act of copies of the Software, and to permit persons to whom the Software is
relinquishment in perpetuity of all present and future rights to this furnished to do so, subject to the following conditions:
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, The above copyright notice and this permission notice shall be included in all
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF copies or substantial portions of the Software.
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.
For more information, please refer to <https://unlicense.org> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 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 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.
+18 -1
View File
@@ -101,9 +101,11 @@ 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/` |
| `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 +140,18 @@ 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
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,7 +32,6 @@
<!-- 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.PickMultipleVisualMedia(), ActivityResultContracts.PickVisualMedia(),
) { uris -> if (uris.isNotEmpty()) viewModel.selectImages(uris) } ) { uri -> uri?.let(viewModel::selectImage) }
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,
onPickImages = { onPickImage = {
picker.launch( picker.launch(
PickVisualMediaRequest( PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.ImageOnly, ActivityResultContracts.PickVisualMedia.ImageOnly,
@@ -82,7 +82,6 @@ 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 },
@@ -99,31 +98,16 @@ class MainActivity : ComponentActivity() {
consumeShareIntent(intent) consumeShareIntent(intent)
} }
/** Picks up one or several photos handed over by the share sheet. */ /** Picks up a photo handed over by the share sheet. */
private fun consumeShareIntent(intent: Intent?) { private fun consumeShareIntent(intent: Intent?) {
val uris: List<Uri> = when (intent?.action) { if (intent?.action != Intent.ACTION_SEND) return
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)
} }
listOfNotNull(uri) uri?.let(viewModel::selectImage)
}
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,20 +1,17 @@
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.layout.size import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.width import androidx.compose.foundation.verticalScroll
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
@@ -39,6 +36,8 @@ 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
@@ -49,104 +48,111 @@ private val DISPLAY_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMM
@Composable @Composable
fun PostScreen( fun PostScreen(
state: PostUiState, state: PostUiState,
onPickImages: () -> Unit, onPickImage: () -> Unit,
onCaptionChange: (id: String, value: String) -> Unit, onCaptionChange: (String) -> Unit,
onDateChange: (id: String, value: LocalDate) -> Unit, onDateChange: (LocalDate) -> Unit,
onRemove: (id: String) -> Unit,
onSubmit: () -> Unit, onSubmit: () -> Unit,
onReset: () -> Unit, onReset: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onOpenUrl: (String) -> Unit, onOpenUrl: (String) -> Unit,
) { ) {
// Which item's date is being edited, if any. var datePickerOpen by remember { mutableStateOf(false) }
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()) {
LazyColumn( Column(
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 photos", style = MaterialTheme.typography.headlineSmall, color = GruvGreen) Text("Post a photo", 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()
.height(160.dp) .aspectRatio(1f)
.background(GruvBg1), .background(GruvBg1),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text("No photos selected", color = GruvFg4) val preview = state.preview
} when {
} preview != null -> Image(
} bitmap = preview.asImageBitmap(),
contentDescription = null,
items(state.items, key = { it.id }) { item -> contentScale = ContentScale.Fit,
ItemCard( modifier = Modifier.fillMaxSize(),
item = item,
onCaptionChange = { onCaptionChange(item.id, it) },
onEditDate = { dateEditFor = item },
onRemove = { onRemove(item.id) },
onOpenUrl = onOpenUrl,
) )
Spacer(Modifier.height(12.dp)) state.imageUri != null -> CircularProgressIndicator(color = GruvAqua)
else -> Text("No photo selected", color = GruvFg4)
}
} }
if (state.hasItems) { Spacer(Modifier.height(12.dp))
item {
Spacer(Modifier.height(8.dp)) OutlinedButton(onClick = onPickImage, modifier = Modifier.fillMaxWidth()) {
Button( Text(if (state.imageUri == null) "Choose photo" else "Choose a different photo")
onClick = onSubmit, }
enabled = canPost,
Spacer(Modifier.height(16.dp))
OutlinedTextField(
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(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text( Text("Date: ${state.date.format(DISPLAY_DATE)}")
when {
state.posting -> "Posting… (${state.succeeded}/${state.items.size})"
pendingCount == 0 -> "All posted"
pendingCount == 1 -> "Post 1 photo"
else -> "Post $pendingCount photos"
},
)
} }
if (state.allDone) { Spacer(Modifier.height(20.dp))
Spacer(Modifier.height(12.dp))
val summary = buildString { Button(
append("${state.succeeded} posted") onClick = onSubmit,
if (state.failed > 0) append(", ${state.failed} failed") enabled = state.imageUri != null && state.status !is PostStatus.Uploading,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (state.status is PostStatus.Uploading) "Posting…" else "Post")
}
Spacer(Modifier.height(16.dp))
when (val status = state.status) {
is PostStatus.Error -> Text(status.message, color = GruvRed)
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))
TextButton(onClick = onReset) { Text("Clear") } Row {
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))
@@ -167,96 +173,19 @@ fun PostScreen(
} }
} }
} }
}
dateEditFor?.let { editing -> if (datePickerOpen) {
DatePickerModal( DatePickerModal(
initial = editing.date, initial = state.date,
onDismiss = { dateEditFor = null }, onDismiss = { datePickerOpen = false },
onConfirm = { onConfirm = {
onDateChange(editing.id, it) onDateChange(it)
dateEditFor = null datePickerOpen = false
}, },
) )
} }
} }
@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,40 +24,21 @@ import java.io.File
import java.time.LocalDate import java.time.LocalDate
import java.util.UUID import java.util.UUID
/** Per-photo status within a batch. */ sealed interface PostStatus {
sealed interface ItemStatus { data object Idle : PostStatus
data object Pending : ItemStatus data object Uploading : PostStatus
data object Uploading : ItemStatus data class Success(val url: String, val note: String?) : PostStatus
data class Success(val url: String, val note: String?) : ItemStatus data class Error(val message: String) : PostStatus
data class Error(val message: String) : ItemStatus
} }
/** data class PostUiState(
* One photo in the batch. A single-photo post (picker or share) is just a val imageUri: Uri? = null,
* 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: ItemStatus = ItemStatus.Pending, val status: PostStatus = PostStatus.Idle,
)
data class PostUiState(
val items: List<PickedItem> = emptyList(),
val lastPost: LastPost? = null, 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 = "",
@@ -88,81 +69,57 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) {
// ── Post screen ───────────────────────────────────────────────────────── // ── Post screen ─────────────────────────────────────────────────────────
/** Replaces the current selection with [uris] and loads each preview. */ fun selectImage(uri: Uri) {
fun selectImages(uris: List<Uri>) { post = post.copy(imageUri = uri, status = PostStatus.Idle, preview = null)
if (uris.isEmpty()) return
val items = uris.map { PickedItem(source = it) }
post = post.copy(items = items)
val context = getApplication<Application>()
for (item in items) {
viewModelScope.launch { viewModelScope.launch {
val preview = withContext(Dispatchers.IO) { val context = getApplication<Application>()
ImagePrep.preview(context, item.source, maxDimension = 256) val preview = withContext(Dispatchers.IO) { ImagePrep.preview(context, uri) }
} val captured = withContext(Dispatchers.IO) { ImagePrep.captureDate(context, uri) }
val captured = withContext(Dispatchers.IO) { post = post.copy(preview = preview, date = captured ?: LocalDate.now())
ImagePrep.captureDate(context, item.source)
}
updateItem(item.id) {
it.copy(preview = preview, date = captured ?: it.date)
}
}
} }
} }
fun setCaption(id: String, value: String) { fun setCaption(value: String) {
updateItem(id) { it.copy(caption = value) } post = post.copy(caption = value)
} }
fun setDate(id: String, value: LocalDate) { fun setDate(value: LocalDate) {
updateItem(id) { it.copy(date = value) } post = post.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( post = post.copy(status = PostStatus.Error("Set the server URL and credentials first"))
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
} }
val pending = post.items.filter { it.status !is ItemStatus.Success } post = post.copy(status = PostStatus.Uploading)
if (pending.isEmpty()) return
val context = getApplication<Application>()
for (item in pending) {
updateItem(item.id) { it.copy(status = ItemStatus.Uploading) }
viewModelScope.launch { viewModelScope.launch {
val staged = withContext(Dispatchers.IO) { stage(item.source) } val context = getApplication<Application>()
// Stage the bytes into our own cache before handing the job to
// WorkManager. A content:// URI from the share sheet is only
// readable while the grant lasts, and a retry can fire long after
// the activity that received it is gone.
val staged = withContext(Dispatchers.IO) { stage(uri) }
if (staged == null) { if (staged == null) {
updateItem(item.id) { post = post.copy(status = PostStatus.Error("Could not read the selected image"))
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 workId = UploadQueue.enqueue( val id = UploadQueue.enqueue(
context = context, context = context,
uri = Uri.fromFile(staged), uri = Uri.fromFile(staged),
caption = current.caption, caption = post.caption,
date = current.date, date = post.date,
uniqueSuffix = item.id.take(6),
) )
observe(item.id, workId) observe(id)
}
} }
} }
@@ -179,35 +136,32 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) {
} }
} }
private fun observe(itemId: String, workId: UUID) { private fun observe(id: UUID) {
viewModelScope.launch { viewModelScope.launch {
WorkManager.getInstance(getApplication()).getWorkInfoByIdFlow(workId).collect { info -> WorkManager.getInstance(getApplication()).getWorkInfoByIdFlow(id).collect { info ->
if (info == null) return@collect if (info == null) return@collect
when (info.state) { when (info.state) {
WorkInfo.State.SUCCEEDED -> { WorkInfo.State.SUCCEEDED -> {
updateItem(itemId) { post = post.copy(
it.copy( status = PostStatus.Success(
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 -> updateItem(itemId) { WorkInfo.State.FAILED -> {
it.copy( post = post.copy(
status = ItemStatus.Error( status = PostStatus.Error(
info.outputData.getString(UploadWorker.KEY_RESULT_ERROR) info.outputData.getString(UploadWorker.KEY_RESULT_ERROR)
?: "Upload failed", ?: "Upload failed",
), ),
) )
} }
WorkInfo.State.CANCELLED -> updateItem(itemId) { WorkInfo.State.CANCELLED ->
it.copy(status = ItemStatus.Error("Upload cancelled")) post = post.copy(status = PostStatus.Error("Upload cancelled"))
}
else -> Unit else -> Unit
} }
@@ -215,10 +169,6 @@ 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,11 +18,9 @@ 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: capture time collides across a burst. A [uniqueSuffix] is * capture time: a burst of frames shot in the same second would collide on
* appended so that a bulk post — several photos enqueued in the same second * capture time, whereas posts are made one at a time. The real capture date
* — cannot collide either; without it enqueueUniqueWork would silently drop * still travels in the `date` field and lands in the .toml sidecar.
* 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")
@@ -34,9 +32,8 @@ 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) + "-" + uniqueSuffix val slug = LocalDateTime.now().format(SLUG_FORMAT)
val request = OneTimeWorkRequestBuilder<UploadWorker>() val request = OneTimeWorkRequestBuilder<UploadWorker>()
.setInputData( .setInputData(
@@ -0,0 +1,10 @@
<?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>
@@ -0,0 +1,28 @@
<?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,9 +1,7 @@
<?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. The foreground is the full circular density-bucket PNG fallbacks are needed. -->
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="@color/gruvbox_bg" /> <background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" /> <foreground android:drawable="@drawable/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="@color/gruvbox_bg" /> <background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" /> <foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon> </adaptive-icon>
Binary file not shown.

Before

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">Photog</string> <string name="app_name">Gallery Upload</string>
<string name="upload_channel_name">Uploads</string> <string name="upload_channel_name">Uploads</string>
</resources> </resources>
-3
View File
@@ -1,3 +0,0 @@
/build/
/.direnv/
.envrc
-42
View File
@@ -1,42 +0,0 @@
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
@@ -1,9 +0,0 @@
<!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
@@ -1,61 +0,0 @@
{
"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
@@ -1,55 +0,0 @@
{
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.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

-237
View File
@@ -1,237 +0,0 @@
#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
@@ -1,37 +0,0 @@
#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
@@ -1,25 +0,0 @@
#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
@@ -1,361 +0,0 @@
#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
@@ -1,69 +0,0 @@
#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
@@ -1,9 +0,0 @@
[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
@@ -1,101 +0,0 @@
#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
@@ -1,40 +0,0 @@
#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
@@ -1,126 +0,0 @@
#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
@@ -1,58 +0,0 @@
#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
@@ -1,126 +0,0 @@
#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
@@ -1,51 +0,0 @@
#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.unlicense; license = licenses.mit;
mainProgram = "photogallery"; mainProgram = "photogallery";
platforms = platforms.linux; platforms = platforms.linux;
}; };
+142 -46
View File
@@ -27,6 +27,11 @@ const (
mediumMaxSize = 1600 mediumMaxSize = 1600
defaultPageSize = 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 // Open Graph preview image, shared to WhatsApp/Telegram/etc. Kept to a
// 1200px longest side and squeezed under ogTargetBytes: WhatsApp is widely // 1200px longest side and squeezed under ogTargetBytes: WhatsApp is widely
// reported to drop the preview entirely once the image passes ~300 KB, so // reported to drop the preview entirely once the image passes ~300 KB, so
@@ -78,15 +83,19 @@ type Generator struct {
serifFamily string serifFamily string
mlFamily string mlFamily string
pageSize int 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, pageSize int) *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 { if pageSize < 1 {
pageSize = defaultPageSize pageSize = defaultPageSize
} }
if defaultView != viewGrid {
defaultView = viewTimeline
}
return &Generator{ return &Generator{
contentDir: contentDir, contentDir: contentDir,
outputDir: outputDir, outputDir: outputDir,
@@ -99,6 +108,7 @@ func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, f
serifFamily: serifFamily, serifFamily: serifFamily,
mlFamily: mlFamily, mlFamily: mlFamily,
pageSize: pageSize, pageSize: pageSize,
defaultView: defaultView,
tmpl: tmpl, tmpl: tmpl,
} }
} }
@@ -110,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 {
@@ -121,7 +148,7 @@ func (g *Generator) Build() error {
return err return err
} }
for _, d := range []string{"images", "thumbs", "medium", "og", "photo", "grid", "day", "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
} }
@@ -133,6 +160,13 @@ func (g *Generator) Build() error {
} }
} }
// 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 { if err := g.renderTimelinePages(photos); err != nil {
return err return err
} }
@@ -165,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)
@@ -407,17 +483,17 @@ func generateOG(src, dst string, maxDim int) error {
return os.WriteFile(dst, buf.Bytes(), 0644) return os.WriteFile(dst, buf.Bytes(), 0644)
} }
// renderGridPages writes the paginated square-grid view under public/grid/: // renderGridPages writes the paginated square-grid view: index.html and
// grid/index.html and grid/page/N/index.html, chunking photos into pageSize // page/N/index.html under the grid's base (the site root when the grid is the
// slices for progressive pagination. The grid is the secondary view; the // default view, /grid/ otherwise), chunking photos into pageSize slices for
// timeline is the site root. // progressive pagination.
func (g *Generator) renderGridPages(photos []Photo) error { func (g *Generator) renderGridPages(photos []Photo) error {
total := len(photos) total := len(photos)
totalPages := (total + g.pageSize - 1) / g.pageSize totalPages := (total + g.pageSize - 1) / g.pageSize
if totalPages == 0 { if totalPages == 0 {
totalPages = 1 totalPages = 1
} }
gridDir := filepath.Join(g.outputDir, "grid") base := g.viewBase(viewGrid)
for i := 0; i < totalPages; i++ { for i := 0; i < totalPages; i++ {
start := i * g.pageSize start := i * g.pageSize
@@ -427,31 +503,24 @@ func (g *Generator) renderGridPages(photos []Photo) error {
} }
slice := photos[start:end] slice := photos[start:end]
dst := filepath.Join(gridDir, "index.html") dir := filepath.Join(g.outputDir, filepath.FromSlash(pageURL(base, i+1)))
if i > 0 {
dir := filepath.Join(gridDir, "page", strconv.Itoa(i+1))
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
return err return err
} }
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 = "/grid/"
default:
prev = "/grid/page/" + strconv.Itoa(i) + "/"
} }
if i+1 < totalPages { if i+1 < totalPages {
next = "/grid/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,
@@ -472,7 +541,7 @@ func (g *Generator) renderGridPages(photos []Photo) error {
return err return err
} }
} }
return nil return g.prunePages(base, totalPages)
} }
// renderPhotoPage writes public/photo/<slug>/index.html. // renderPhotoPage writes public/photo/<slug>/index.html.
@@ -544,39 +613,35 @@ func chunkDayGroups(groups []DayGroup, target int) [][]DayGroup {
return pages return pages
} }
// renderTimelinePages writes the day-grouped timeline as the site root: // renderTimelinePages writes the day-grouped timeline: index.html and
// index.html and page/N/index.html. Photos are grouped by day, then packed // page/N/index.html under the timeline's base (the site root when the
// timeline is the default view). Photos are grouped by day, then packed
// into pages of roughly pageSize without splitting a day across pages. // into pages of roughly pageSize without splitting a day across pages.
func (g *Generator) renderTimelinePages(photos []Photo) error { func (g *Generator) renderTimelinePages(photos []Photo) error {
pages := chunkDayGroups(groupByDay(photos), g.pageSize) pages := chunkDayGroups(groupByDay(photos), g.pageSize)
totalPages := len(pages) totalPages := len(pages)
base := g.viewBase(viewTimeline)
for i, groups := range pages { for i, groups := range pages {
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{
"Groups": groups, "Groups": groups,
"GridURL": pageURL(g.viewBase(viewGrid), 1),
"Total": len(photos), "Total": len(photos),
"Page": i + 1, "Page": i + 1,
"TotalPages": totalPages, "TotalPages": totalPages,
@@ -597,19 +662,58 @@ func (g *Generator) renderTimelinePages(photos []Photo) error {
return err return err
} }
} }
return g.prunePages(base, totalPages)
}
// prunePages removes the pager pages of a view past its last one. They are
// left behind when the photo count shrinks or the views swap places, and
// would keep showing photos that are gone.
func (g *Generator) prunePages(base string, totalPages int) error {
// Page 1 is the view's own root, never a leftover: start past it even
// if a caller reports no pages at all.
for n := max(totalPages, 1) + 1; ; n++ {
dir := filepath.Join(g.outputDir, filepath.FromSlash(pageURL(base, n)))
if _, err := os.Stat(dir); err != nil {
return nil return nil
}
if err := os.RemoveAll(dir); err != nil {
return err
}
}
} }
// renderDayPages writes one page per calendar day under public/day/<ISO>/, // renderDayPages writes one page per calendar day under public/day/<ISO>/,
// holding just that day's photos. Timeline date headings link here. // holding just that day's photos. Timeline date headings link here. Pages of
// days that no longer have photos, after a delete or a date change, are
// removed.
func (g *Generator) renderDayPages(photos []Photo) error { func (g *Generator) renderDayPages(photos []Photo) error {
for _, grp := range groupByDay(photos) { groups := groupByDay(photos)
live := make(map[string]bool, len(groups))
for _, grp := range groups {
live[grp.day] = true
}
dayDir := filepath.Join(g.outputDir, "day")
entries, err := os.ReadDir(dayDir)
if err != nil {
return err
}
for _, e := range entries {
if e.IsDir() && !live[e.Name()] {
if err := os.RemoveAll(filepath.Join(dayDir, e.Name())); err != nil {
return err
}
}
}
for _, grp := range groups {
dir := filepath.Join(g.outputDir, "day", grp.day) dir := filepath.Join(g.outputDir, "day", grp.day)
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
return err return err
} }
err := g.render("day.html", filepath.Join(dir, "index.html"), map[string]any{ err := g.render("day.html", filepath.Join(dir, "index.html"), map[string]any{
"Date": grp.Label, "Date": grp.Label,
"TimelineURL": pageURL(g.viewBase(viewTimeline), 1),
"Count": grp.Count, "Count": grp.Count,
"Photos": grp.Photos, "Photos": grp.Photos,
"Total": len(photos), "Total": len(photos),
@@ -713,14 +817,6 @@ 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
} }
+158
View File
@@ -0,0 +1,158 @@
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 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.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

+6 -1
View File
@@ -20,15 +20,20 @@ func main() {
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)") 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)")
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, *pageSize) 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)
+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)
}
+325
View File
@@ -0,0 +1,325 @@
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, g)
}
func writeFile(t *testing.T, path string, data []byte) {
t.Helper()
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
}
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)
}
}
})
}
}
+14 -1
View File
@@ -82,6 +82,12 @@ in {
description = "Photos per page on the timeline and grid views. The timeline packs whole days, so pages vary slightly around this."; 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/.";
};
nginx = { nginx = {
enable = lib.mkEnableOption "nginx virtual host for photogallery"; enable = lib.mkEnableOption "nginx virtual host for photogallery";
@@ -94,7 +100,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 {
@@ -148,6 +154,7 @@ in {
"--serif-family" cfg.serifFamily "--serif-family" cfg.serifFamily
"--ml-family" cfg.mlFamily "--ml-family" cfg.mlFamily
"--page-size" (toString cfg.pageSize) "--page-size" (toString cfg.pageSize)
"--default-view" cfg.defaultView
]; ];
Restart = "on-failure"; Restart = "on-failure";
@@ -184,6 +191,12 @@ in {
basicAuthFile = cfg.nginx.htpasswdFile; basicAuthFile = cfg.nginx.htpasswdFile;
extraConfig = "client_max_body_size 20M;"; extraConfig = "client_max_body_size 20M;";
}; };
locations."/manage" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}";
basicAuth = "gallery";
basicAuthFile = cfg.nginx.htpasswdFile;
};
}; };
}; };
}) })
+5
View File
@@ -35,6 +35,11 @@ func (s *Server) Listen() error {
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)
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")
}) })
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

+1 -4
View File
@@ -6,9 +6,6 @@
<title>{{.Date}} · Photo Gallery</title> <title>{{.Date}} · 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>
@@ -23,7 +20,7 @@
<p class="handle">{{.Handle}}</p> <p class="handle">{{.Handle}}</p>
<p class="pcount"><strong>{{.Total}}</strong> posts</p> <p class="pcount"><strong>{{.Total}}</strong> posts</p>
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p> <p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
<a href="/" class="rss-a">← Timeline</a> <a href="{{.TimelineURL}}" class="rss-a">← Timeline</a>
</div> </div>
</div> </div>
</header> </header>
+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>
+1 -10
View File
@@ -6,9 +6,6 @@
<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>
@@ -23,7 +20,7 @@
<p class="handle">{{.Handle}}</p> <p class="handle">{{.Handle}}</p>
<p class="pcount"><strong>{{.Total}}</strong> posts</p> <p class="pcount"><strong>{{.Total}}</strong> posts</p>
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p> <p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
<a href="/" class="rss-a">Timeline</a> <a href="{{.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"/>
@@ -32,12 +29,6 @@
</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>
-3
View File
@@ -25,9 +25,6 @@
<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}}/">
+1 -10
View File
@@ -6,9 +6,6 @@
<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>
@@ -23,7 +20,7 @@
<p class="handle">{{.Handle}}</p> <p class="handle">{{.Handle}}</p>
<p class="pcount"><strong>{{.Total}}</strong> posts</p> <p class="pcount"><strong>{{.Total}}</strong> posts</p>
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p> <p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
<a href="/grid/" class="rss-a">Grid</a> <a href="{{.GridURL}}" class="rss-a">Grid</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"/>
@@ -32,12 +29,6 @@
</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>
-3
View File
@@ -6,9 +6,6 @@
<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;