Compare commits

...

10 Commits

Author SHA1 Message Date
puttaalu eb6dba6eb0 Add single-day pages linked from timeline date headings
Each timeline date heading now links to /day/<YYYY-MM-DD>/, a page
holding just that day's photos with a back link to the timeline.
renderDayPages writes one per groupByDay run; the ISO label doubles
as the URL slug.
2026-08-20 00:13:49 +02:00
puttaalu 4ddf5adf24 Make the timeline the default view, paginated and configurable
The day-grouped timeline is now the site root (/, /page/N/); the flat
square grid moves to /grid/ (/grid/page/N/). Both are linked from the
profile header, separated by a divider.

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

Page size is configurable via -page-size (default 60), threaded through
the Generator and exposed as services.photogallery.pageSize in the
NixOS module.
2026-08-20 00:11:08 +02:00
puttaalu 9b6fcb482b Add a timeline page grouping photos by day
New /timeline/ page renders photos grouped into consecutive same-day
runs (ISO date heading plus per-day count), reusing the square grid.
groupByDay collapses the newest-first photo list; the page is not
paginated. Linked from the profile header.
2026-08-19 23:46:28 +02:00
puttaalu c169c030d6 Update CLAUDE.md for pagination, medium images, swipe nav and the Android app
Also corrects the serif font, which is Crimson Pro rather than Vollkorn,
and notes that .gitignore patterns here have to be anchored or they eat
the Kotlin package directory.
2026-08-19 23:36:14 +02:00
puttaalu 15822ed227 Add swipe navigation to the photo detail page
Swipe left for older, right for newer, matching the existing arrow keys.
The image tracks the finger and slides out on commit; the exit animation
covers the page load, so this stays plain static-site navigation.

The keyboard handler moves out of the template into static/photo-nav.js,
which the existing //go:embed of static/ picks up with no generator
changes.

Swipes starting within 24px of a screen edge are left alone — that strip
is the browser's own back-gesture. Only touch pointers are handled, so
desktop drag-to-save still works, and the axis locks after 10px so
vertical drags stay scrolls. With no neighbour that way the image
rubber-bands instead of navigating.

Also prefetch the neighbour medium JPEGs, not just their HTML, or the
next page still blocks on a fresh image request.

The keyboard handler now bails on modifier keys; it previously hijacked
Alt+ArrowLeft, which is browser-back on Linux and Windows.
2026-08-19 23:24:42 +02:00
puttaalu 210ce69e18 Add an Android uploader app
Native single-photo uploader for POST /upload. Lives in android/ with its
own flake, so the root flake stays Go-only and NixOS consumers of
nixosModules.default do not pull the Android SDK into their lock.

The server is untouched, so the app closes every gap client side:

- The slug is the enqueue-time timestamp, persisted in the work input.
  server.go derives the stored slug from the uploaded filename, so reusing
  that filename across WorkManager retries overwrites the same post rather
  than creating a duplicate. That stands in for an idempotency key.
- EXIF is stripped before upload. The gallery publishes the uploaded file
  verbatim as its full-size download, and phone photos carry GPS. JPEG and
  PNG drop metadata at marker and chunk level with the compressed pixels
  untouched; HEIC is decoded to JPEG because the extension check rejects it.
- Photos flagged for rotation are rotated into the pixels instead of relying
  on the orientation tag, which cannot survive the strip and which the
  thumbnailer ignores regardless.
- Anything over 18 MiB steps quality, then resolution, to stay inside
  nginx's client_max_body_size 20M.
- Credentials are sealed with an AES-GCM key in the Android Keystore. The
  key deliberately does not require user authentication, or background
  retries could not read it. Basic auth is attached by an interceptor
  rather than an Authenticator, which reacts to a 401 by replaying a
  multipart body that is not reliably replayable.
- The connection test uses GET /upload. /health is unreachable from outside
  because the nginx vhost only proxies location /upload.

WorkManager's SystemForegroundService needs foregroundServiceType declared
on the service entry, not just as a permission, or setForeground throws on
Android 14+. Only lintVitalRelease catches that, never a debug build.

Also anchors the .gitignore patterns. Unanchored, "photogallery" matched the
Kotlin package directory ws/inflo/photogallery/ and silently swallowed every
source file on git add.
2026-08-13 22:01:20 +02:00
puttaalu 70d115e11c Split the photo nav so Back sits alone on the left
Move the Newer and Older links to a right-aligned group joined by a
slash so they stop crowding the Back link, and drop the aqua accent so
every link in the row shares the muted serif tone.
2026-07-03 23:11:11 +02:00
puttaalu 3639bd5577 Add newer/older navigation to the photo detail page
Replace the standalone Back link with a three-cell nav row so each
photo page links to its neighbours in the timeline. Prefetch the
neighbouring pages and bind the arrow keys for keyboard navigation.
2026-07-03 23:03:54 +02:00
puttaalu 856439f3fd Paginate the gallery grid with 60 photos per page
Emit /page/N/ pages alongside index.html and render a pager at the
bottom of each page. Ships as a plain link that navigates without JS,
and an IntersectionObserver enhancement that fetches the next page and
appends tiles for an infinite-scroll feel.
2026-07-02 22:20:28 +02:00
puttaalu 613906cd0c Colour the profile name and handle with Gruvbox green and blue 2026-07-02 11:38:56 +02:00
42 changed files with 3346 additions and 71 deletions
+19 -5
View File
@@ -1,8 +1,22 @@
content/
public/
photogallery
result
result-*
/content/
/public/
/.demo/
# Anchored: unanchored these would also match the Kotlin package directory
# android/app/src/main/java/ws/inflo/photogallery/ and any nested "result".
/photogallery
/result
/result-*
*.tar.gz
.envrc
.direnv/
# Android app
android/.gradle/
android/build/
android/app/build/
android/local.properties
android/.idea/
android/.kotlin/
android/keystore.properties
*.jks
*.keystore
+63 -9
View File
@@ -2,6 +2,7 @@
Personal static photo gallery with Atom feed and upload API. Single-user.
No database, no framework — just a Go binary that watches a directory and regenerates a static site.
An Android app in `android/` posts to the same upload API.
## Architecture
@@ -9,7 +10,8 @@ No database, no framework — just a Go binary that watches a directory and rege
content/ # images + .toml sidecars (source of truth)
public/ # generated output (served by nginx / --serve flag)
templates/ # embedded HTML templates
static/ # embedded CSS
static/ # embedded CSS + JS
android/ # Kotlin/Compose uploader app, own flake
```
The binary does three things at once:
@@ -17,13 +19,15 @@ The binary does three things at once:
- Serves a multipart upload API at `POST /upload`
- In dev mode (`--serve`), also serves `public/` as static files
In production nginx sits in front: serves `public/` directly, proxies `/upload` to localhost, htpasswd on the upload endpoint.
Routes: `GET /upload` (HTML upload form), `POST /upload` (multipart), `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`.
## Stack
- **Go** — stdlib + `BurntSushi/toml`, `fsnotify/fsnotify`, `golang.org/x/image`
- **Templates** — `html/template`, embedded via `embed.FS`
- **Images** — centre-crop to square, Catmull-Rom resize to 600×600 JPEG thumbnails
- **Images** — three renditions per photo: original copy, 600×600 centre-cropped square thumbnail, and a 1600px-longest-side "medium" for the detail page. Catmull-Rom resize. Outputs are skipped when newer than the source.
- **Feed** — Atom (`encoding/xml`)
- **NixOS** — `flake.nix` + `module.nix`, exports `nixosModules.default`
@@ -34,15 +38,39 @@ caption = "Tempelhof at sunrise"
date = "2026-06-14"
```
Missing sidecar → filename as caption, today as date.
Missing sidecar → filename as caption, today as date. Photos sort newest first by that date.
## Design
- Gruvbox dark hard colour scheme
- Vollkorn (serif) for headings and captions
- Manjari for Malayalam text (bio line)
- 3-column square grid → 2-column on mobile
- Single photo page with sidebar
- Crimson Pro (serif) for headings and captions — configurable via `-serif-family` / `-fonts-url`
- 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`):
- **Timeline is the site root** (`/`, `/page/2/`, …). Photos grouped by calendar day: one ISO date heading (`2026-06-14`) + count per day, then the square grid. `groupByDay` collapses the newest-first list into consecutive same-day runs; `chunkDayGroups` packs whole days into pages targeting `pageSize`, never splitting a day across a page boundary (so appended pages never duplicate a heading — the load-more JS relies on this).
- **Grid** (`/grid/`, `/grid/page/2/`, …) is the flat square grid, linked from the profile header.
- Each timeline date heading links to a **single-day page** (`/day/<YYYY-MM-DD>/`) holding just that day's photos. `renderDayPages` writes one per `groupByDay` run; the ISO label doubles as the URL slug.
- Single photo page with sidebar and newer/older navigation
### Photo page navigation
`static/photo-nav.js` handles arrow keys and touch swipe. Both read their
destinations from the `[data-nav="newer"]` / `[data-nav="older"]` anchors the
template renders, so a photo with no sibling in that direction has nothing to
find and the gesture rubber-bands. If the script fails to load the links still
work.
Swipe left → older, right → newer, matching ArrowRight/ArrowLeft. Constraints
worth knowing before touching it:
- Swipes starting within 24px of a screen edge are ignored — that strip is the browser's own back-gesture.
- Only `pointerType === "touch"`, so desktop drag-to-save is untouched.
- Axis locks once after 10px so vertical drags stay scrolls; `touch-action: pan-y` tells the compositor the same.
- The image is wrapped in an `<a>` to the full-size original, so the click trailing a swipe is suppressed or it opens a new tab.
- `EXIT_MS` in the JS must stay in sync with the transition duration in `style.css`.
Anything added under `static/` is embedded and copied to `public/static/`
automatically — `assets.go` embeds the whole directory and `copyStatic()` walks
every entry, so new CSS/JS needs no generator change.
## Running locally
@@ -54,6 +82,20 @@ go run . --serve
# upload: http://localhost:8080/upload
```
## Android app
Kotlin + Jetpack Compose uploader: pick or share a photo, add caption and date, post. Credentials (HTTP Basic) are sealed with an Android Keystore AES-GCM key. See `android/README.md` for the detail.
Its own flake, deliberately separate from the root one so NixOS consumers of `nixosModules.default` don't pull the Android SDK into their lock:
```bash
cd android
nix develop
./gradlew assembleRelease # app/build/outputs/apk/release/app-release.apk
```
The server needs no changes to serve it — every gap (slug collisions, EXIF, HEIC, size caps) is closed client-side. Release builds need `android/keystore.properties`; without it the signing config is skipped and only debug builds work.
## NixOS module usage
```nix
@@ -86,12 +128,22 @@ nix build # succeeds
| File | Purpose |
|---|---|
| `main.go` | Flags, wires generator + watcher + server |
| `generator.go` | Scans content dir, renders HTML + Atom feed, generates thumbs |
| `generator.go` | Scans content dir, renders HTML + Atom feed, generates thumbs/mediums |
| `server.go` | Upload API + optional static file serving |
| `watcher.go` | fsnotify watcher with 500ms debounce |
| `assets.go` | `//go:embed` for templates and static |
| `templates/timeline.html` | Site root: photos grouped by day, paginated |
| `templates/grid.html` | Flat square grid at `/grid/`, paginated |
| `templates/day.html` | Single-day page at `/day/<date>/` |
| `templates/photo.html` | Single photo page, neighbour links and prefetch |
| `static/photo-nav.js` | Arrow-key and swipe navigation |
| `module.nix` | NixOS module (systemd service, tmpfiles, nginx vhost) |
| `flake.nix` | `buildGoModule` package + devShell + nixosModules |
| `android/flake.nix` | Android SDK dev shell (separate from the root flake) |
## Gotchas
- `.gitignore` patterns here must be anchored. An unanchored `photogallery` also matches the Kotlin package directory `android/app/src/main/java/ws/inflo/photogallery/` and silently swallows every source file in it.
## What's not done yet
@@ -99,3 +151,5 @@ nix build # succeeds
- Multi-image upload
- Delete/edit via UI
- Any auth beyond nginx htpasswd
- 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
+67
View File
@@ -0,0 +1,67 @@
# Gallery Upload — Android client
Native uploader for the photogallery `POST /upload` endpoint. Pick or share a
photo, add a caption and date, post. Credentials are entered once and sealed
with a key held in the Android Keystore.
The server is not modified by this app. Everything it needs, it does client
side.
## Build
```bash
cd android
nix develop # Android SDK 36, build-tools 36.0.0, JDK 17, gradle
gradle wrapper # first time only, generates ./gradlew
./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
The dev shell exports `ANDROID_HOME`, `JAVA_HOME` and a `GRADLE_OPTS` that
points AGP at the nix-provided `aapt2`. Without that last one AGP downloads a
generic-linux `aapt2` from Maven which will not run on NixOS.
## Release signing
`app/build.gradle.kts` wires up a release signing config only when
`android/keystore.properties` exists. Both that file and `*.jks` are
gitignored — keep the keystore outside the repo and back it up somewhere you
will still have in five years, because losing it means you can never upgrade an
installed build in place.
```bash
mkdir -p ~/keys
keytool -genkeypair -v \
-keystore ~/keys/photogallery-release.jks \
-alias photogallery \
-keyalg RSA -keysize 4096 -validity 10000
```
Then write `android/keystore.properties`:
```properties
storeFile=/home/you/keys/photogallery-release.jks
storePassword=
keyAlias=photogallery
keyPassword=
```
```bash
./gradlew assembleRelease
```
Without `keystore.properties` the release build still runs, it just comes out
unsigned.
## How it maps onto the server
| Server behaviour | What the app does about it |
|---|---|
| Slug derived from the uploaded filename | Sends `yyyy-MM-dd-HHmmss.jpg`, generated once at enqueue |
| Same filename overwrites the same post | Reused across retries, which makes retries idempotent |
| Uploaded file published verbatim as "full size" | Strips EXIF, including GPS, before upload |
| Extension check rejects HEIC | Converts HEIC to JPEG; JPEG and PNG pass through losslessly |
| nginx `client_max_body_size 20M` | Steps quality, then resolution, if the result exceeds 18 MiB |
| Thumbnailer ignores EXIF orientation | Bakes rotation into the pixels rather than relying on the tag |
| nginx only proxies `/upload`, so `/health` 404s | Tests credentials with `GET /upload` instead |
| Replies `uploaded <slug>.jpg` in plain text | Parses that into `<base>/photo/<slug>/` for the "Open" link |
+87
View File
@@ -0,0 +1,87 @@
import java.util.Properties
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
// Release signing is wired up only when android/keystore.properties exists.
// That file holds the keystore passwords and is gitignored, as is the .jks
// itself — keep both outside the repo. See android/README.md for the
// keytool invocation that creates them.
val keystorePropsFile = rootProject.file("keystore.properties")
val keystoreProps = Properties().apply {
if (keystorePropsFile.exists()) keystorePropsFile.inputStream().use { load(it) }
}
android {
namespace = "ws.inflo.photogallery"
compileSdk = 36
defaultConfig {
applicationId = "ws.inflo.photogallery"
minSdk = 28 // ImageDecoder + HEIF decode both land here
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
}
signingConfigs {
if (keystorePropsFile.exists()) {
create("release") {
storeFile = rootProject.file(keystoreProps.getProperty("storeFile"))
storePassword = keystoreProps.getProperty("storePassword")
keyAlias = keystoreProps.getProperty("keyAlias")
keyPassword = keystoreProps.getProperty("keyPassword")
}
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
// Falls back to unsigned when keystore.properties is absent, so a
// fresh clone still builds.
signingConfig = signingConfigs.findByName("release")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.exifinterface)
implementation(libs.okhttp)
debugImplementation(libs.androidx.ui.tooling)
}
+6
View File
@@ -0,0 +1,6 @@
# OkHttp ships Animal Sniffer and Conscrypt references that are absent at
# runtime on Android. Silence them rather than pulling in the dependencies.
-dontwarn okhttp3.internal.platform.**
-dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.**
-dontwarn org.openjsse.**
+48
View File
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<!-- WorkManager expedited uploads run in a foreground service on API 31+. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:largeHeap="true"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.PhotoGalleryUploader"
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:launchMode="singleTask"
android:theme="@style/Theme.PhotoGalleryUploader">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Share sheet target: pick a photo in Google Photos, share here. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity>
<!-- WorkManager runs expedited work in its own foreground service. On
Android 14+ the type has to be declared on the service entry, not
just requested as a permission, or setForeground() throws. -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
</application>
</manifest>
@@ -0,0 +1,121 @@
package ws.inflo.photogallery
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import ws.inflo.photogallery.ui.PhotoGalleryTheme
import ws.inflo.photogallery.ui.PostScreen
import ws.inflo.photogallery.ui.SettingsScreen
import ws.inflo.photogallery.ui.UploadViewModel
class MainActivity : ComponentActivity() {
private val viewModel: UploadViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
consumeShareIntent(intent)
setContent {
PhotoGalleryTheme {
// Land on settings until there is somewhere to post to.
var showSettings by rememberSaveable { mutableStateOf(!viewModel.isConfigured) }
val picker = rememberLauncherForActivityResult(
ActivityResultContracts.PickVisualMedia(),
) { uri -> uri?.let(viewModel::selectImage) }
val notificationPermission = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { /* Upload works either way; this only affects the progress notification. */ }
LaunchedEffect(Unit) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(
this@MainActivity,
Manifest.permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED
) {
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
if (showSettings) {
SettingsScreen(
state = viewModel.config,
onBaseUrlChange = viewModel::setBaseUrl,
onUsernameChange = viewModel::setUsername,
onPasswordChange = viewModel::setPassword,
onSave = viewModel::saveAndTest,
onBack = if (viewModel.isConfigured) {
{ showSettings = false }
} else {
null
},
)
} else {
PostScreen(
state = viewModel.post,
onPickImage = {
picker.launch(
PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.ImageOnly,
),
)
},
onCaptionChange = viewModel::setCaption,
onDateChange = viewModel::setDate,
onSubmit = viewModel::submit,
onReset = viewModel::clear,
onOpenSettings = { showSettings = true },
onOpenUrl = ::openUrl,
)
}
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
consumeShareIntent(intent)
}
/** Picks up a photo handed over by the share sheet. */
private fun consumeShareIntent(intent: Intent?) {
if (intent?.action != Intent.ACTION_SEND) return
val uri: Uri? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_STREAM)
}
uri?.let(viewModel::selectImage)
}
private fun openUrl(url: String) {
if (url.isBlank()) return
try {
startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
} catch (_: Exception) {
Toast.makeText(this, "No browser to open $url", Toast.LENGTH_SHORT).show()
}
}
}
@@ -0,0 +1,171 @@
package ws.inflo.photogallery.data
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* Settings and credential persistence.
*
* The password is the only secret here and it never hits disk in the clear:
* it is sealed with an AES-GCM key held in the Android Keystore, so the bytes
* sitting in SharedPreferences are useless without the hardware-backed key.
*
* The key deliberately does not require user authentication. WorkManager
* retries an upload with nobody looking at the screen, and a key gated on a
* fingerprint would throw there.
*
* androidx.security-crypto (EncryptedSharedPreferences) covers this ground but
* is deprecated, so the Keystore calls are made directly.
*/
class SettingsStore(context: Context) {
private val prefs = context.applicationContext
.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
var baseUrl: String
get() = prefs.getString(KEY_BASE_URL, "").orEmpty()
set(value) = prefs.edit().putString(KEY_BASE_URL, value.trim().trimEnd('/')).apply()
var username: String
get() = prefs.getString(KEY_USERNAME, "").orEmpty()
set(value) = prefs.edit().putString(KEY_USERNAME, value.trim()).apply()
/** Empty string when unset, or when the Keystore key no longer decrypts it. */
var password: String
get() = prefs.getString(KEY_PASSWORD, null)?.let(::decrypt).orEmpty()
set(value) = prefs.edit().putString(KEY_PASSWORD, encrypt(value)).apply()
val isConfigured: Boolean
get() = baseUrl.isNotEmpty() && username.isNotEmpty() && password.isNotEmpty()
// ── Last successful post, shown on the post screen ──────────────────────
var lastPost: LastPost?
get() {
val slug = prefs.getString(KEY_LAST_SLUG, null) ?: return null
return LastPost(
slug = slug,
url = prefs.getString(KEY_LAST_URL, "").orEmpty(),
caption = prefs.getString(KEY_LAST_CAPTION, "").orEmpty(),
postedAtMillis = prefs.getLong(KEY_LAST_AT, 0L),
)
}
set(value) {
prefs.edit().apply {
if (value == null) {
remove(KEY_LAST_SLUG); remove(KEY_LAST_URL)
remove(KEY_LAST_CAPTION); remove(KEY_LAST_AT)
} else {
putString(KEY_LAST_SLUG, value.slug)
putString(KEY_LAST_URL, value.url)
putString(KEY_LAST_CAPTION, value.caption)
putLong(KEY_LAST_AT, value.postedAtMillis)
}
}.apply()
}
// ── Keystore sealing ────────────────────────────────────────────────────
private fun secretKey(): SecretKey {
val keystore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
(keystore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)
?.let { return it.secretKey }
val generator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
ANDROID_KEYSTORE,
)
generator.init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.setUserAuthenticationRequired(false)
.build(),
)
return generator.generateKey()
}
private fun encrypt(plain: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, secretKey())
val ciphertext = cipher.doFinal(plain.toByteArray(Charsets.UTF_8))
return Base64.encodeToString(cipher.iv, Base64.NO_WRAP) +
SEPARATOR +
Base64.encodeToString(ciphertext, Base64.NO_WRAP)
}
/**
* Returns "" rather than throwing when the sealed blob cannot be opened.
* That happens legitimately — a Keystore key can be invalidated by a device
* restore or a lock-screen change — and the right response is to prompt for
* the password again, not to crash on launch.
*/
private fun decrypt(stored: String): String = try {
val parts = stored.split(SEPARATOR, limit = 2)
if (parts.size != 2) {
""
} else {
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
val ciphertext = Base64.decode(parts[1], Base64.NO_WRAP)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(GCM_TAG_BITS, iv))
String(cipher.doFinal(ciphertext), Charsets.UTF_8)
}
} catch (_: Exception) {
""
}
companion object {
private const val PREFS = "photogallery_settings"
private const val KEY_BASE_URL = "base_url"
private const val KEY_USERNAME = "username"
private const val KEY_PASSWORD = "password_sealed"
private const val KEY_LAST_SLUG = "last_slug"
private const val KEY_LAST_URL = "last_url"
private const val KEY_LAST_CAPTION = "last_caption"
private const val KEY_LAST_AT = "last_at"
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
private const val KEY_ALIAS = "photogallery_credentials"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val GCM_TAG_BITS = 128
private const val SEPARATOR = ":"
/**
* Returns an error message, or null when the URL is usable.
*
* https is required, not preferred. Basic auth puts the password in a
* trivially reversible header, so a plain-http base URL would hand the
* gallery password to anything on the path. The gallery's nginx vhost
* sets forceSSL anyway.
*/
fun validateBaseUrl(raw: String): String? {
val trimmed = raw.trim()
if (trimmed.isEmpty()) return "Base URL is required"
val url = trimmed.toHttpUrlOrNull() ?: return "Not a valid URL"
if (url.scheme != "https") {
return "Must be https — plain http sends your password in the clear"
}
return null
}
}
}
data class LastPost(
val slug: String,
val url: String,
val caption: String,
val postedAtMillis: Long,
)
@@ -0,0 +1,308 @@
package ws.inflo.photogallery.image
import android.content.Context
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.graphics.Matrix
import android.net.Uri
import androidx.exifinterface.media.ExifInterface
import java.io.ByteArrayOutputStream
import java.io.File
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
/**
* Turns a picked or shared image into bytes the gallery's upload API accepts.
*
* Three things have to 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 checks the extension
* and rejects anything else, HEIC included), and under nginx's 20M body cap.
*
* Where possible this is done without re-encoding, so the published file keeps
* the original sensor pixels exactly.
*/
object ImagePrep {
/** nginx is configured with client_max_body_size 20M; leave headroom. */
private const val MAX_UPLOAD_BYTES = 18L * 1024 * 1024
/**
* Cap on decoded pixels for the paths that must decode. A bitmap is 4 bytes
* per pixel, so 40MP is a ~160MB allocation — survivable with largeHeap,
* which is why the manifest asks for it. Beyond that we sample down rather
* than die.
*/
private const val MAX_DECODE_PIXELS = 40_000_000L
private const val JPEG_QUALITY = 92
/** Quality ladder walked when the encoded result overshoots the cap. */
private val QUALITY_LADDER = intArrayOf(92, 85, 75)
data class Prepared(
val file: File,
val extension: String,
/** Non-null when the app had to depart from a byte-exact copy. */
val note: String?,
)
/**
* EXIF capture date, for prefilling the date field. Read before any
* stripping happens — the same metadata block carries both this and the
* GPS tags being removed.
*/
fun captureDate(context: Context, uri: Uri): LocalDate? = try {
context.contentResolver.openInputStream(uri)?.use { stream ->
val exif = ExifInterface(stream)
val raw = exif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL)
?: exif.getAttribute(ExifInterface.TAG_DATETIME)
raw?.let { parseExifDate(it) }
}
} catch (_: Exception) {
null
}
private fun parseExifDate(raw: String): LocalDate? = try {
LocalDate.parse(raw.trim().substringBefore(' '), EXIF_DATE)
} catch (_: DateTimeParseException) {
null
}
private val EXIF_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy:MM:dd")
/**
* Produces the file to upload, written into [cacheDir].
*
* JPEG and PNG take a lossless route: whole metadata segments are dropped
* without touching the compressed image data. Anything else — HEIC being
* the common case — has to be decoded and re-encoded as JPEG.
*/
fun prepare(context: Context, uri: Uri, cacheDir: File, baseName: String): Prepared {
val source = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: error("Could not read the selected image")
val orientation = readOrientation(context, uri)
val notes = mutableListOf<String>()
// A rotation flag cannot survive metadata stripping, and the gallery's
// thumbnailer ignores EXIF orientation regardless. Bake the rotation
// into the pixels instead, which costs a re-encode.
val mustRotate = orientation != ExifInterface.ORIENTATION_NORMAL &&
orientation != ExifInterface.ORIENTATION_UNDEFINED
val lossless: Pair<ByteArray, String>? = when {
mustRotate -> null
isJpeg(source) -> stripJpegSegments(source)?.let { it to "jpg" }
isPng(source) -> stripPngChunks(source)?.let { it to "png" }
else -> null
}
var bytes: ByteArray
var extension: String
if (lossless != null) {
bytes = lossless.first
extension = lossless.second
} else {
if (mustRotate) notes += "rotated to match its EXIF orientation"
else if (!isJpeg(source) && !isPng(source)) notes += "converted to JPEG"
bytes = encodeJpeg(decodeOriented(context, uri, orientation), JPEG_QUALITY)
extension = "jpg"
}
// Size valve. Only trips on genuinely huge originals; stepping quality
// is tried before touching resolution.
if (bytes.size > MAX_UPLOAD_BYTES) {
val bitmap = decodeOriented(context, uri, orientation)
var shrunk: ByteArray? = null
for (quality in QUALITY_LADDER) {
val attempt = encodeJpeg(bitmap, quality)
if (attempt.size <= MAX_UPLOAD_BYTES) {
shrunk = attempt
notes += "re-encoded at quality $quality to fit the 20M upload limit"
break
}
}
if (shrunk == null) {
var scaled = bitmap
var factor = 1.0
while (factor > 0.2) {
factor *= 0.75
scaled = Bitmap.createScaledBitmap(
bitmap,
(bitmap.width * factor).toInt().coerceAtLeast(1),
(bitmap.height * factor).toInt().coerceAtLeast(1),
true,
)
val attempt = encodeJpeg(scaled, QUALITY_LADDER.last())
if (attempt.size <= MAX_UPLOAD_BYTES) {
shrunk = attempt
notes += "scaled to ${scaled.width}×${scaled.height} to fit the 20M upload limit"
break
}
}
}
bytes = shrunk ?: error("Image is too large to upload even after downscaling")
extension = "jpg"
}
val file = File(cacheDir, "$baseName.$extension")
file.writeBytes(bytes)
return Prepared(
file = file,
extension = extension,
note = notes.takeIf { it.isNotEmpty() }?.joinToString("; "),
)
}
/**
* Small, correctly-rotated bitmap for the on-screen preview. Decoded at a
* sample size rather than full resolution — the preview is a thumbnail and
* has no business allocating a 40MP bitmap.
*/
fun preview(context: Context, uri: Uri, maxDimension: Int = 1024): Bitmap? = try {
val source = ImageDecoder.createSource(context.contentResolver, uri)
val bitmap = ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
val longest = maxOf(info.size.width, info.size.height)
var sample = 1
while (longest / (sample * 2) >= maxDimension) sample *= 2
if (sample > 1) decoder.setTargetSampleSize(sample)
}
applyOrientation(bitmap, readOrientation(context, uri))
} catch (_: Exception) {
null
}
private fun readOrientation(context: Context, uri: Uri): Int = try {
context.contentResolver.openInputStream(uri)?.use { stream ->
ExifInterface(stream).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_UNDEFINED,
)
} ?: ExifInterface.ORIENTATION_UNDEFINED
} catch (_: Exception) {
ExifInterface.ORIENTATION_UNDEFINED
}
private fun decodeOriented(context: Context, uri: Uri, orientation: Int): Bitmap {
val source = ImageDecoder.createSource(context.contentResolver, uri)
val bitmap = ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
decoder.isMutableRequired = false
val pixels = info.size.width.toLong() * info.size.height.toLong()
if (pixels > MAX_DECODE_PIXELS) {
var sample = 2
while (pixels / (sample.toLong() * sample) > MAX_DECODE_PIXELS) sample *= 2
decoder.setTargetSampleSize(sample)
}
}
return applyOrientation(bitmap, orientation)
}
private fun applyOrientation(bitmap: Bitmap, orientation: Int): Bitmap {
val matrix = Matrix()
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
ExifInterface.ORIENTATION_TRANSPOSE -> { matrix.postRotate(90f); matrix.postScale(-1f, 1f) }
ExifInterface.ORIENTATION_TRANSVERSE -> { matrix.postRotate(270f); matrix.postScale(-1f, 1f) }
else -> return bitmap
}
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
private fun encodeJpeg(bitmap: Bitmap, quality: Int): ByteArray =
ByteArrayOutputStream(bitmap.width * bitmap.height / 4).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, out)
out.toByteArray()
}
private fun isJpeg(b: ByteArray) =
b.size > 3 && b.u8(0) == 0xFF && b.u8(1) == 0xD8 && b.u8(2) == 0xFF
private fun isPng(b: ByteArray) =
b.size > 8 && b.u8(0) == 0x89 && b.u8(1) == 0x50 && b.u8(2) == 0x4E && b.u8(3) == 0x47
private fun ByteArray.u8(i: Int) = this[i].toInt() and 0xFF
/**
* 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 null if the marker structure does not parse, in which case the
* caller falls back to a decode-and-re-encode that is guaranteed to strip.
*/
private fun stripJpegSegments(src: ByteArray): ByteArray? {
val out = ByteArrayOutputStream(src.size)
out.write(0xFF); out.write(0xD8)
var i = 2
while (i + 1 < src.size) {
if (src.u8(i) != 0xFF) return null
val marker = src.u8(i + 1)
// Standalone markers: no length field, no payload.
if (marker == 0x01 || marker in 0xD0..0xD7) {
out.write(0xFF); out.write(marker)
i += 2
continue
}
// Start of scan: entropy-coded data runs to the end of the file.
if (marker == 0xDA) {
out.write(src, i, src.size - i)
return out.toByteArray()
}
if (i + 3 >= src.size) return null
val length = (src.u8(i + 2) shl 8) or src.u8(i + 3)
if (length < 2 || i + 2 + length > src.size) return null
val isExifOrXmp = marker == 0xE1
val isComment = marker == 0xFE
if (!isExifOrXmp && !isComment) out.write(src, i, 2 + length)
i += 2 + length
}
return null
}
/**
* Drops the metadata chunks and keeps everything else, including the
* critical IHDR/PLTE/IDAT/IEND run and colour chunks like iCCP and sRGB.
* Whole chunks go, so no CRC needs recomputing.
*/
private fun stripPngChunks(src: ByteArray): ByteArray? {
val drop = setOf("eXIf", "tEXt", "zTXt", "iTXt", "tIME")
val out = ByteArrayOutputStream(src.size)
out.write(src, 0, 8)
var i = 8
while (i + 8 <= src.size) {
val length = ((src.u8(i).toLong() shl 24) or (src.u8(i + 1).toLong() shl 16) or
(src.u8(i + 2).toLong() shl 8) or src.u8(i + 3).toLong()).toInt()
if (length < 0) return null
val type = String(src, i + 4, 4, Charsets.US_ASCII)
val total = 12 + length
if (i + total > src.size) return null
if (type !in drop) out.write(src, i, total)
i += total
if (type == "IEND") return out.toByteArray()
}
return null
}
}
@@ -0,0 +1,140 @@
package ws.inflo.photogallery.net
import okhttp3.Credentials
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.Response
import java.io.File
import java.io.IOException
import java.util.concurrent.TimeUnit
sealed interface UploadOutcome {
data class Success(val slug: String, val url: String) : UploadOutcome
/** Wrong credentials. Retrying will not help. */
data object AuthFailed : UploadOutcome
/** Server said no for a reason a retry will not change. */
data class Rejected(val code: Int, val message: String) : UploadOutcome
/** Network trouble or a 5xx. Worth retrying. */
data class Transient(val message: String) : UploadOutcome
}
/**
* Talks to the gallery's POST /upload endpoint.
*
* Behind nginx that endpoint sits under HTTP Basic auth. The credentials go
* out on every request via an interceptor rather than through OkHttp's
* [okhttp3.Authenticator], on purpose: an Authenticator only reacts to a 401
* and then replays the request, and a multipart body streamed off disk is not
* reliably replayable — the retry can arrive with an empty body.
*/
class UploadClient(
baseUrl: String,
username: String,
password: String,
) {
private val base = baseUrl.trimEnd('/')
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.writeTimeout(5, TimeUnit.MINUTES) // a full-resolution photo on mobile data
.readTimeout(60, TimeUnit.SECONDS)
.addInterceptor(BasicAuthInterceptor(username, password))
.build()
/**
* Uploads [file] as the given [fileName].
*
* The filename matters more than it looks: server.go derives the photo's
* slug from it, so passing the same name on a retry overwrites the same
* post instead of creating a duplicate.
*/
fun upload(
file: File,
fileName: String,
caption: String,
date: String,
): UploadOutcome {
val mediaType = if (fileName.endsWith(".png", ignoreCase = true)) {
"image/png".toMediaType()
} else {
"image/jpeg".toMediaType()
}
val body = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("image", fileName, file.asRequestBody(mediaType))
.addFormDataPart("caption", caption)
.addFormDataPart("date", date)
.build()
val request = Request.Builder()
.url("$base/upload")
.post(body)
.build()
return try {
client.newCall(request).execute().use { response ->
val text = response.body?.string().orEmpty()
when {
response.isSuccessful -> {
val slug = parseSlug(text, fileName.substringBeforeLast('.'))
UploadOutcome.Success(slug = slug, url = "$base/photo/$slug/")
}
response.code == 401 || response.code == 403 -> UploadOutcome.AuthFailed
response.code in 500..599 ->
UploadOutcome.Transient("Server error ${response.code}")
else ->
UploadOutcome.Rejected(response.code, text.trim().ifEmpty { "Upload rejected" })
}
}
} catch (e: IOException) {
UploadOutcome.Transient(e.message ?: "Network error")
}
}
/**
* Credential check. /health is not usable for this — the nginx vhost only
* proxies location /upload, so /health falls through to the static root and
* 404s. GET /upload returns the upload page through the same Basic-auth
* gate the POST uses, which is exactly what needs testing.
*/
fun testConnection(): String? {
val request = Request.Builder().url("$base/upload").get().build()
return try {
client.newCall(request).execute().use { response ->
when {
response.isSuccessful -> null
response.code == 401 || response.code == 403 -> "Wrong username or password"
else -> "Server returned ${response.code}"
}
}
} catch (e: IOException) {
e.message ?: "Could not reach the server"
}
}
/** Server replies "uploaded <slug>.<ext>"; recover the slug from that. */
private fun parseSlug(responseBody: String, fallback: String): String {
val name = responseBody.trim().removePrefix("uploaded ").trim()
val slug = name.substringBeforeLast('.')
return slug.ifEmpty { fallback }
}
private class BasicAuthInterceptor(username: String, password: String) : Interceptor {
private val header = Credentials.basic(username, password)
override fun intercept(chain: Interceptor.Chain): Response =
chain.proceed(
chain.request().newBuilder()
.header("Authorization", header)
.build(),
)
}
}
@@ -0,0 +1,225 @@
package ws.inflo.photogallery.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
private val DISPLAY_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMM yyyy")
@Composable
fun PostScreen(
state: PostUiState,
onPickImage: () -> Unit,
onCaptionChange: (String) -> Unit,
onDateChange: (LocalDate) -> Unit,
onSubmit: () -> Unit,
onReset: () -> Unit,
onOpenSettings: () -> Unit,
onOpenUrl: (String) -> Unit,
) {
var datePickerOpen by remember { mutableStateOf(false) }
Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Post a photo", style = MaterialTheme.typography.headlineSmall, color = GruvGreen)
TextButton(onClick = onOpenSettings) { Text("Settings") }
}
Spacer(Modifier.height(12.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.background(GruvBg1),
contentAlignment = Alignment.Center,
) {
val preview = state.preview
when {
preview != null -> Image(
bitmap = preview.asImageBitmap(),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
)
state.imageUri != null -> CircularProgressIndicator(color = GruvAqua)
else -> Text("No photo selected", color = GruvFg4)
}
}
Spacer(Modifier.height(12.dp))
OutlinedButton(onClick = onPickImage, modifier = Modifier.fillMaxWidth()) {
Text(if (state.imageUri == null) "Choose photo" else "Choose a different photo")
}
Spacer(Modifier.height(16.dp))
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(),
) {
Text("Date: ${state.date.format(DISPLAY_DATE)}")
}
Spacer(Modifier.height(20.dp))
Button(
onClick = onSubmit,
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)
}
Spacer(Modifier.height(8.dp))
Row {
TextButton(onClick = { onOpenUrl(status.url) }) { Text("Open") }
TextButton(onClick = onReset) { Text("Post another") }
}
}
else -> Unit
}
state.lastPost?.let { last ->
Spacer(Modifier.height(24.dp))
HorizontalDivider(color = GruvBg2)
Spacer(Modifier.height(12.dp))
Text("Last post", color = GruvFg4, style = MaterialTheme.typography.labelMedium)
Spacer(Modifier.height(4.dp))
Text(
last.caption.ifBlank { last.slug },
color = GruvFg,
style = MaterialTheme.typography.bodyMedium,
)
Text(
last.slug,
color = GruvFg4,
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.bodySmall,
)
TextButton(onClick = { onOpenUrl(last.url) }) { Text("Open") }
}
}
}
if (datePickerOpen) {
DatePickerModal(
initial = state.date,
onDismiss = { datePickerOpen = false },
onConfirm = {
onDateChange(it)
datePickerOpen = false
},
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DatePickerModal(
initial: LocalDate,
onDismiss: () -> Unit,
onConfirm: (LocalDate) -> Unit,
) {
val state = rememberDatePickerState(
initialSelectedDateMillis = initial
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli(),
)
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(
onClick = {
val millis = state.selectedDateMillis
if (millis != null) {
onConfirm(
Instant.ofEpochMilli(millis)
.atZone(ZoneId.systemDefault())
.toLocalDate(),
)
} else {
onDismiss()
}
},
) { Text("OK") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
) {
DatePicker(state = state)
}
}
@@ -0,0 +1,129 @@
package ws.inflo.photogallery.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
@Composable
fun SettingsScreen(
state: SettingsUiState,
onBaseUrlChange: (String) -> Unit,
onUsernameChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onSave: () -> Unit,
onBack: (() -> Unit)?,
) {
var passwordVisible by remember { mutableStateOf(false) }
Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
) {
Text("Settings", style = MaterialTheme.typography.headlineSmall, color = GruvGreen)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = state.baseUrl,
onValueChange = onBaseUrlChange,
label = { Text("Gallery URL") },
placeholder = { Text("https://photos.example.com") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(4.dp))
Text(
"Must be https. Basic auth puts the password in a trivially " +
"reversible header, so plain http would expose it.",
color = GruvFg4,
style = MaterialTheme.typography.bodySmall,
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = state.username,
onValueChange = onUsernameChange,
label = { Text("Username") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = state.password,
onValueChange = onPasswordChange,
label = { Text("Password") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
visualTransformation = if (passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
trailingIcon = {
TextButton(onClick = { passwordVisible = !passwordVisible }) {
Text(if (passwordVisible) "Hide" else "Show")
}
},
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(4.dp))
Text(
"Stored sealed with a key held in the Android Keystore, never " +
"in the clear.",
color = GruvFg4,
style = MaterialTheme.typography.bodySmall,
)
Spacer(Modifier.height(24.dp))
Button(
onClick = onSave,
enabled = !state.testing,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (state.testing) "Testing…" else "Save and test connection")
}
state.message?.let {
Spacer(Modifier.height(12.dp))
Text(it, color = if (state.messageIsError) GruvRed else GruvAqua)
}
onBack?.let {
Spacer(Modifier.height(16.dp))
TextButton(onClick = it) { Text("← Back") }
}
}
}
}
@@ -0,0 +1,40 @@
package ws.inflo.photogallery.ui
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
// Gruvbox dark hard, same values as static/style.css in the gallery.
val GruvBg = Color(0xFF1D2021)
val GruvBg0 = Color(0xFF282828)
val GruvBg1 = Color(0xFF3C3836)
val GruvBg2 = Color(0xFF504945)
val GruvFg = Color(0xFFEBDBB2)
val GruvFg3 = Color(0xFFBDAE93)
val GruvFg4 = Color(0xFFA89984)
val GruvAqua = Color(0xFF8EC07C)
val GruvGreen = Color(0xFF98971A)
val GruvBlue = Color(0xFF458588)
val GruvRed = Color(0xFFFB4934)
private val GruvboxDark = darkColorScheme(
primary = GruvAqua,
onPrimary = GruvBg,
secondary = GruvBlue,
onSecondary = GruvFg,
background = GruvBg,
onBackground = GruvFg,
surface = GruvBg0,
onSurface = GruvFg,
surfaceVariant = GruvBg1,
onSurfaceVariant = GruvFg3,
outline = GruvBg2,
error = GruvRed,
onError = GruvBg,
)
@Composable
fun PhotoGalleryTheme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = GruvboxDark, content = content)
}
@@ -0,0 +1,218 @@
package ws.inflo.photogallery.ui
import android.app.Application
import android.graphics.Bitmap
import android.net.Uri
import androidx.core.net.toUri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.work.WorkInfo
import androidx.work.WorkManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ws.inflo.photogallery.data.LastPost
import ws.inflo.photogallery.data.SettingsStore
import ws.inflo.photogallery.image.ImagePrep
import ws.inflo.photogallery.net.UploadClient
import ws.inflo.photogallery.work.UploadQueue
import ws.inflo.photogallery.work.UploadWorker
import java.io.File
import java.time.LocalDate
import java.util.UUID
sealed interface PostStatus {
data object Idle : PostStatus
data object Uploading : PostStatus
data class Success(val url: String, val note: String?) : PostStatus
data class Error(val message: String) : PostStatus
}
data class PostUiState(
val imageUri: Uri? = null,
val preview: Bitmap? = null,
val caption: String = "",
val date: LocalDate = LocalDate.now(),
val status: PostStatus = PostStatus.Idle,
val lastPost: LastPost? = null,
)
data class SettingsUiState(
val baseUrl: String = "",
val username: String = "",
val password: String = "",
val testing: Boolean = false,
val message: String? = null,
val messageIsError: Boolean = false,
)
class UploadViewModel(app: Application) : AndroidViewModel(app) {
private val settings = SettingsStore(app)
var post by mutableStateOf(PostUiState(lastPost = settings.lastPost))
private set
var config by mutableStateOf(
SettingsUiState(
baseUrl = settings.baseUrl,
username = settings.username,
password = settings.password,
),
)
private set
val isConfigured: Boolean get() = settings.isConfigured
// ── Post screen ─────────────────────────────────────────────────────────
fun selectImage(uri: Uri) {
post = post.copy(imageUri = uri, status = PostStatus.Idle, preview = null)
viewModelScope.launch {
val context = getApplication<Application>()
val preview = withContext(Dispatchers.IO) { ImagePrep.preview(context, uri) }
val captured = withContext(Dispatchers.IO) { ImagePrep.captureDate(context, uri) }
post = post.copy(preview = preview, date = captured ?: LocalDate.now())
}
}
fun setCaption(value: String) {
post = post.copy(caption = value)
}
fun setDate(value: LocalDate) {
post = post.copy(date = value)
}
fun clear() {
post = PostUiState(lastPost = settings.lastPost)
}
fun submit() {
val uri = post.imageUri ?: return
if (!settings.isConfigured) {
post = post.copy(status = PostStatus.Error("Set the server URL and credentials first"))
return
}
post = post.copy(status = PostStatus.Uploading)
viewModelScope.launch {
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) {
post = post.copy(status = PostStatus.Error("Could not read the selected image"))
return@launch
}
val id = UploadQueue.enqueue(
context = context,
uri = Uri.fromFile(staged),
caption = post.caption,
date = post.date,
)
observe(id)
}
}
private fun stage(uri: Uri): File? {
return try {
val context = getApplication<Application>()
val target = File(context.cacheDir, "staged-${UUID.randomUUID()}")
val copied = context.contentResolver.openInputStream(uri)?.use { input ->
target.outputStream().use { output -> input.copyTo(output) }
}
if (copied == null) null else target
} catch (_: Exception) {
null
}
}
private fun observe(id: UUID) {
viewModelScope.launch {
WorkManager.getInstance(getApplication()).getWorkInfoByIdFlow(id).collect { info ->
if (info == null) return@collect
when (info.state) {
WorkInfo.State.SUCCEEDED -> {
post = post.copy(
status = PostStatus.Success(
url = info.outputData.getString(UploadWorker.KEY_RESULT_URL).orEmpty(),
note = info.outputData.getString(UploadWorker.KEY_RESULT_NOTE),
),
lastPost = settings.lastPost,
)
}
WorkInfo.State.FAILED -> {
post = post.copy(
status = PostStatus.Error(
info.outputData.getString(UploadWorker.KEY_RESULT_ERROR)
?: "Upload failed",
),
)
}
WorkInfo.State.CANCELLED ->
post = post.copy(status = PostStatus.Error("Upload cancelled"))
else -> Unit
}
}
}
}
// ── Settings screen ─────────────────────────────────────────────────────
fun setBaseUrl(value: String) {
config = config.copy(baseUrl = value, message = null)
}
fun setUsername(value: String) {
config = config.copy(username = value, message = null)
}
fun setPassword(value: String) {
config = config.copy(password = value, message = null)
}
fun saveAndTest() {
val urlError = SettingsStore.validateBaseUrl(config.baseUrl)
if (urlError != null) {
config = config.copy(message = urlError, messageIsError = true)
return
}
if (config.username.isBlank() || config.password.isBlank()) {
config = config.copy(
message = "Username and password are required",
messageIsError = true,
)
return
}
settings.baseUrl = config.baseUrl
settings.username = config.username
settings.password = config.password
config = config.copy(testing = true, message = null)
viewModelScope.launch {
val error = withContext(Dispatchers.IO) {
UploadClient(settings.baseUrl, settings.username, settings.password)
.testConnection()
}
config = config.copy(
testing = false,
message = error ?: "Saved — server reachable and credentials accepted",
messageIsError = error != null,
)
}
}
}
@@ -0,0 +1,64 @@
package ws.inflo.photogallery.work
import android.content.Context
import android.net.Uri
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.UUID
object UploadQueue {
/**
* Slug format. Uses the enqueue wall-clock time rather than the photo's
* capture time: a burst of frames shot in the same second would collide on
* capture time, whereas posts are made one at a time. The real capture date
* still travels in the `date` field and lands in the .toml sidecar.
*/
private val SLUG_FORMAT: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd-HHmmss")
private val ISO_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
fun enqueue(
context: Context,
uri: Uri,
caption: String,
date: LocalDate,
): UUID {
val slug = LocalDateTime.now().format(SLUG_FORMAT)
val request = OneTimeWorkRequestBuilder<UploadWorker>()
.setInputData(
Data.Builder()
.putString(UploadWorker.KEY_URI, uri.toString())
.putString(UploadWorker.KEY_SLUG, slug)
.putString(UploadWorker.KEY_CAPTION, caption)
.putString(UploadWorker.KEY_DATE, date.format(ISO_DATE))
.build(),
)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build(),
)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
// Unique per slug, so a double tap cannot enqueue the same post twice.
WorkManager.getInstance(context).enqueueUniqueWork(
"upload-$slug",
ExistingWorkPolicy.KEEP,
request,
)
return request.id
}
}
@@ -0,0 +1,160 @@
package ws.inflo.photogallery.work
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.pm.ServiceInfo
import android.os.Build
import androidx.core.net.toUri
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ws.inflo.photogallery.R
import ws.inflo.photogallery.data.LastPost
import ws.inflo.photogallery.data.SettingsStore
import ws.inflo.photogallery.image.ImagePrep
import ws.inflo.photogallery.net.UploadClient
import ws.inflo.photogallery.net.UploadOutcome
/**
* Performs one upload, surviving the app being backgrounded or killed.
*
* Retries are safe by construction. The slug is generated once when the work
* is enqueued and carried in the input data, and server.go derives the stored
* filename from the uploaded filename — so a retry after an ambiguous timeout
* overwrites the same post rather than creating a second one. That is the only
* reason this works without an idempotency key on the server.
*/
class UploadWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val uriString = inputData.getString(KEY_URI) ?: return@withContext Result.failure()
val slug = inputData.getString(KEY_SLUG) ?: return@withContext Result.failure()
val caption = inputData.getString(KEY_CAPTION).orEmpty()
val date = inputData.getString(KEY_DATE).orEmpty()
val settings = SettingsStore(applicationContext)
if (!settings.isConfigured) {
return@withContext failWith("Set the server URL and credentials first")
}
val prepared = try {
ImagePrep.prepare(
context = applicationContext,
uri = uriString.toUri(),
cacheDir = applicationContext.cacheDir,
baseName = slug,
)
} catch (e: Exception) {
return@withContext failWith(e.message ?: "Could not read the image")
}
val client = UploadClient(
baseUrl = settings.baseUrl,
username = settings.username,
password = settings.password,
)
val outcome = client.upload(
file = prepared.file,
fileName = prepared.file.name,
caption = caption,
date = date,
)
// Keep the staged and prepared copies only while a retry might still
// need them.
if (outcome !is UploadOutcome.Transient) {
prepared.file.delete()
deleteStagedSource(uriString)
}
when (outcome) {
is UploadOutcome.Success -> {
settings.lastPost = LastPost(
slug = outcome.slug,
url = outcome.url,
caption = caption,
postedAtMillis = System.currentTimeMillis(),
)
Result.success(
androidx.work.Data.Builder()
.putString(KEY_RESULT_SLUG, outcome.slug)
.putString(KEY_RESULT_URL, outcome.url)
.putString(KEY_RESULT_NOTE, prepared.note)
.build(),
)
}
UploadOutcome.AuthFailed -> failWith("Wrong username or password")
is UploadOutcome.Rejected -> failWith(outcome.message)
is UploadOutcome.Transient ->
if (runAttemptCount < MAX_ATTEMPTS) Result.retry()
else failWith(outcome.message)
}
}
/** Removes the copy the UI staged in our cache, never anything outside it. */
private fun deleteStagedSource(uriString: String) {
val uri = uriString.toUri()
if (uri.scheme != "file") return
val file = java.io.File(uri.path ?: return)
if (file.parentFile == applicationContext.cacheDir) file.delete()
}
private fun failWith(message: String) = Result.failure(
androidx.work.Data.Builder().putString(KEY_RESULT_ERROR, message).build(),
)
override suspend fun getForegroundInfo(): ForegroundInfo {
val manager = applicationContext
.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(CHANNEL_ID) == null) {
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
applicationContext.getString(R.string.upload_channel_name),
NotificationManager.IMPORTANCE_LOW,
),
)
}
val notification: Notification = Notification.Builder(applicationContext, CHANNEL_ID)
.setContentTitle(applicationContext.getString(R.string.app_name))
.setContentText("Posting photo…")
.setSmallIcon(android.R.drawable.stat_sys_upload)
.setOngoing(true)
.build()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
ForegroundInfo(NOTIFICATION_ID, notification)
}
}
companion object {
const val KEY_URI = "uri"
const val KEY_SLUG = "slug"
const val KEY_CAPTION = "caption"
const val KEY_DATE = "date"
const val KEY_RESULT_SLUG = "result_slug"
const val KEY_RESULT_URL = "result_url"
const val KEY_RESULT_NOTE = "result_note"
const val KEY_RESULT_ERROR = "result_error"
private const val MAX_ATTEMPTS = 5
private const val CHANNEL_ID = "uploads"
private const val NOTIFICATION_ID = 4711
}
}
@@ -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>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- minSdk is 28, so every device resolves the v26 adaptive icon and no
density-bucket PNG fallbacks are needed. -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Gruvbox dark hard, matching static/style.css in the gallery itself. -->
<resources>
<color name="gruvbox_bg">#1d2021</color>
<color name="gruvbox_bg0">#282828</color>
<color name="gruvbox_fg">#ebdbb2</color>
<color name="gruvbox_aqua">#8ec07c</color>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Gallery Upload</string>
<string name="upload_channel_name">Uploads</string>
</resources>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Platform parent rather than Theme.Material3.* so the app does not need
the View-based material-components dependency. Compose supplies the
real theming; this only prevents a white flash before it composes. -->
<style name="Theme.PhotoGalleryUploader" parent="@android:style/Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/gruvbox_bg</item>
<item name="android:statusBarColor">@color/gruvbox_bg</item>
<item name="android:navigationBarColor">@color/gruvbox_bg</item>
</style>
</resources>
+5
View File
@@ -0,0 +1,5 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}
+139
View File
@@ -0,0 +1,139 @@
{
"nodes": {
"android-nixpkgs": {
"inputs": {
"devshell": "devshell",
"flake-utils": "flake-utils",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1786567347,
"narHash": "sha256-I52DnM27HeCMJMgCEyNb5wz8prz6GOjzIxJnvF0ezD0=",
"owner": "tadfisher",
"repo": "android-nixpkgs",
"rev": "ccdf76eeb9011ad795cca84d1cc4bd9baa9a7329",
"type": "github"
},
"original": {
"owner": "tadfisher",
"ref": "stable",
"repo": "android-nixpkgs",
"type": "github"
}
},
"devshell": {
"inputs": {
"nixpkgs": [
"android-nixpkgs",
"nixpkgs"
]
},
"locked": {
"lastModified": 1768818222,
"narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=",
"owner": "numtide",
"repo": "devshell",
"rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "devshell",
"type": "github"
}
},
"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"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"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": 1786599213,
"narHash": "sha256-yNJd40f11EzXBjSByCB7IPpeFFAdeoSKKM67dGkfFoU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "0e251e24a4f24e036a084b6b4b2d2491af4167f4",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"android-nixpkgs": "android-nixpkgs",
"flake-utils": "flake-utils_2",
"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"
}
},
"systems_2": {
"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
}
+60
View File
@@ -0,0 +1,60 @@
{
description = "photogallery Android uploader dev shell";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
# Reproducible Android SDK across NixOS + macOS without the nixpkgs
# androidenv read-only-SDK-root quirks.
android-nixpkgs = {
url = "github:tadfisher/android-nixpkgs/stable";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, flake-utils, android-nixpkgs }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
config.allowUnfree = true; # Android SDK is unfree
};
buildToolsVersion = "36.0.0";
# SDK components for the Compose app. Pinned to the exact versions
# the Gradle config requests — adding extras only inflates the
# closure. When AGP bumps any of these, the build error names the
# exact missing version: look it up in `android-nixpkgs` and replace
# the line. No NDK or CMake: there is no native code in this app.
androidSdk = android-nixpkgs.sdk.${system} (sdkPkgs: with sdkPkgs; [
cmdline-tools-latest # sdkmanager, avdmanager
platform-tools # adb, for sideloading to the phone
platforms-android-36 # compileSdk / targetSdk
build-tools-36-0-0 # aapt2, d8, zipalign
build-tools-35-0-0 # AGP 8.x resolves this one too, and the
# nix store is read-only so it cannot
# download it itself
]);
in {
devShells.default = pkgs.mkShell {
packages = [
pkgs.jdk17 # AGP 8.x toolchain
pkgs.gradle # bootstraps ./gradlew, then the wrapper takes over
androidSdk
];
shellHook = ''
export ANDROID_HOME="${androidSdk}/share/android-sdk"
export ANDROID_SDK_ROOT="$ANDROID_HOME"
export JAVA_HOME="${pkgs.jdk17.home}"
# AGP downloads aapt2 from Maven by default. The Maven binary is
# generic-linux ELF and won't run on NixOS. Force Gradle to use the
# patchelfed aapt2 shipped in our pinned build-tools instead.
export GRADLE_OPTS="-Dorg.gradle.project.android.aapt2FromMavenOverride=$ANDROID_HOME/build-tools/${buildToolsVersion}/aapt2"
'';
};
});
}
+8
View File
@@ -0,0 +1,8 @@
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g
org.gradle.parallel=true
org.gradle.caching=true
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
+30
View File
@@ -0,0 +1,30 @@
[versions]
agp = "8.13.0"
kotlin = "2.2.20"
coreKtx = "1.17.0"
lifecycle = "2.9.4"
activityCompose = "1.11.0"
composeBom = "2025.09.00"
work = "2.10.5"
okhttp = "4.12.0"
exifinterface = "1.4.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "exifinterface" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "photogallery-uploader"
include(":app")
+242 -26
View File
@@ -12,6 +12,7 @@ import (
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -23,6 +24,7 @@ import (
const (
thumbSize = 600
mediumMaxSize = 1600
defaultPageSize = 60
)
// PhotoMeta is the structure of a .toml sidecar file.
@@ -41,6 +43,14 @@ type Photo struct {
Date time.Time
}
// DayGroup is a run of photos sharing the same calendar day, for the timeline.
type DayGroup struct {
day string // "2006-01-02", grouping key (unexported: not for templates)
Label string // "14 June 2026"
Count int
Photos []Photo
}
type Generator struct {
contentDir string
outputDir string
@@ -52,12 +62,16 @@ type Generator struct {
fontsURL string
serifFamily string
mlFamily string
pageSize int
mu sync.Mutex
tmpl *template.Template
}
func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, fontsURL, serifFamily, mlFamily string) *Generator {
func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, fontsURL, serifFamily, mlFamily string, pageSize int) *Generator {
tmpl := template.Must(template.ParseFS(assets, "templates/*.html"))
if pageSize < 1 {
pageSize = defaultPageSize
}
return &Generator{
contentDir: contentDir,
outputDir: outputDir,
@@ -69,6 +83,7 @@ func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, f
fontsURL: fontsURL,
serifFamily: serifFamily,
mlFamily: mlFamily,
pageSize: pageSize,
tmpl: tmpl,
}
}
@@ -91,7 +106,7 @@ func (g *Generator) Build() error {
return err
}
for _, d := range []string{"images", "thumbs", "medium", "photo", "static"} {
for _, d := range []string{"images", "thumbs", "medium", "photo", "grid", "day", "static"} {
if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil {
return err
}
@@ -103,11 +118,24 @@ func (g *Generator) Build() error {
}
}
if err := g.renderIndex(photos); err != nil {
if err := g.renderTimelinePages(photos); err != nil {
return err
}
for _, p := range photos {
if err := g.renderPhotoPage(p); err != nil {
if err := g.renderGridPages(photos); err != nil {
return err
}
if err := g.renderDayPages(photos); err != nil {
return err
}
for i := range photos {
var newer, older *Photo
if i > 0 {
newer = &photos[i-1]
}
if i+1 < len(photos) {
older = &photos[i+1]
}
if err := g.renderPhotoPage(photos[i], newer, older); err != nil {
return err
}
}
@@ -290,10 +318,212 @@ func generateMedium(src, dst string, maxDim int) error {
return jpeg.Encode(out, resized, &jpeg.Options{Quality: 88})
}
// renderIndex writes public/index.html.
func (g *Generator) renderIndex(photos []Photo) error {
return g.render("index.html", filepath.Join(g.outputDir, "index.html"), map[string]any{
"Photos": photos,
// renderGridPages writes the paginated square-grid view under public/grid/:
// grid/index.html and grid/page/N/index.html, chunking photos into pageSize
// slices for progressive pagination. The grid is the secondary view; the
// timeline is the site root.
func (g *Generator) renderGridPages(photos []Photo) error {
total := len(photos)
totalPages := (total + g.pageSize - 1) / g.pageSize
if totalPages == 0 {
totalPages = 1
}
gridDir := filepath.Join(g.outputDir, "grid")
for i := 0; i < totalPages; i++ {
start := i * g.pageSize
end := start + g.pageSize
if end > total {
end = total
}
slice := photos[start:end]
dst := filepath.Join(gridDir, "index.html")
if i > 0 {
dir := filepath.Join(gridDir, "page", strconv.Itoa(i+1))
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
dst = filepath.Join(dir, "index.html")
}
prev := ""
next := ""
switch i {
case 0:
// no prev
case 1:
prev = "/grid/"
default:
prev = "/grid/page/" + strconv.Itoa(i) + "/"
}
if i+1 < totalPages {
next = "/grid/page/" + strconv.Itoa(i+2) + "/"
}
data := map[string]any{
"Photos": slice,
"Total": total,
"Page": i + 1,
"TotalPages": totalPages,
"PrevURL": prev,
"NextURL": next,
"BaseURL": g.baseURL,
"Year": time.Now().Year(),
"Author": g.author,
"Handle": g.handle,
"Bio": g.bio,
"BioAlt": g.bioAlt,
"Initial": g.initial(),
"FontsURL": g.fontsURL,
"SerifFamily": g.serifFamily,
"MlFamily": g.mlFamily,
}
if err := g.render("grid.html", dst, data); err != nil {
return err
}
}
return nil
}
// renderPhotoPage writes public/photo/<slug>/index.html.
func (g *Generator) renderPhotoPage(p Photo, newer, older *Photo) error {
dir := filepath.Join(g.outputDir, "photo", p.Slug)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
return g.render("photo.html", filepath.Join(dir, "index.html"), map[string]any{
"Photo": p,
"Newer": newer,
"Older": older,
"BaseURL": g.baseURL,
"Year": time.Now().Year(),
"Author": g.author,
"Handle": g.handle,
"Initial": g.initial(),
"FontsURL": g.fontsURL,
"SerifFamily": g.serifFamily,
"MlFamily": g.mlFamily,
})
}
// groupByDay collapses photos (already sorted newest first) into per-day runs,
// preserving order. Two photos land in the same group iff their dates share a
// calendar day.
func groupByDay(photos []Photo) []DayGroup {
var groups []DayGroup
for _, p := range photos {
day := p.Date.Format("2006-01-02")
if n := len(groups); n > 0 && groups[n-1].day == day {
groups[n-1].Photos = append(groups[n-1].Photos, p)
groups[n-1].Count++
continue
}
groups = append(groups, DayGroup{
day: day,
Label: day, // ISO 2006-01-02
Count: 1,
Photos: []Photo{p},
})
}
return groups
}
// chunkDayGroups packs day groups into pages, filling each page until it holds
// at least target photos, then starting a new one. A single day is never split
// across a page boundary, so page sizes vary slightly around target. Always
// returns at least one page (empty when there are no photos) so the site root
// renders.
func chunkDayGroups(groups []DayGroup, target int) [][]DayGroup {
var pages [][]DayGroup
var cur []DayGroup
count := 0
for _, grp := range groups {
cur = append(cur, grp)
count += grp.Count
if count >= target {
pages = append(pages, cur)
cur, count = nil, 0
}
}
if len(cur) > 0 {
pages = append(pages, cur)
}
if len(pages) == 0 {
pages = [][]DayGroup{nil}
}
return pages
}
// renderTimelinePages writes the day-grouped timeline as the site root:
// index.html and page/N/index.html. Photos are grouped by day, then packed
// into pages of roughly pageSize without splitting a day across pages.
func (g *Generator) renderTimelinePages(photos []Photo) error {
pages := chunkDayGroups(groupByDay(photos), g.pageSize)
totalPages := len(pages)
for i, groups := range pages {
dst := filepath.Join(g.outputDir, "index.html")
if i > 0 {
dir := filepath.Join(g.outputDir, "page", strconv.Itoa(i+1))
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
dst = filepath.Join(dir, "index.html")
}
prev := ""
next := ""
switch i {
case 0:
// no prev
case 1:
prev = "/"
default:
prev = "/page/" + strconv.Itoa(i) + "/"
}
if i+1 < totalPages {
next = "/page/" + strconv.Itoa(i+2) + "/"
}
data := map[string]any{
"Groups": groups,
"Total": len(photos),
"Page": i + 1,
"TotalPages": totalPages,
"PrevURL": prev,
"NextURL": next,
"BaseURL": g.baseURL,
"Year": time.Now().Year(),
"Author": g.author,
"Handle": g.handle,
"Bio": g.bio,
"BioAlt": g.bioAlt,
"Initial": g.initial(),
"FontsURL": g.fontsURL,
"SerifFamily": g.serifFamily,
"MlFamily": g.mlFamily,
}
if err := g.render("timeline.html", dst, data); err != nil {
return err
}
}
return nil
}
// renderDayPages writes one page per calendar day under public/day/<ISO>/,
// holding just that day's photos. Timeline date headings link here.
func (g *Generator) renderDayPages(photos []Photo) error {
for _, grp := range groupByDay(photos) {
dir := filepath.Join(g.outputDir, "day", grp.day)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
err := g.render("day.html", filepath.Join(dir, "index.html"), map[string]any{
"Date": grp.Label,
"Count": grp.Count,
"Photos": grp.Photos,
"Total": len(photos),
"BaseURL": g.baseURL,
"Year": time.Now().Year(),
"Author": g.author,
@@ -305,25 +535,11 @@ func (g *Generator) renderIndex(photos []Photo) error {
"SerifFamily": g.serifFamily,
"MlFamily": g.mlFamily,
})
}
// renderPhotoPage writes public/photo/<slug>/index.html.
func (g *Generator) renderPhotoPage(p Photo) error {
dir := filepath.Join(g.outputDir, "photo", p.Slug)
if err := os.MkdirAll(dir, 0755); err != nil {
if err != nil {
return err
}
return g.render("photo.html", filepath.Join(dir, "index.html"), map[string]any{
"Photo": p,
"BaseURL": g.baseURL,
"Year": time.Now().Year(),
"Author": g.author,
"Handle": g.handle,
"Initial": g.initial(),
"FontsURL": g.fontsURL,
"SerifFamily": g.serifFamily,
"MlFamily": g.mlFamily,
})
}
return nil
}
func (g *Generator) render(tmpl, dst string, data any) error {
+2 -1
View File
@@ -19,6 +19,7 @@ func main() {
fontsURL := flag.String("fonts-url", "https://fonts.googleapis.com/css2?family=Crimson+Pro:ital,wght@0,400;0,600;0,700;1,400&family=Manjari:wght@100;400;700&display=swap", "stylesheet URL loading the two web fonts")
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")
pageSize := flag.Int("page-size", 60, "photos per page on the timeline and grid views (timeline packs whole days, so pages vary slightly around this)")
flag.Parse()
for _, d := range []string{*contentDir, *outputDir} {
@@ -27,7 +28,7 @@ func main() {
}
}
g := NewGenerator(*contentDir, *outputDir, *baseURL, *author, *handle, *bio, *bioAlt, *fontsURL, *serifFamily, *mlFamily)
g := NewGenerator(*contentDir, *outputDir, *baseURL, *author, *handle, *bio, *bioAlt, *fontsURL, *serifFamily, *mlFamily, *pageSize)
if err := g.Build(); err != nil {
log.Printf("initial build: %v", err)
+7
View File
@@ -76,6 +76,12 @@ in {
description = "CSS font-family for the alt-script (Malayalam) bio line. Must match a family loaded by fontsURL.";
};
pageSize = lib.mkOption {
type = lib.types.ints.positive;
default = 60;
description = "Photos per page on the timeline and grid views. The timeline packs whole days, so pages vary slightly around this.";
};
nginx = {
enable = lib.mkEnableOption "nginx virtual host for photogallery";
@@ -141,6 +147,7 @@ in {
"--fonts-url" cfg.fontsURL
"--serif-family" cfg.serifFamily
"--ml-family" cfg.mlFamily
"--page-size" (toString cfg.pageSize)
];
Restart = "on-failure";
+193
View File
@@ -0,0 +1,193 @@
// Navigation for the single photo page: arrow keys and touch swipe.
//
// Both read their destinations from the neighbour links the template already
// renders, so a photo with no newer/older sibling simply has nothing to find
// and the gesture rubber-bands instead of navigating. If this file fails to
// load the page still works — the links remain clickable.
(function () {
'use strict';
var newer = document.querySelector('[data-nav="newer"]');
var older = document.querySelector('[data-nav="older"]');
// ── Keyboard ──────────────────────────────────────────────────────────
document.addEventListener('keydown', function (e) {
// Alt+Arrow is back/forward in most browsers; don't shadow it.
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
if (e.target instanceof Element && e.target.matches('input, textarea')) return;
if (e.key === 'ArrowLeft' && newer) location.href = newer.href;
if (e.key === 'ArrowRight' && older) location.href = older.href;
});
// ── Swipe ─────────────────────────────────────────────────────────────
var wrap = document.querySelector('.dimg-wrap');
var img = wrap && wrap.querySelector('img');
if (!wrap || !img) return;
var EDGE_DEAD_ZONE = 24; // px from either screen edge, left to the browser
var AXIS_LOCK = 10; // px of travel before we decide scroll vs swipe
var MIN_TRAVEL = 60; // px needed to commit
var TRAVEL_RATIO = 0.18; // ...or this fraction of the viewport, whichever is more
var MIN_VELOCITY = 0.5; // px/ms; a fast flick commits below MIN_TRAVEL
var VELOCITY_WINDOW = 30; // ms between velocity samples
var RESISTANCE = 0.35; // drag factor when there is no neighbour that way
var FADE = 0.35; // how far the image dims across a full-width drag
var EXIT_MS = 180; // keep in sync with the CSS transition duration
var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var pointerId = null;
var startX = 0, startY = 0;
var prevX = 0, prevT = 0;
var dx = 0;
var locked = false; // horizontal drag in progress
var abandoned = false; // decided this gesture was a vertical scroll
var leaving = false; // committed, animating out
var swiped = false; // suppress the click that trails a swipe
// Swipe left (content moves left, the thing to the right comes in) → older.
// Matches ArrowRight above.
function targetFor(delta) {
return delta < 0 ? older : newer;
}
function paint() {
var shift = targetFor(dx) ? dx : dx * RESISTANCE;
var progress = Math.min(Math.abs(shift) / wrap.clientWidth, 1);
img.style.transform = 'translate3d(' + shift + 'px, 0, 0)';
img.style.opacity = String(1 - progress * FADE);
}
function release() {
pointerId = null;
locked = false;
abandoned = false;
dx = 0;
}
function snapBack() {
wrap.classList.remove('dragging');
wrap.classList.add('animating');
img.style.transform = '';
img.style.opacity = '';
window.setTimeout(function () {
wrap.classList.remove('animating');
}, EXIT_MS);
release();
}
function commit(target) {
if (reduceMotion) {
location.href = target.href;
return;
}
leaving = true;
wrap.classList.remove('dragging');
wrap.classList.add('animating');
img.style.transform =
'translate3d(' + (dx < 0 ? -wrap.clientWidth : wrap.clientWidth) + 'px, 0, 0)';
img.style.opacity = '0';
var went = false;
function go() {
if (went) return;
went = true;
location.href = target.href;
}
// transitionend does not fire if the tab is backgrounded mid-animation.
img.addEventListener('transitionend', go, { once: true });
window.setTimeout(go, EXIT_MS + 70);
release();
}
wrap.addEventListener('pointerdown', function (e) {
// A click can only follow a pointerdown, so clearing here is enough to
// keep a genuine tap working after an earlier swipe snapped back.
swiped = false;
if (leaving) return;
if (pointerId !== null) return; // already tracking a finger
if (e.pointerType !== 'touch') return; // leave mouse drag-to-save alone
if (!e.isPrimary) return; // second finger: pinch, not swipe
if (e.clientX < EDGE_DEAD_ZONE ||
e.clientX > window.innerWidth - EDGE_DEAD_ZONE) return;
pointerId = e.pointerId;
startX = prevX = e.clientX;
startY = e.clientY;
prevT = e.timeStamp;
dx = 0;
locked = false;
abandoned = false;
});
wrap.addEventListener('pointermove', function (e) {
if (e.pointerId !== pointerId || abandoned) return;
dx = e.clientX - startX;
var dy = e.clientY - startY;
if (!locked) {
if (Math.abs(dx) < AXIS_LOCK && Math.abs(dy) < AXIS_LOCK) return;
if (Math.abs(dy) >= Math.abs(dx)) {
abandoned = true; // vertical: hand the gesture back to the scroller
return;
}
locked = true;
wrap.classList.remove('animating');
wrap.classList.add('dragging');
wrap.setPointerCapture(pointerId);
}
if (e.cancelable) e.preventDefault();
if (e.timeStamp - prevT > VELOCITY_WINDOW) {
prevX = e.clientX;
prevT = e.timeStamp;
}
paint();
}, { passive: false });
wrap.addEventListener('pointerup', function (e) {
if (e.pointerId !== pointerId) return;
if (!locked) {
release();
return;
}
swiped = true;
var target = targetFor(dx);
var elapsed = e.timeStamp - prevT;
var velocity = elapsed > 0 ? (e.clientX - prevX) / elapsed : 0;
var far = Math.abs(dx) > Math.max(MIN_TRAVEL, window.innerWidth * TRAVEL_RATIO);
// A flick only counts if it is still moving the way the drag went.
var flicked = Math.abs(velocity) > MIN_VELOCITY && (velocity < 0) === (dx < 0);
if (target && (far || flicked)) {
commit(target);
} else {
snapBack();
}
});
wrap.addEventListener('pointercancel', function (e) {
if (e.pointerId !== pointerId) return;
if (locked) snapBack();
else release();
});
// The image sits inside an <a> to the full-size original. Without this a
// swipe that ends over it opens that link in a new tab.
wrap.addEventListener('click', function (e) {
if (!swiped) return;
e.preventDefault();
e.stopPropagation();
}, true);
})();
+117 -8
View File
@@ -8,6 +8,8 @@
--fg3: #bdae93;
--fg4: #a89984;
--aqua: #8ec07c;
--green: #98971a;
--blue: #458588;
--line: #3c3836;
--serif: 'Crimson Pro', Georgia, serif;
--mj: 'Manjari', sans-serif;
@@ -72,13 +74,13 @@ body {
font-family: var(--serif);
font-size: 26px;
font-weight: 700;
color: var(--fg);
color: var(--green);
margin-bottom: 3px;
}
.handle {
font-size: 12px;
color: var(--fg4);
color: var(--blue);
font-family: monospace;
margin-bottom: 10px;
letter-spacing: 0.02em;
@@ -128,6 +130,16 @@ body {
color: var(--fg2);
}
/* Divider between adjacent header links, e.g. GRID | RSS FEED. The pipe is a
flex child of the second link, so its right margin adds to that link's
gap:5px — 9px + 5px matches the 14px left margin for even spacing. */
.rss-a + .rss-a::before {
content: "|";
margin-left: 14px;
margin-right: 9px;
color: var(--fg4);
}
/* ── Photo grid ────────────────────────────────────────────── */
.grid-wrap {
@@ -161,6 +173,67 @@ body {
transform: scale(1.05);
}
/* ── Pager ─────────────────────────────────────────────────── */
.pager {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px 20px 8px;
font-family: monospace;
font-size: 13px;
letter-spacing: 0.04em;
}
.pager a {
color: var(--aqua);
text-decoration: none;
}
.pager a:hover {
color: var(--fg2);
}
.pager-newer { margin-right: auto; }
.pager-older { margin-left: auto; }
/* ── Timeline ──────────────────────────────────────────────── */
.tl-day {
margin-bottom: 34px;
}
.tl-date {
display: flex;
align-items: baseline;
gap: 10px;
font-family: monospace;
font-size: 15px;
font-weight: 600;
color: var(--fg2);
letter-spacing: 0.04em;
padding: 0 3px 10px;
margin-bottom: 3px;
border-bottom: 1px solid var(--line);
}
.tl-date a {
color: inherit;
text-decoration: none;
}
.tl-date a:hover {
color: var(--aqua);
}
.tl-count {
font-family: monospace;
font-size: 11px;
font-weight: 400;
color: var(--fg4);
letter-spacing: 0.04em;
}
/* ── Single photo page ─────────────────────────────────────── */
.photo-page {
@@ -169,20 +242,29 @@ body {
padding: 16px 20px;
}
.back-btn {
display: inline-block;
font-size: 15px;
color: var(--fg3);
text-decoration: none;
.photo-nav {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 8px 0;
margin-bottom: 16px;
font-family: var(--serif);
font-size: 15px;
}
.back-btn:hover {
.photo-nav a {
color: var(--fg3);
text-decoration: none;
}
.photo-nav a:hover {
color: var(--fg);
}
.photo-nav-neighbours {
color: var(--fg3);
}
.dlayout {
display: flex;
align-items: stretch;
@@ -213,6 +295,33 @@ body {
height: auto;
}
/* Swipe navigation. pan-y claims the horizontal axis for the gesture handler
while leaving vertical scrolling native. will-change is scoped to the two
transient states so the image is not permanently promoted to its own layer. */
.dimg-wrap {
touch-action: pan-y;
}
.dimg-wrap.dragging {
user-select: none;
-webkit-user-select: none;
}
.dimg-wrap.dragging img {
transition: none;
will-change: transform;
}
.dimg-wrap.animating img {
transition: transform 180ms ease-out, opacity 180ms ease-out;
will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
.dimg-wrap.animating img { transition: none; }
}
.dsidebar {
width: 220px;
flex-shrink: 0;
+6 -10
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Photo Gallery</title>
<title>{{.Date}} · Photo Gallery</title>
<link rel="stylesheet" href="{{.FontsURL}}">
<link rel="stylesheet" href="/static/style.css">
<style>:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }</style>
@@ -18,22 +18,17 @@
<div class="pinfo">
<h1>{{.Author}}</h1>
<p class="handle">{{.Handle}}</p>
<p class="pcount"><strong>{{len .Photos}}</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>
<a href="/feed.xml" class="rss-a">
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true">
<circle cx="2.5" cy="11.5" r="1.5"/>
<path d="M1.5 7.5A5 5 0 0 1 6.5 12.5H8A6.5 6.5 0 0 0 1.5 6V7.5z"/>
<path d="M1.5 4A8.5 8.5 0 0 1 10 12.5h1.5A10 10 0 0 0 1.5 2.5V4z"/>
</svg>
RSS feed
</a>
<a href="/" class="rss-a">← Timeline</a>
</div>
</div>
</header>
<main>
<div class="grid-wrap">
<section class="tl-day">
<h2 class="tl-date">{{.Date}}<span class="tl-count">{{.Count}}</span></h2>
<div class="pgrid">
{{range .Photos}}
<a href="/photo/{{.Slug}}/" class="ptile">
@@ -41,6 +36,7 @@
</a>
{{end}}
</div>
</section>
</div>
</main>
+97
View File
@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Photo Gallery</title>
<link rel="stylesheet" href="{{.FontsURL}}">
<link rel="stylesheet" href="/static/style.css">
<style>:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }</style>
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Photo Feed">
</head>
<body>
<header class="profile-hdr">
<div class="profile-inner">
<div class="avatar-ring">
<span class="avatar-initials">{{.Initial}}</span>
</div>
<div class="pinfo">
<h1>{{.Author}}</h1>
<p class="handle">{{.Handle}}</p>
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
<a href="/" class="rss-a">Timeline</a>
<a href="/feed.xml" class="rss-a">
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true">
<circle cx="2.5" cy="11.5" r="1.5"/>
<path d="M1.5 7.5A5 5 0 0 1 6.5 12.5H8A6.5 6.5 0 0 0 1.5 6V7.5z"/>
<path d="M1.5 4A8.5 8.5 0 0 1 10 12.5h1.5A10 10 0 0 0 1.5 2.5V4z"/>
</svg>
RSS feed
</a>
</div>
</div>
</header>
<main>
<div class="grid-wrap">
<div class="pgrid">
{{range .Photos}}
<a href="/photo/{{.Slug}}/" class="ptile">
<img src="/{{.Thumb}}" alt="{{.Caption}}" loading="lazy">
</a>
{{end}}
</div>
{{if or .PrevURL .NextURL}}
<nav class="pager">
{{if .PrevURL}}<a class="pager-newer" href="{{.PrevURL}}">← Newer</a>{{end}}
{{if .NextURL}}<a class="pager-older" href="{{.NextURL}}" data-load-more>Older →</a>{{end}}
</nav>
{{end}}
</div>
</main>
<footer class="site-footer">
© 2000{{.Year}} {{.Author}}. All rights reserved.
</footer>
<script>
(function () {
const btn = document.querySelector('[data-load-more]');
if (!btn) return;
const grid = document.querySelector('.pgrid');
let loading = false;
async function loadMore(url) {
if (loading) return;
loading = true;
try {
const res = await fetch(url);
if (!res.ok) return;
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
doc.querySelectorAll('.pgrid > a').forEach(t => grid.appendChild(t));
const nextBtn = doc.querySelector('[data-load-more]');
if (nextBtn) btn.href = nextBtn.getAttribute('href');
else btn.remove();
} finally {
loading = false;
}
}
btn.addEventListener('click', (e) => {
e.preventDefault();
loadMore(btn.href);
});
const io = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && document.body.contains(btn)) {
loadMore(btn.href);
}
}, { rootMargin: '400px' });
io.observe(btn);
})();
</script>
</body>
</html>
+13
View File
@@ -8,10 +8,21 @@
<link rel="stylesheet" href="/static/style.css">
<style>:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }</style>
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Photo Feed">
{{if .Newer}}<link rel="prefetch" href="/photo/{{.Newer.Slug}}/">
<link rel="prefetch" as="image" href="/{{.Newer.Medium}}">{{end}}
{{if .Older}}<link rel="prefetch" href="/photo/{{.Older.Slug}}/">
<link rel="prefetch" as="image" href="/{{.Older.Medium}}">{{end}}
</head>
<body>
<div class="photo-page">
<nav class="photo-nav">
<a href="/" class="back-btn">← Back</a>
<span class="photo-nav-neighbours">
{{if .Newer}}<a href="/photo/{{.Newer.Slug}}/" data-nav="newer">← Newer</a>{{end}}
{{if and .Newer .Older}} / {{end}}
{{if .Older}}<a href="/photo/{{.Older.Slug}}/" data-nav="older">Older →</a>{{end}}
</span>
</nav>
<div class="dlayout">
<div class="dimg-wrap">
<a href="/{{.Photo.File}}" target="_blank" rel="noopener">
@@ -33,5 +44,7 @@
<footer class="site-footer">
© 2000{{.Year}} {{.Author}}. All rights reserved.
</footer>
<script src="/static/photo-nav.js" defer></script>
</body>
</html>
+105
View File
@@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Photo Gallery</title>
<link rel="stylesheet" href="{{.FontsURL}}">
<link rel="stylesheet" href="/static/style.css">
<style>:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }</style>
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Photo Feed">
</head>
<body>
<header class="profile-hdr">
<div class="profile-inner">
<div class="avatar-ring">
<span class="avatar-initials">{{.Initial}}</span>
</div>
<div class="pinfo">
<h1>{{.Author}}</h1>
<p class="handle">{{.Handle}}</p>
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
<a href="/grid/" class="rss-a">Grid</a>
<a href="/feed.xml" class="rss-a">
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true">
<circle cx="2.5" cy="11.5" r="1.5"/>
<path d="M1.5 7.5A5 5 0 0 1 6.5 12.5H8A6.5 6.5 0 0 0 1.5 6V7.5z"/>
<path d="M1.5 4A8.5 8.5 0 0 1 10 12.5h1.5A10 10 0 0 0 1.5 2.5V4z"/>
</svg>
RSS feed
</a>
</div>
</div>
</header>
<main>
<div class="grid-wrap">
{{range .Groups}}
<section class="tl-day">
<h2 class="tl-date"><a href="/day/{{.Label}}/">{{.Label}}</a><span class="tl-count">{{.Count}}</span></h2>
<div class="pgrid">
{{range .Photos}}
<a href="/photo/{{.Slug}}/" class="ptile">
<img src="/{{.Thumb}}" alt="{{.Caption}}" loading="lazy">
</a>
{{end}}
</div>
</section>
{{end}}
{{if or .PrevURL .NextURL}}
<nav class="pager">
{{if .PrevURL}}<a class="pager-newer" href="{{.PrevURL}}">← Newer</a>{{end}}
{{if .NextURL}}<a class="pager-older" href="{{.NextURL}}" data-load-more>Older →</a>{{end}}
</nav>
{{end}}
</div>
</main>
<footer class="site-footer">
© 2000{{.Year}} {{.Author}}. All rights reserved.
</footer>
<script>
(function () {
const btn = document.querySelector('[data-load-more]');
if (!btn) return;
const nav = btn.closest('.pager');
const wrap = nav.parentNode;
let loading = false;
async function loadMore(url) {
if (loading) return;
loading = true;
try {
const res = await fetch(url);
if (!res.ok) return;
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
// Days never split across pages, so appended sections never duplicate
// a heading already on the page.
doc.querySelectorAll('.tl-day').forEach(s => wrap.insertBefore(s, nav));
const nextBtn = doc.querySelector('[data-load-more]');
if (nextBtn) btn.href = nextBtn.getAttribute('href');
else btn.remove();
} finally {
loading = false;
}
}
btn.addEventListener('click', (e) => {
e.preventDefault();
loadMore(btn.href);
});
const io = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && document.body.contains(btn)) {
loadMore(btn.href);
}
}, { rootMargin: '400px' });
io.observe(btn);
})();
</script>
</body>
</html>