2 Commits 60ea471469 ... e9ab959d43

Auteur SHA1 Message Date
  Medowar e9ab959d43 adding topics il y a 1 mois
  Medowar 26576658b9 implementing writing order of images on upload. il y a 1 mois
20 fichiers modifiés avec 1621 ajouts et 75 suppressions
  1. 7 1
      README.md
  2. 16 1
      admin/api.php
  3. 149 12
      admin/gallery-edit.php
  4. 14 0
      admin/index.php
  5. 108 0
      admin/migrate.php
  6. 49 0
      admin/topics-api.php
  7. 83 11
      app/archive.php
  8. 1 0
      app/bootstrap.php
  9. 164 0
      app/migrate.php
  10. 53 9
      app/s3.php
  11. 365 4
      app/storage.php
  12. 50 9
      assets/admin.js
  13. 134 2
      assets/site.css
  14. 37 7
      assets/site.js
  15. 215 0
      assets/topics.js
  16. 38 2
      docs/ADMIN-GUIDE.md
  17. 76 7
      docs/ARCHITECTURE.md
  18. 26 0
      docs/SETUP.md
  19. 26 8
      gallery/index.php
  20. 10 2
      upload-api.php

+ 7 - 1
README.md

@@ -13,6 +13,11 @@ No database, no framework, no build step — upload via FTP/SFTP and it runs.
   expiry date after which the gallery is hidden. Visitors load images directly
   from S3 through short-lived presigned URLs — nothing is proxied through the
   webhost.
+- **Topics** — optionally split a gallery into named sections (the days of a
+  trip, the stops of a shoot). Each gets a heading the client can collapse, and
+  its own folder in the ZIP download. Photos without a topic simply show first,
+  so galleries that do not use them look exactly as before. Guest uploads land
+  in their own section automatically.
 - **Admin backoffice** — a small CMS to edit the front page, manage the
   showreel, create galleries and bulk-upload images. Uploads go straight from
   the browser to S3, in full resolution, originals untouched; grid thumbnails
@@ -40,7 +45,8 @@ first** (Settings).
 
 ## Documentation
 
-- [docs/SETUP.md](docs/SETUP.md) — deployment, Hetzner bucket + CORS setup
+- [docs/SETUP.md](docs/SETUP.md) — deployment, Hetzner bucket + CORS setup,
+  and how to update an existing installation
 - [docs/ADMIN-GUIDE.md](docs/ADMIN-GUIDE.md) — using the backoffice
 - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — how it works inside
 

+ 16 - 1
admin/api.php

@@ -12,6 +12,10 @@
  *   slug      gallery slug
  *   original  the full-resolution file (required, stored unmodified)
  *   thumb     browser-generated JPEG thumbnail (optional; absent for RAW/video)
+ *   batch     id of the selection this file came from (optional)
+ *   seq       its position within that selection, so parallel uploads are
+ *             stored in the order they were picked, not the order they land
+ *   topic     id of the topic to upload into (optional; empty = no topic)
  *
  * The browser never receives an S3 URL or any credential for writing.
  */
@@ -45,10 +49,21 @@ if ($gallery === null) {
     json_response(['error' => 'Unknown gallery'], 404);
 }
 
+// The uploader may target a topic. Validate it here so a stale page cannot
+// write a reference to a topic that has since been deleted; gallery_append_image
+// re-checks under its lock for the same reason.
+$topic = (string)($_POST['topic'] ?? '');
+if ($topic !== '' && !isset(gallery_topic_map($gallery)[$topic])) {
+    $topic = '';
+}
+
 // Trusted admin path: any file type is allowed (imagesOnly stays false).
 [$status, $payload] = gallery_store_s3_upload(
     $gallery,
     $_FILES['original'] ?? null,
-    $_FILES['thumb'] ?? null
+    $_FILES['thumb'] ?? null,
+    false,
+    $_POST,
+    $topic !== '' ? $topic : null
 );
 json_response($payload, $status);

+ 149 - 12
admin/gallery-edit.php

@@ -66,6 +66,30 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
         redirect('gallery-edit.php?g=' . rawurlencode($slug));
     }
 
+    // Topic management. Assignment is not here: it runs through topics-api.php,
+    // because drag-and-drop cannot reload the page after every drop.
+    if (str_starts_with($action, 'topic-')) {
+        $id = (string)($_POST['id'] ?? '');
+        if ($action === 'topic-add') {
+            $added = gallery_topic_add($slug, (string)($_POST['name'] ?? ''));
+            flash_set(
+                $added !== null ? 'Topic "' . $added['name'] . '" added.' : 'Give the topic a name.',
+                $added !== null ? 'ok' : 'error'
+            );
+        } elseif ($action === 'topic-rename') {
+            gallery_topic_rename($slug, $id, (string)($_POST['name'] ?? ''));
+            flash_set('Topic renamed.');
+        } elseif ($action === 'topic-delete') {
+            // The images survive; they simply stop belonging to a topic and
+            // reappear in the untopiced section at the top.
+            gallery_topic_delete($slug, $id);
+            flash_set('Topic removed. Its images are now without a topic.');
+        } elseif ($action === 'topic-move') {
+            gallery_topic_move($slug, $id, (string)($_POST['dir'] ?? ''));
+        }
+        redirect('gallery-edit.php?g=' . rawurlencode($slug));
+    }
+
     if ($action === 'delete-image') {
         $key = (string)($_POST['key'] ?? '');
         foreach ($gallery['images'] ?? [] as $i => $img) {
@@ -100,6 +124,10 @@ $uploadUrl = !empty($gallery['upload_key'])
         . '&k=' . rawurlencode($gallery['upload_key'])
     : '';
 
+// Topics split the gallery into sections; a gallery with none renders exactly
+// as it did before they existed.
+$topics = gallery_topics($gallery);
+
 admin_header($gallery['title'], 'galleries');
 flash_render();
 ?>
@@ -110,6 +138,17 @@ flash_render();
 
 <div class="card">
     <h2 style="margin-top:0">Upload images</h2>
+    <?php if ($topics !== []): ?>
+        <!-- Uploading straight into a topic; the alternative is assigning a few
+             hundred photos one at a time after the fact. -->
+        <label for="upload-topic">Upload into</label>
+        <select id="upload-topic">
+            <option value="">No topic</option>
+            <?php foreach ($topics as $topic): ?>
+                <option value="<?= e($topic['id']) ?>"><?= e($topic['name']) ?></option>
+            <?php endforeach; ?>
+        </select>
+    <?php endif; ?>
     <div class="dropzone" id="dropzone"
          data-api="api.php"
          data-slug="<?= e($slug) ?>"
@@ -248,21 +287,119 @@ flash_render();
     <button type="submit">Save settings</button>
 </form>
 
+<div class="card">
+    <h2 style="margin-top:0">Topics</h2>
+    <p class="help" style="margin-bottom:1rem">
+        Topics split the gallery into sections — the days of a trip, the stops of
+        a shoot. They are optional: images without a topic always come first, and
+        a gallery with no topics looks exactly as it always did. Guests upload
+        into "<?= e(GALLERY_GUEST_TOPIC_NAME) ?>", which appears by itself the
+        first time someone uses the guest link.
+    </p>
+    <?php if ($topics !== []): ?>
+        <table style="margin-bottom:1rem">
+            <tr><th>Topic</th><th style="width:180px">Order</th><th style="width:90px"></th></tr>
+            <?php foreach ($topics as $i => $topic): ?>
+            <tr>
+                <td>
+                    <form method="post" style="display:flex;gap:.5rem;align-items:center"><?= csrf_field() ?>
+                        <input type="hidden" name="action" value="topic-rename">
+                        <input type="hidden" name="id" value="<?= e($topic['id']) ?>">
+                        <input type="text" name="name" value="<?= e($topic['name']) ?>"
+                               maxlength="80" style="margin:0;flex:1">
+                        <button class="btn-ghost" style="margin:0;padding:.3rem .7rem">Rename</button>
+                    </form>
+                </td>
+                <td>
+                    <form method="post" style="display:inline"><?= csrf_field() ?>
+                        <input type="hidden" name="action" value="topic-move">
+                        <input type="hidden" name="id" value="<?= e($topic['id']) ?>">
+                        <input type="hidden" name="dir" value="up">
+                        <button class="btn-ghost" style="margin:0;padding:.3rem .7rem" <?= $i === 0 ? 'disabled' : '' ?>>↑</button>
+                    </form>
+                    <form method="post" style="display:inline"><?= csrf_field() ?>
+                        <input type="hidden" name="action" value="topic-move">
+                        <input type="hidden" name="id" value="<?= e($topic['id']) ?>">
+                        <input type="hidden" name="dir" value="down">
+                        <button class="btn-ghost" style="margin:0;padding:.3rem .7rem" <?= $i === count($topics) - 1 ? 'disabled' : '' ?>>↓</button>
+                    </form>
+                </td>
+                <td>
+                    <form method="post" style="display:inline"
+                          onsubmit="return confirm('Remove this topic? Its images are kept and move back to no topic.')"><?= csrf_field() ?>
+                        <input type="hidden" name="action" value="topic-delete">
+                        <input type="hidden" name="id" value="<?= e($topic['id']) ?>">
+                        <button class="btn-danger" style="margin:0;padding:.3rem .7rem">Delete</button>
+                    </form>
+                </td>
+            </tr>
+            <?php endforeach; ?>
+        </table>
+    <?php endif; ?>
+    <form method="post"><?= csrf_field() ?>
+        <input type="hidden" name="action" value="topic-add">
+        <label for="topic-name">New topic</label>
+        <input type="text" id="topic-name" name="name" maxlength="80" placeholder="e.g. Day 1 – Reykjavík">
+        <button type="submit">Add topic</button>
+    </form>
+</div>
+
 <h2>Images (<span id="img-count"><?= count($gallery['images'] ?? []) ?></span>)</h2>
-<div class="thumb-row">
-    <?php foreach ($gallery['images'] ?? [] as $img): ?>
-    <figure>
-        <img src="<?= e(s3_presign_get($img['thumb'] ?? $img['key'])) ?>" alt="" loading="lazy">
-        <figcaption title="<?= e($img['name'] ?? '') ?>"><?= e($img['name'] ?? '') ?></figcaption>
-        <form method="post" onsubmit="return confirm('Delete this image from S3?')">
-            <?= csrf_field() ?>
-            <input type="hidden" name="action" value="delete-image">
-            <input type="hidden" name="key" value="<?= e($img['key']) ?>">
-            <button>✕</button>
-        </form>
-    </figure>
+<?php
+// Grouped for editing, empty topics included: an empty section is still a drop
+// target, and without it a new topic could never receive its first image.
+$groups = gallery_groups($gallery, true);
+// Position in the flat images array, which is what decides the order inside a
+// section. Handed to the browser so a dragged image can be dropped into the
+// place the page will show it in after the next reload, rather than at the end.
+$order = array_flip(array_column($gallery['images'] ?? [], 'key'));
+?>
+<div class="topic-blocks" id="topic-blocks"
+     data-api="topics-api.php"
+     data-slug="<?= e($slug) ?>"
+     data-csrf="<?= e(csrf_token()) ?>">
+    <?php foreach ($groups as $group): $topic = $group['topic']; ?>
+    <section class="topic-block" data-topic="<?= e($topic['id'] ?? '') ?>">
+        <?php if ($topics !== []): ?>
+            <h3 class="topic-block-head">
+                <?= $topic === null ? 'No topic' : e($topic['name']) ?>
+                <span class="topic-count"><?= count($group['images']) ?></span>
+            </h3>
+        <?php endif; ?>
+        <div class="thumb-row">
+            <?php foreach ($group['images'] as $img): ?>
+            <figure draggable="true" data-key="<?= e($img['key']) ?>" data-order="<?= (int)($order[$img['key']] ?? 0) ?>">
+                <img src="<?= e(s3_presign_get($img['thumb'] ?? $img['key'])) ?>" alt="" loading="lazy" draggable="false">
+                <figcaption title="<?= e($img['name'] ?? '') ?>"><?= e($img['name'] ?? '') ?></figcaption>
+                <button type="button" class="thumb-menu-btn" aria-haspopup="true" aria-expanded="false"
+                        title="Assign topic or delete">⋯</button>
+                <div class="thumb-menu" hidden>
+                    <?php if ($topics !== []): ?>
+                        <p class="thumb-menu-label">Assign topic</p>
+                        <button type="button" class="topic-pick<?= $topic === null ? ' is-current' : '' ?>" data-topic="">No topic</button>
+                        <?php foreach ($topics as $t): ?>
+                            <button type="button" class="topic-pick<?= ($topic['id'] ?? null) === $t['id'] ? ' is-current' : '' ?>"
+                                    data-topic="<?= e($t['id']) ?>"><?= e($t['name']) ?></button>
+                        <?php endforeach; ?>
+                    <?php else: ?>
+                        <p class="thumb-menu-label">Add a topic above to sort images into sections.</p>
+                    <?php endif; ?>
+                    <!-- Deletion stays a real form post: same server path, same
+                         confirmation, and it keeps working without the menu JS. -->
+                    <form method="post" onsubmit="return confirm('Delete this image from S3?')">
+                        <?= csrf_field() ?>
+                        <input type="hidden" name="action" value="delete-image">
+                        <input type="hidden" name="key" value="<?= e($img['key']) ?>">
+                        <button class="thumb-menu-danger">Delete</button>
+                    </form>
+                </div>
+            </figure>
+            <?php endforeach; ?>
+        </div>
+    </section>
     <?php endforeach; ?>
 </div>
 
 <script src="../assets/admin.js"></script>
+<script src="../assets/topics.js"></script>
 <?php admin_footer(); ?>

+ 14 - 0
admin/index.php

@@ -8,11 +8,22 @@ $active = count(array_filter($galleries, fn($g) => !gallery_is_expired($g)));
 $stats = array_map(fn($g) => gallery_stats($g['slug']), $galleries);
 $views = array_sum(array_column($stats, 'views'));
 $downloads = array_sum(array_column($stats, 'downloads'));
+// Computed from the galleries already loaded above rather than by rescanning:
+// this is the page an operator lands on after an update, so it is where a
+// pending migration should announce itself.
+$pending = array_filter($galleries, fn($g) => migrate_pending_for($g) !== []);
 
 admin_header('Dashboard');
 flash_render();
 ?>
 <h1>Dashboard</h1>
+<?php if ($pending !== []): ?>
+    <div class="flash flash-error">
+        <?= count($pending) ?> galler<?= count($pending) === 1 ? 'y needs' : 'ies need' ?>
+        a one-time data migration after the last software update.
+        <a href="migrate.php">Run it now →</a>
+    </div>
+<?php endif; ?>
 <div class="card">
     <table>
         <tr><td>Showreel images</td><td><?= count($site['showreel'] ?? []) ?></td>
@@ -25,6 +36,9 @@ flash_render();
             <td><a href="galleries.php">Per gallery →</a></td></tr>
         <tr><td>Front page</td><td><?= e($site['intro_title']) ?></td>
             <td><a href="frontpage.php">Edit →</a></td></tr>
+        <tr><td>Data format</td>
+            <td><?= $pending === [] ? 'up to date (v' . (int)SCHEMA_VERSION . ')' : count($pending) . ' pending' ?></td>
+            <td><a href="migrate.php">Migration →</a></td></tr>
     </table>
 </div>
 <p class="help">

+ 108 - 0
admin/migrate.php

@@ -0,0 +1,108 @@
+<?php
+/**
+ * Data migration page, part of the update procedure: upload the new files, then
+ * open this once and press the button.
+ *
+ * Nothing runs by itself. The application keeps reading old gallery files
+ * correctly whether or not this has been run — see app/migrate.php — so this is
+ * a tidy-up the operator triggers, never a gate that can lock a site out of its
+ * own data after an FTP upload.
+ */
+require dirname(__DIR__) . '/app/bootstrap.php';
+auth_require();
+
+$results = null;
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    csrf_verify();
+    // Large installations touch every gallery file; the work itself is small,
+    // but there is no reason to be caught by a low default time limit.
+    @set_time_limit(0);
+    $results = migrate_run();
+    flash_set('Migration finished.');
+}
+
+$pending = migrate_pending();
+
+admin_header('Data migration');
+flash_render();
+?>
+<h1>Data migration</h1>
+
+<div class="card">
+    <p class="help" style="margin-top:0;margin-bottom:1rem">
+        Run this once after updating the software. It brings every gallery's data
+        file up to the current format — currently schema version
+        <?= (int)SCHEMA_VERSION ?>. It is safe to run again at any time: each step
+        is written so that repeating it changes nothing, and no images, archives
+        or S3 objects are ever removed.
+    </p>
+
+    <?php if ($results !== null): ?>
+        <h2 style="margin-top:0">Result</h2>
+        <table style="margin-bottom:1rem">
+            <tr><th>Gallery</th><th style="width:110px">Applied</th><th>Notes</th></tr>
+            <?php foreach ($results as $row): ?>
+            <tr>
+                <td><a href="gallery-edit.php?g=<?= e(rawurlencode($row['slug'])) ?>"><?= e($row['title']) ?></a></td>
+                <td>
+                    <?php if ($row['error'] !== null): ?>
+                        <span class="tag tag-expired">failed</span>
+                    <?php elseif ($row['applied'] === []): ?>
+                        <span class="tag">up to date</span>
+                    <?php else: ?>
+                        <span class="tag tag-lock">v<?= e(implode(', v', $row['applied'])) ?></span>
+                    <?php endif; ?>
+                </td>
+                <td class="help" style="margin:0">
+                    <?php if ($row['error'] !== null): ?>
+                        <?= e($row['error']) ?>
+                    <?php else: ?>
+                        <?= $row['notes'] === [] ? '—' : e(implode(' ', $row['notes'])) ?>
+                    <?php endif; ?>
+                </td>
+            </tr>
+            <?php endforeach; ?>
+            <?php if ($results === []): ?>
+                <tr><td colspan="3" class="help">No galleries yet.</td></tr>
+            <?php endif; ?>
+        </table>
+    <?php endif; ?>
+
+    <?php if ($pending === []): ?>
+        <p style="margin-bottom:1rem">Everything is up to date.</p>
+    <?php else: ?>
+        <h2 style="margin-top:0">Pending</h2>
+        <table style="margin-bottom:1rem">
+            <tr><th>Gallery</th><th style="width:140px">Missing steps</th></tr>
+            <?php foreach ($pending as $slug => $info): ?>
+            <tr>
+                <td><a href="gallery-edit.php?g=<?= e(rawurlencode($slug)) ?>"><?= e($info['title']) ?></a></td>
+                <td>v<?= e(implode(', v', $info['versions'])) ?></td>
+            </tr>
+            <?php endforeach; ?>
+        </table>
+    <?php endif; ?>
+
+    <form method="post">
+        <?= csrf_field() ?>
+        <button type="submit" style="margin:0">
+            <?= $pending === [] ? 'Run again' : 'Run migration' ?>
+        </button>
+    </form>
+</div>
+
+<div class="card">
+    <h2 style="margin-top:0">What version <?= (int)SCHEMA_VERSION ?> does</h2>
+    <p class="help" style="margin:0">
+        <strong>Topics.</strong> Galleries can now be split into named sections.
+        Existing galleries need no conversion — they simply have no topics and
+        look exactly as before — so this step only normalises the new fields and
+        clears any leftover reference to a topic that does not exist.
+        One thing it cannot do: photos uploaded through a guest link before the
+        update are indistinguishable from your own, because nothing recorded who
+        uploaded them. They stay without a topic. Guest uploads made from now on
+        land in "<?= e(GALLERY_GUEST_TOPIC_NAME) ?>" automatically.
+    </p>
+</div>
+<?php admin_footer(); ?>

+ 49 - 0
admin/topics-api.php

@@ -0,0 +1,49 @@
+<?php
+/**
+ * Admin JSON API for putting one image into a topic (or back into none).
+ *
+ * Creating, renaming, reordering and deleting topics are plain form posts on
+ * gallery-edit.php — they change the page anyway. Assignment is the exception:
+ * dragging a photo from one section to another has to happen without a reload,
+ * or organising a few hundred images would be a few hundred page loads.
+ *
+ * Fields: slug, action (assign), key (the image's S3 key), topic (a topic id,
+ * or empty for no topic).
+ */
+require dirname(__DIR__) . '/app/bootstrap.php';
+
+if (!auth_check()) {
+    json_response(['error' => 'Not authenticated'], 401);
+}
+if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+    json_response(['error' => 'POST only'], 405);
+}
+csrf_verify();
+
+// Nothing here is slow, but a drag over many images fires these back to back
+// and PHP holds the session file exclusively for the whole request.
+session_write_close();
+
+$slug = (string)($_POST['slug'] ?? '');
+$gallery = gallery_load($slug);
+if ($gallery === null) {
+    json_response(['error' => 'Unknown gallery'], 404);
+}
+
+if (($_POST['action'] ?? '') !== 'assign') {
+    json_response(['error' => 'Unknown action'], 400);
+}
+
+$topic = (string)($_POST['topic'] ?? '');
+// Checked against this gallery's topics, so a stale page cannot write a
+// reference to one that has since been deleted. gallery_assign_topic() checks
+// again under its lock, where the answer is authoritative.
+if ($topic !== '' && !isset(gallery_topic_map($gallery)[$topic])) {
+    json_response(['error' => 'Unknown topic'], 400);
+}
+
+if (!gallery_assign_topic($slug, (string)($_POST['key'] ?? ''), $topic)) {
+    json_response(['error' => 'Unknown image'], 404);
+}
+
+json_response(['ok' => true]);

+ 83 - 11
app/archive.php

@@ -43,7 +43,9 @@
  *
  * Staying current
  * ---------------
- * Every stored or deleted image marks its gallery dirty (archive_mark_dirty).
+ * Every stored or deleted image marks its gallery dirty (archive_mark_dirty),
+ * as does anything that moves a photo between the ZIP's folders — assigning a
+ * topic, renaming one, reordering them.
  * A dirty gallery's download button is disabled until the rebuild lands, rather
  * than handing out a ZIP that is missing the newest photos. Rebuilds run in the
  * background: archive_kick() on a page render dispatches worker.php, which runs
@@ -71,13 +73,75 @@ function archive_tick_file(): string
     return DATA_DIR . '/archive.tick';
 }
 
+/**
+ * A topic's name as a ZIP folder: ASCII, no separators, nothing a desktop
+ * unzipper could refuse. Deliberately the same character set safe_filename()
+ * uses for the files inside it, so a path is uniform end to end.
+ */
+function archive_folder_name(string $name): string
+{
+    $folder = ascii_transliterate($name);
+    // Runs collapse to one dash: "Day 1 – Reykjavík" is a perfectly ordinary
+    // topic name, and transliterating it would otherwise leave "Day-1---…".
+    $folder = preg_replace('/[^A-Za-z0-9._-]+/', '-', $folder) ?? '';
+    // Collapse afterwards, not in the same pass: transliteration turns the dash
+    // in "Day 1 – Reykjavík" into one of its own, and the spaces around it into
+    // two more, which would otherwise leave "Day-1---Reykjavik".
+    $folder = preg_replace('/-{2,}/', '-', $folder) ?? '';
+    $folder = trim(substr($folder, 0, 60), '-.');
+    return $folder !== '' ? $folder : 'topic';
+}
+
+/**
+ * Every photo the archive contains, in build order, with the path it gets
+ * inside the ZIP: untopiced photos in the root, each topic its own folder.
+ *
+ * Order and grouping come from gallery_groups(), the same function the gallery
+ * page renders from, so the ZIP's structure always matches what the client saw.
+ * Two topics that reduce to the same folder name are kept apart by the same
+ * dedupe the filenames use.
+ *
+ * Returns [ ['image' => <image record>, 'path' => 'Day-1/DSC_0001.jpg'], … ].
+ */
+function archive_entries(array $gallery): array
+{
+    $entries = [];
+    $folders = [];
+    foreach (gallery_groups($gallery) as $group) {
+        $prefix = '';
+        if ($group['topic'] !== null) {
+            $prefix = zip_dedupe_name(archive_folder_name($group['topic']['name']), $folders) . '/';
+        }
+        foreach ($group['images'] as $image) {
+            $entries[] = [
+                'image' => $image,
+                'path'  => $prefix . safe_filename((string)($image['name'] ?? 'photo.jpg'), 'photo'),
+            ];
+        }
+    }
+    return $entries;
+}
+
 /**
  * Identity of a gallery's image set. A build records the hash it was made from;
  * when the gallery's current hash differs, the archive is out of date.
+ *
+ * A gallery that uses no topics hashes exactly as it did before topics existed,
+ * so installing this version does not invalidate a single archive already built.
+ * Once topics are in play the hash is taken over the ZIP paths instead, which
+ * covers key, order, topic membership, topic order and topic names at once:
+ * anything that would change the archive's layout changes the hash.
  */
 function archive_source_hash(array $gallery): string
 {
-    return sha1(implode("\n", array_column($gallery['images'] ?? [], 'key')));
+    if (!gallery_uses_topics($gallery)) {
+        return sha1(implode("\n", array_column($gallery['images'] ?? [], 'key')));
+    }
+    $lines = [];
+    foreach (archive_entries($gallery) as $entry) {
+        $lines[] = $entry['path'] . "\t" . (string)($entry['image']['key'] ?? '');
+    }
+    return sha1(implode("\n", $lines));
 }
 
 /** Whether a gallery's archive is missing or no longer matches its images. */
@@ -95,8 +159,9 @@ function archive_is_stale(array $gallery): bool
 // ---------------------------------------------------------------------------
 
 /**
- * Flag a gallery for rebuilding. Called from gallery_append_image() and from
- * image deletion, i.e. everywhere a gallery's contents can change.
+ * Flag a gallery for rebuilding. Called from gallery_append_image(), from image
+ * deletion and from the topic mutations in app/storage.php — everywhere the
+ * gallery's contents or their arrangement can change.
  *
  * Uploads run in parallel (uploads.concurrency), so the queue is written through
  * json_update()'s exclusive lock — three uploads finishing together must not
@@ -194,7 +259,9 @@ function archive_start(string $slug, array $gallery): ?array
         'key'         => $key,
         'upload_id'   => $uploadId,
         'source_hash' => archive_source_hash($gallery),
-        'total'       => count($gallery['images']),
+        // One entry per photo, so this still counts photos — but it counts them
+        // in the order and grouping the ZIP will actually use.
+        'total'       => count(archive_entries($gallery)),
         'next_index'  => 0,
         // Archive length so far, and how much of it S3 already has. The
         // difference is exactly what the buffer file holds.
@@ -280,7 +347,10 @@ function archive_run_slice(string $slug, ?float $budget = null): array
         }
     }
 
-    $images = $gallery['images'];
+    // Rebuilt from the gallery on every slice rather than carried in the state
+    // file: it is a pure function of the gallery, and any change to the gallery
+    // has already restarted the build through the source hash above.
+    $entries = archive_entries($gallery);
     $bufferPath = gallery_archive_buffer($slug);
     $fh = fopen($bufferPath, 'c+b');
     if ($fh === false) {
@@ -311,16 +381,18 @@ function archive_run_slice(string $slug, ?float $budget = null): array
         }
 
         $photoStart = microtime(true);
-        $image = $images[$state['next_index']];
+        $entry = $entries[$state['next_index']];
+        $image = $entry['image'];
 
         // Reserve the name in a copy: a photo that fails below is retried by the
         // next slice, and a name left registered by the failed attempt would
         // make the retry rename itself to "… (2)".
+        //
+        // Deduping the whole path rather than the bare filename makes it
+        // per-folder for free: the same DSC_0001.jpg may appear once in every
+        // topic, and only a genuine clash inside one folder gets renamed.
         $names = $state['names'];
-        $name = zip_dedupe_name(
-            safe_filename((string)($image['name'] ?? 'photo.jpg'), 'photo'),
-            $names
-        );
+        $name = zip_dedupe_name($entry['path'], $names);
 
         $entryOffset = (int)$state['offset'];
         $header = zip_local_header($name, $mtime);

+ 1 - 0
app/bootstrap.php

@@ -25,6 +25,7 @@ require APP_ROOT . '/app/auth.php';
 require APP_ROOT . '/app/s3.php';
 require APP_ROOT . '/app/zip.php';
 require APP_ROOT . '/app/archive.php';
+require APP_ROOT . '/app/migrate.php';
 require APP_ROOT . '/app/markdown.php';
 require APP_ROOT . '/app/partials.php';
 

+ 164 - 0
app/migrate.php

@@ -0,0 +1,164 @@
+<?php
+/**
+ * One-time data migrations, run by hand from admin/migrate.php after an update.
+ *
+ * Why this exists
+ * ---------------
+ * The gallery files have always evolved by lazy defaults: a new field is read
+ * as `$gallery['x'] ?? default`, so a file written by an older version simply
+ * keeps working and gains the field the next time something writes it. That is
+ * still true — nothing here is required for the application to run.
+ *
+ * What it buys is a place to *finish* a schema change rather than leaving every
+ * gallery in one of two shapes indefinitely: files get normalised in one pass,
+ * broken leftovers are cleaned up, and each gallery records how far it has been
+ * brought. The next schema change adds a numbered step to MIGRATIONS instead of
+ * inventing a mechanism.
+ *
+ * Rules for a step
+ * ----------------
+ *   - idempotent: running it twice must be indistinguishable from running it
+ *     once, because nothing stops an operator from pressing the button again
+ *   - written through json_update(), so it cannot fight an upload that lands
+ *     while it runs
+ *   - never destructive: a step normalises and repairs, it does not delete
+ *     images or S3 objects
+ */
+
+declare(strict_types=1);
+
+/** The schema version a fully migrated gallery file carries. */
+const SCHEMA_VERSION = 1;
+
+/** Version number => the function that brings a gallery up to it. */
+const MIGRATIONS = [
+    1 => 'migrate_v1_topics',
+];
+
+/** How far this gallery has been migrated. Files predating this read as 0. */
+function migrate_version_of(array $gallery): int
+{
+    return (int)($gallery['schema_version'] ?? 0);
+}
+
+/** The steps a gallery still needs, in order. */
+function migrate_pending_for(array $gallery): array
+{
+    $at = migrate_version_of($gallery);
+    return array_values(array_filter(array_keys(MIGRATIONS), fn(int $v): bool => $v > $at));
+}
+
+/**
+ * Every gallery with something left to do: slug => [title, versions].
+ * An empty result means the installation is fully migrated.
+ */
+function migrate_pending(): array
+{
+    $out = [];
+    foreach (galleries_all() as $gallery) {
+        $pending = migrate_pending_for($gallery);
+        if ($pending !== []) {
+            $out[(string)$gallery['slug']] = [
+                'title'    => (string)($gallery['title'] ?? $gallery['slug']),
+                'versions' => $pending,
+            ];
+        }
+    }
+    return $out;
+}
+
+/**
+ * Run every outstanding step against every gallery.
+ *
+ * Each gallery is migrated under its own lock and committed on its own, so a
+ * failure part-way through leaves the galleries already done in their new shape
+ * and the rest exactly as they were — never a half-written file.
+ *
+ * Returns one row per gallery: ['slug', 'title', 'applied' => [versions],
+ * 'notes' => [string], 'error' => ?string].
+ */
+function migrate_run(): array
+{
+    $rows = [];
+    foreach (galleries_all() as $gallery) {
+        $slug = (string)$gallery['slug'];
+        $row = ['slug' => $slug, 'title' => (string)($gallery['title'] ?? $slug),
+                'applied' => [], 'notes' => [], 'error' => null];
+
+        try {
+            json_update(gallery_file($slug), function (array $g) use (&$row): ?array {
+                if ($g === []) {
+                    return null;   // deleted between the listing and now
+                }
+                $pending = migrate_pending_for($g);
+                if ($pending === []) {
+                    return null;
+                }
+                foreach ($pending as $version) {
+                    $g = (MIGRATIONS[$version])($g, $row['notes']);
+                    $g['schema_version'] = $version;
+                    $row['applied'][] = $version;
+                }
+                return $g;
+            });
+        } catch (Throwable $e) {
+            $row['error'] = $e->getMessage();
+        }
+
+        $rows[] = $row;
+    }
+    return $rows;
+}
+
+/**
+ * v1 — topics.
+ *
+ * Topics are additive, so there is nothing to convert: a gallery written before
+ * they existed is already valid and renders as one untopiced section. This step
+ * only tidies:
+ *
+ *   - gives every gallery a 'topics' key, so the field is present rather than
+ *     merely defaulted on read
+ *   - drops malformed topic entries (a hand-edited file, an interrupted write)
+ *   - clears an image's 'topic' when it names a topic that does not exist —
+ *     harmless at render time, where it reads as no topic, but not worth
+ *     carrying around
+ *
+ * It also flags what it deliberately cannot do: guest uploads made before this
+ * version are indistinguishable from the photographer's own, because the image
+ * record never recorded who uploaded it. They stay untopiced; only uploads made
+ * from now on land in the guest topic.
+ *
+ * @param string[] $notes collects operator-facing remarks about this gallery
+ */
+function migrate_v1_topics(array $gallery, array &$notes): array
+{
+    $before = count($gallery['topics'] ?? []);
+    $topics = gallery_topics($gallery);
+    $gallery['topics'] = $topics;
+    if ($before > count($topics)) {
+        $notes[] = ($before - count($topics)) . ' malformed topic entr'
+            . ($before - count($topics) === 1 ? 'y' : 'ies') . ' dropped.';
+    }
+
+    $known = gallery_topic_map($gallery);
+    $cleared = 0;
+    foreach ($gallery['images'] ?? [] as $i => $image) {
+        $topic = (string)($image['topic'] ?? '');
+        if ($topic !== '' && !isset($known[$topic])) {
+            unset($gallery['images'][$i]['topic']);
+            $cleared++;
+        }
+    }
+    if ($cleared > 0) {
+        $notes[] = "$cleared image(s) pointed at a topic that no longer exists; they are now without a topic.";
+    }
+
+    if (!empty($gallery['upload_key'])) {
+        $notes[] = 'Guest uploads are enabled. Photos guests uploaded before this update '
+            . 'cannot be identified afterwards and stay without a topic; new guest uploads '
+            . 'go into "' . GALLERY_GUEST_TOPIC_NAME . '".';
+    }
+
+    return $gallery;
+}

+ 53 - 9
app/s3.php

@@ -601,27 +601,70 @@ function upload_error_message(int $code): string
     };
 }
 
+/**
+ * The ordering pair the uploader sends with every file, sanitised for storage:
+ *
+ *   batch  opaque id shared by all files of one drop/selection
+ *   seq    the file's position within that batch
+ *
+ * Returns [] when either is absent or malformed — an upload without usable
+ * ordering is simply appended at the end, which is what every upload did before
+ * this existed, so an older cached admin.js keeps working.
+ *
+ * The batch id is never interpreted, only compared, so the guest-facing endpoint
+ * can accept it from an unauthenticated browser: the worst a crafted value can
+ * do is place the sender's own upload among its own siblings. It is still capped
+ * and stripped to keep the gallery JSON tidy.
+ */
+function upload_order_fields(array $fields): array
+{
+    // is_string, not a cast: a client is free to post batch[]=… as an array.
+    if (!is_string($fields['batch'] ?? null) || !is_numeric($fields['seq'] ?? null)) {
+        return [];
+    }
+    $batch = preg_replace('/[^A-Za-z0-9_-]+/', '', $fields['batch']) ?? '';
+    if ($batch === '') {
+        return [];
+    }
+    return ['batch' => substr($batch, 0, 32), 'seq' => max(0, (int)$fields['seq'])];
+}
+
 /**
  * Ingest one uploaded image into a gallery: stream the original (and optional
- * browser-generated thumbnail) to S3, then append it to the gallery's JSON file.
+ * browser-generated thumbnail) to S3, then store it in the gallery's JSON file.
  *
  * Shared by admin/api.php (trusted admin) and upload-api.php (public guest link).
- * The browser uploads several images at once, so the gallery entry is appended
+ * The browser uploads several images at once, so the gallery entry goes in
  * through gallery_append_image(), which re-reads and rewrites the JSON file
  * under an exclusive lock — two uploads finishing together cannot drop one
- * another's entry. Object keys are generated server-side under the gallery's
- * own prefix — never taken from the client.
+ * another's entry — and places it by the batch/seq the browser sent rather than
+ * at the end, so the gallery keeps the order the files were selected in.
+ * Object keys are generated server-side under the gallery's own prefix — never
+ * taken from the client.
  *
  * $original / $thumb are $_FILES entries (or null). When $imagesOnly is true the
  * original must have a recognised image extension and decode via getimagesize(),
- * so a public link cannot be used to store arbitrary file types.
+ * so a public link cannot be used to store arbitrary file types. $fields is the
+ * request's $_POST, read for the ordering pair only.
+ *
+ * $topic is the topic the image should land in, decided by the caller — the
+ * guest endpoint always passes GALLERY_GUEST_TOPIC, the admin endpoint passes
+ * whatever the uploader selected. An unknown topic is dropped rather than
+ * stored, so the image still lands (untopiced) if the topic was deleted while
+ * the upload was in flight.
  *
  * Returns [int $httpStatus, array $payload] for the caller to hand to
  * json_response(); a thumbnail failure is non-fatal (the grid falls back to the
  * original key).
  */
-function gallery_store_s3_upload(array $gallery, ?array $original, ?array $thumb, bool $imagesOnly = false): array
-{
+function gallery_store_s3_upload(
+    array $gallery,
+    ?array $original,
+    ?array $thumb,
+    bool $imagesOnly = false,
+    array $fields = [],
+    ?string $topic = null
+): array {
     if (!is_array($original) || ($original['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
         return [400, ['error' => upload_error_message((int)($original['error'] ?? UPLOAD_ERR_NO_FILE))]];
     }
@@ -665,13 +708,14 @@ function gallery_store_s3_upload(array $gallery, ?array $original, ?array $thumb
         }
     }
 
-    // Locked read-modify-write: concurrent uploads append without clobbering.
+    // Locked read-modify-write: concurrent uploads are placed in selection
+    // order (see gallery_image_position) without clobbering each other.
     $count = gallery_append_image($slug, [
         'key'   => $key,
         'thumb' => $thumbKey,
         'name'  => substr((string)($original['name'] ?? basename($key)), 0, 200),
         'size'  => (int)($original['size'] ?? 0),
-    ]);
+    ] + upload_order_fields($fields), $topic);
 
     // The gallery was deleted while this image was in flight: drop the objects
     // we just wrote rather than leaving them unreferenced in the bucket.

+ 365 - 4
app/storage.php

@@ -284,21 +284,82 @@ function gallery_delete(string $slug): void
 }
 
 /**
- * Append one image to a gallery under an exclusive lock, so parallel uploads
+ * Where a newly uploaded image belongs among the ones already stored.
+ *
+ * Uploads run several at a time, so they finish in an order set by file size
+ * and network luck, not by the order the photographer picked them. Each job
+ * therefore carries the batch it was selected in and its position within that
+ * batch ($image['batch'] / $image['seq']), and lands next to its siblings
+ * instead of wherever it happened to arrive.
+ *
+ * A batch occupies one contiguous run: its first arrival appends at the end,
+ * and every later one inserts inside that run, which only shifts the runs after
+ * it. So dropping a second selection while the first is still uploading keeps
+ * the two apart, in the order they were dropped.
+ *
+ * Returns the insert position, or null to append — for an unknown batch, and
+ * for images stored before this ordering existed (no batch at all).
+ */
+function gallery_image_position(array $images, array $image): ?int
+{
+    $batch = $image['batch'] ?? null;
+    if (!is_string($batch) || $batch === '') {
+        return null;
+    }
+
+    $seq = (int)($image['seq'] ?? 0);
+    $pos = null;
+    foreach ($images as $i => $existing) {
+        if (($existing['batch'] ?? null) !== $batch) {
+            continue;
+        }
+        if ((int)($existing['seq'] ?? 0) > $seq) {
+            return $i; // first sibling that belongs after us
+        }
+        $pos = $i + 1;
+    }
+    return $pos;
+}
+
+/**
+ * Insert one image into a gallery under an exclusive lock, so parallel uploads
  * into the same gallery cannot overwrite each other's entries.
  *
+ * Position comes from gallery_image_position(), so the stored order follows the
+ * selection order rather than the order the uploads completed in.
+ *
+ * $topic is the topic id the image should land in, or null for none. The
+ * special value GALLERY_GUEST_TOPIC is created on the fly if the gallery has no
+ * guest topic yet — inside this function's lock, so two guests uploading at the
+ * same moment cannot each append their own copy of it.
+ *
  * Returns the new image count, or null if the gallery no longer exists — an
  * absent gallery must not be resurrected as a stub by a late upload.
  */
-function gallery_append_image(string $slug, array $image): ?int
+function gallery_append_image(string $slug, array $image, ?string $topic = null): ?int
 {
     $missing = false;
-    $gallery = json_update(gallery_file($slug), function (array $g) use ($image, &$missing) {
+    $gallery = json_update(gallery_file($slug), function (array $g) use ($image, $topic, &$missing) {
         if ($g === []) {
             $missing = true;
             return null; // deleted mid-upload — do not write a stub file back
         }
-        $g['images'][] = $image;
+        if ($topic === GALLERY_GUEST_TOPIC) {
+            $g = gallery_with_guest_topic($g);
+        }
+        // Silently drop a topic that no longer exists rather than storing a
+        // dangling reference: the gallery may have been edited mid-upload.
+        if ($topic !== null && isset(gallery_topic_map($g)[$topic])) {
+            $image['topic'] = $topic;
+        }
+        $images = $g['images'] ?? [];
+        $at = gallery_image_position($images, $image);
+        if ($at === null) {
+            $images[] = $image;
+        } else {
+            array_splice($images, $at, 0, [$image]);
+        }
+        $g['images'] = $images;
         return $g;
     });
     if ($missing) {
@@ -309,6 +370,306 @@ function gallery_append_image(string $slug, array $image): ?int
     return count($gallery['images'] ?? []);
 }
 
+// ---------------------------------------------------------------------------
+// Topics — optional named sections within one gallery
+// ---------------------------------------------------------------------------
+
+/**
+ * Topics group a gallery's images into sections: the days of a trip, the stops
+ * of a shoot. They are entirely optional — a gallery with no topics behaves,
+ * renders and archives exactly as it did before they existed.
+ *
+ * The gallery record gains one key, and an image one optional key:
+ *
+ *   'topics' => [ ['id' => 't7k3f9a', 'name' => 'Day 1'], … ]   // display order
+ *   'images' => [ ['key' => …, 'topic' => 't7k3f9a'], … ]       // absent = none
+ *
+ * The images array itself stays one flat list in upload order; the grouping is
+ * *derived* by gallery_groups() wherever it is needed. That keeps the upload
+ * ordering above (batch/seq) untouched, and means a gallery file written before
+ * topics existed is already a valid one — nothing has to be backfilled for the
+ * gallery to work. See app/migrate.php for the tidy-up pass.
+ */
+
+/** Id of the topic guest uploads land in. Reserved; never handed out by gallery_topic_add(). */
+const GALLERY_GUEST_TOPIC = 'guest';
+const GALLERY_GUEST_TOPIC_NAME = 'Guest uploads';
+
+/** Ids are ours, but they arrive back from forms and the assign endpoint. */
+function gallery_topic_id_valid(string $id): bool
+{
+    return (bool)preg_match('/^[A-Za-z0-9_-]{1,32}$/', $id);
+}
+
+/** Trim a submitted topic name to something storable; '' means "reject". */
+function gallery_topic_name(string $name): string
+{
+    return substr(trim(preg_replace('/\s+/u', ' ', $name) ?? ''), 0, 80);
+}
+
+/**
+ * A gallery's topics, normalised: well-formed entries only, duplicate ids
+ * dropped, stored order preserved. Every reader goes through this, so a
+ * hand-edited or half-migrated file cannot break a page.
+ */
+function gallery_topics(array $gallery): array
+{
+    $out = [];
+    $seen = [];
+    foreach ($gallery['topics'] ?? [] as $topic) {
+        if (!is_array($topic)) {
+            continue;
+        }
+        $id = (string)($topic['id'] ?? '');
+        $name = gallery_topic_name((string)($topic['name'] ?? ''));
+        if ($id === '' || $name === '' || isset($seen[$id]) || !gallery_topic_id_valid($id)) {
+            continue;
+        }
+        $seen[$id] = true;
+        $out[] = ['id' => $id, 'name' => $name];
+    }
+    return $out;
+}
+
+/** id => name, for membership tests and label lookups. */
+function gallery_topic_map(array $gallery): array
+{
+    return array_column(gallery_topics($gallery), 'name', 'id');
+}
+
+/**
+ * A gallery's images grouped for display, in render order: the images with no
+ * topic first, then each topic in its stored order. Within a group the images
+ * keep their existing order, so uploads still land where batch/seq put them.
+ *
+ * An image whose topic id matches no existing topic reads as untopiced — a
+ * dangling reference must never make a photo vanish from the gallery.
+ *
+ * $includeEmpty keeps topics that hold no images (and the untopiced group when
+ * the gallery has topics at all): the admin editor needs them as drop targets,
+ * the public view does not want to show empty headings.
+ *
+ * Returns [ ['topic' => null|['id'=>…,'name'=>…], 'images' => [...]], … ].
+ */
+function gallery_groups(array $gallery, bool $includeEmpty = false): array
+{
+    $topics = gallery_topics($gallery);
+    $map = gallery_topic_map($gallery);
+
+    $buckets = ['' => []];
+    foreach ($topics as $topic) {
+        $buckets[$topic['id']] = [];
+    }
+    foreach ($gallery['images'] ?? [] as $image) {
+        $id = (string)($image['topic'] ?? '');
+        $buckets[isset($map[$id]) ? $id : ''][] = $image;
+    }
+
+    $groups = [];
+    if ($buckets[''] !== [] || ($includeEmpty && $topics !== [])) {
+        $groups[] = ['topic' => null, 'images' => $buckets['']];
+    }
+    foreach ($topics as $topic) {
+        if ($buckets[$topic['id']] === [] && !$includeEmpty) {
+            continue;
+        }
+        $groups[] = ['topic' => $topic, 'images' => $buckets[$topic['id']]];
+    }
+    return $groups;
+}
+
+/** Whether topics play any part in this gallery — the pre-topics fast path. */
+function gallery_uses_topics(array $gallery): bool
+{
+    if (gallery_topics($gallery) !== []) {
+        return true;
+    }
+    foreach ($gallery['images'] ?? [] as $image) {
+        if (($image['topic'] ?? '') !== '') {
+            return true;
+        }
+    }
+    return false;
+}
+
+/**
+ * The gallery with a guest topic guaranteed to exist. Called inside a lock by
+ * gallery_append_image(); the admin may rename or reorder the topic afterwards,
+ * but the id stays 'guest', so later guest uploads keep landing in it. Deleting
+ * it simply means the next guest upload creates it again.
+ */
+function gallery_with_guest_topic(array $gallery): array
+{
+    if (isset(gallery_topic_map($gallery)[GALLERY_GUEST_TOPIC])) {
+        return $gallery;
+    }
+    $topics = gallery_topics($gallery);
+    $topics[] = ['id' => GALLERY_GUEST_TOPIC, 'name' => GALLERY_GUEST_TOPIC_NAME];
+    $gallery['topics'] = $topics;
+    return $gallery;
+}
+
+/**
+ * Add a topic. Returns the stored entry, or null if the name was empty or the
+ * gallery is gone. Ids are random rather than derived from the name, so
+ * renaming a topic never has to touch the images pointing at it.
+ */
+function gallery_topic_add(string $slug, string $name): ?array
+{
+    $name = gallery_topic_name($name);
+    if ($name === '') {
+        return null;
+    }
+    $added = null;
+    json_update(gallery_file($slug), function (array $g) use ($name, &$added): ?array {
+        if ($g === []) {
+            return null;
+        }
+        $topics = gallery_topics($g);
+        do {
+            $id = 't' . random_token(6);
+        } while (isset(gallery_topic_map($g)[$id]));
+        $added = ['id' => $id, 'name' => $name];
+        $topics[] = $added;
+        $g['topics'] = $topics;
+        return $g;
+    });
+    return $added;
+}
+
+/** Rename a topic in place. The archive folder is named after it, hence dirty. */
+function gallery_topic_rename(string $slug, string $id, string $name): bool
+{
+    $name = gallery_topic_name($name);
+    if ($name === '' || !gallery_topic_id_valid($id)) {
+        return false;
+    }
+    $changed = false;
+    $gallery = json_update(gallery_file($slug), function (array $g) use ($id, $name, &$changed): ?array {
+        $topics = gallery_topics($g);
+        foreach ($topics as $i => $topic) {
+            if ($topic['id'] === $id && $topic['name'] !== $name) {
+                $topics[$i]['name'] = $name;
+                $g['topics'] = $topics;
+                $changed = true;
+                return $g;
+            }
+        }
+        return null;
+    });
+    if ($changed) {
+        archive_mark_dirty($slug, $gallery);
+    }
+    return $changed;
+}
+
+/**
+ * Remove a topic. Its images are not deleted — they fall back to no topic, and
+ * so reappear at the top of the gallery.
+ */
+function gallery_topic_delete(string $slug, string $id): bool
+{
+    if (!gallery_topic_id_valid($id)) {
+        return false;
+    }
+    $changed = false;
+    $gallery = json_update(gallery_file($slug), function (array $g) use ($id, &$changed): ?array {
+        $topics = gallery_topics($g);
+        $kept = array_values(array_filter($topics, fn(array $t): bool => $t['id'] !== $id));
+        if (count($kept) === count($topics)) {
+            return null;
+        }
+        $g['topics'] = $kept;
+        foreach ($g['images'] ?? [] as $i => $image) {
+            if (($image['topic'] ?? '') === $id) {
+                unset($g['images'][$i]['topic']);
+            }
+        }
+        $changed = true;
+        return $g;
+    });
+    if ($changed) {
+        archive_mark_dirty($slug, $gallery);
+    }
+    return $changed;
+}
+
+/**
+ * Move a topic one place up or down in the display order. Mirrors the showreel
+ * reordering in admin/showreel.php: a swap with the neighbour, no-op at the end.
+ */
+function gallery_topic_move(string $slug, string $id, string $dir): bool
+{
+    if (!gallery_topic_id_valid($id)) {
+        return false;
+    }
+    $changed = false;
+    $gallery = json_update(gallery_file($slug), function (array $g) use ($id, $dir, &$changed): ?array {
+        $topics = gallery_topics($g);
+        $at = array_search($id, array_column($topics, 'id'), true);
+        if ($at === false) {
+            return null;
+        }
+        $to = $dir === 'up' ? $at - 1 : $at + 1;
+        if ($to < 0 || $to >= count($topics)) {
+            return null;
+        }
+        [$topics[$at], $topics[$to]] = [$topics[$to], $topics[$at]];
+        $g['topics'] = $topics;
+        $changed = true;
+        return $g;
+    });
+    if ($changed) {
+        archive_mark_dirty($slug, $gallery);
+    }
+    return $changed;
+}
+
+/**
+ * Put one image into a topic, or back into none ($topicId null or '').
+ *
+ * Matched by S3 key, the image record's de-facto identity. Under the same lock
+ * as uploads, so assigning while an upload is in flight cannot lose either one.
+ * Returns false for an unknown image or an unknown topic; a no-op assignment
+ * (already in that topic) counts as success and skips the write.
+ */
+function gallery_assign_topic(string $slug, string $key, ?string $topicId): bool
+{
+    $topicId = (string)$topicId;
+    if ($topicId !== '' && !gallery_topic_id_valid($topicId)) {
+        return false;
+    }
+    $ok = false;
+    $changed = false;
+    $gallery = json_update(gallery_file($slug), function (array $g) use ($key, $topicId, &$ok, &$changed): ?array {
+        if ($topicId !== '' && !isset(gallery_topic_map($g)[$topicId])) {
+            return null;
+        }
+        foreach ($g['images'] ?? [] as $i => $image) {
+            if (($image['key'] ?? '') !== $key) {
+                continue;
+            }
+            $ok = true;
+            if ((string)($image['topic'] ?? '') === $topicId) {
+                return null;   // already there
+            }
+            if ($topicId === '') {
+                unset($g['images'][$i]['topic']);
+            } else {
+                $g['images'][$i]['topic'] = $topicId;
+            }
+            $changed = true;
+            return $g;
+        }
+        return null;
+    });
+    if ($changed) {
+        // The image now belongs in a different folder of the ZIP.
+        archive_mark_dirty($slug, $gallery);
+    }
+    return $ok;
+}
+
 /** Counter name => the timestamp field recording when it last moved. */
 const GALLERY_COUNTERS = [
     'views'     => 'last_viewed_at',

+ 50 - 9
assets/admin.js

@@ -15,8 +15,10 @@
  * is store-and-forward — the webhost receives the whole body before it starts
  * the S3 PUT — so a single-file queue leaves the uplink idle for the entire
  * webhost→S3 leg and for every thumbnail decode. Overlapping requests keeps it
- * saturated; the server appends to the gallery JSON under a lock, so parallel
- * completions cannot lose entries.
+ * saturated; the server writes the gallery JSON under a lock, so parallel
+ * completions cannot lose entries, and each file carries the batch it was
+ * selected in plus its position there, so they are stored in the order they
+ * were picked rather than the order they happen to finish in.
  */
 (function () {
     'use strict';
@@ -27,6 +29,9 @@
     var input = document.getElementById('file-input');
     var list = document.getElementById('upload-list');
     var countEl = document.getElementById('img-count');
+    /* Which topic new images land in. Only the admin page offers the choice;
+       on the guest link this is absent and the server picks the guest topic. */
+    var topicEl = document.getElementById('upload-topic');
 
     var api = zone.dataset.api;
     var slug = zone.dataset.slug;
@@ -53,26 +58,55 @@
     zone.addEventListener('click', function () { input.click(); });
     input.addEventListener('change', function () { enqueue(input.files); input.value = ''; });
 
+    /* Only a drag carrying files is ours. The gallery editor drags thumbnails
+       between topics over the same page, and that must not light the dropzone up
+       or be mistaken for an upload. */
+    function hasFiles(e) {
+        var types = e.dataTransfer ? e.dataTransfer.types : null;
+        return !!types && Array.prototype.indexOf.call(types, 'Files') !== -1;
+    }
+
     ['dragenter', 'dragover'].forEach(function (ev) {
-        zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.add('drag'); });
+        zone.addEventListener(ev, function (e) {
+            if (!hasFiles(e)) return;
+            e.preventDefault();
+            zone.classList.add('drag');
+        });
     });
     ['dragleave', 'drop'].forEach(function (ev) {
-        zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.remove('drag'); });
+        zone.addEventListener(ev, function (e) {
+            if (!hasFiles(e)) return;
+            e.preventDefault();
+            zone.classList.remove('drag');
+        });
     });
-    zone.addEventListener('drop', function (e) { enqueue(e.dataTransfer.files); });
+    zone.addEventListener('drop', function (e) { if (hasFiles(e)) enqueue(e.dataTransfer.files); });
 
     window.addEventListener('beforeunload', function (e) {
         if (active || queue.length) { e.preventDefault(); e.returnValue = ''; }
     });
 
+    /* One selection (a drop, or one trip through the file dialog) is a batch,
+       and every file remembers its place in it. Uploads finish in an order set
+       by file size and network luck, so without this the gallery would store
+       them shuffled; the server puts each one back among its own siblings.
+       The id is opaque — the server only compares it, never reads it — so a
+       random token is enough and no clock has to be trusted. */
+    function batchId() {
+        return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
+    }
+
     function enqueue(files) {
-        Array.prototype.forEach.call(files, function (file) {
+        var batch = batchId();
+        Array.prototype.forEach.call(files, function (file, i) {
             var row = document.createElement('div');
             row.className = 'upload-item';
             row.innerHTML = '<span class="name"></span><span class="bar"><i></i></span><span class="state">queued</span>';
             row.querySelector('.name').textContent = file.name;
             list.appendChild(row);
-            queue.push({ file: file, row: row });
+            /* Read at selection time, not at send time: the admin may pick the
+               next topic while this batch is still uploading. */
+            queue.push({ file: file, row: row, batch: batch, seq: i, topic: topicEl ? topicEl.value : '' });
         });
         pump();
     }
@@ -86,7 +120,7 @@
 
     function run(job) {
         active++;
-        uploadOne(job.file, job.row)
+        uploadOne(job)
             .then(function () { setState(job.row, 'done', 'done'); bumpCount(); })
             .catch(function (err) {
                 setState(job.row, 'failed', 'error');
@@ -236,7 +270,9 @@
         return name.replace(/\.[^.\/]*$/, '') + '.jpg';
     }
 
-    function uploadOne(file, row) {
+    function uploadOne(job) {
+        var file = job.file;
+        var row = job.row;
         var bar = row.querySelector('.bar i');
         setState(row, maxRes ? 'resizing' : 'thumbnail');
 
@@ -248,6 +284,11 @@
             var form = new FormData();
             form.append('slug', slug);
             if (uploadKey) form.append('key', uploadKey);
+            // Kept on the job, so a manual retry lands in its original place
+            // even when the files after it are already stored.
+            form.append('batch', job.batch);
+            form.append('seq', String(job.seq));
+            if (job.topic) form.append('topic', job.topic);
             if (out.resized) form.append('original', out.resized, jpegName(file.name));
             else form.append('original', file, file.name);
             if (out.thumb) form.append('thumb', out.thumb, 'thumb.jpg');

+ 134 - 2
assets/site.css

@@ -293,6 +293,11 @@ body.reel-page { scroll-snap-type: y mandatory; }
     gap: 6px;
 }
 
+/* A collapsed topic. Needed explicitly: the rule above is a class selector and
+   would otherwise win over the browser's own [hidden] rule, leaving a collapsed
+   section fully visible. */
+.grid[hidden] { display: none; }
+
 .grid a {
     position: relative;
     aspect-ratio: 3 / 2;
@@ -310,6 +315,51 @@ body.reel-page { scroll-snap-type: y mandatory; }
 
 .grid a:hover img { transform: scale(1.03); opacity: 1; }
 
+/* Topic sections on the client gallery.
+
+   Images without a topic render as a bare .grid above everything else, exactly
+   as a gallery did before topics existed — no heading, nothing to collapse.
+   Only actual topics get the heading below. */
+
+.topic-head { margin: 2.6rem 0 .9rem; font-weight: 400; }
+.grid + .topic-head { margin-top: 3.2rem; }
+
+.topic-toggle {
+    /* Reset the global button styling: this is a heading you can click, not a
+       control that should look like one. */
+    display: flex;
+    align-items: baseline;
+    gap: .6rem;
+    width: auto;
+    margin: 0;
+    padding: 0;
+    background: none;
+    border: 0;
+    color: var(--fg);
+    font: inherit;
+    font-size: 1.05rem;
+    letter-spacing: .04em;
+    text-transform: none;
+    cursor: pointer;
+    text-align: left;
+}
+.topic-toggle:hover { color: var(--fg); opacity: .8; }
+
+.topic-chevron {
+    display: inline-block;
+    color: var(--muted);
+    font-size: 1.2rem;
+    line-height: 1;
+    transition: transform .2s ease;
+}
+.topic-toggle[aria-expanded="true"] .topic-chevron { transform: rotate(90deg); }
+
+.topic-count {
+    color: var(--muted);
+    font-size: .78rem;
+    letter-spacing: .1em;
+}
+
 /* Lightbox */
 
 .lightbox {
@@ -520,8 +570,90 @@ th { color: var(--muted); font-weight: 400; font-size: .75rem; letter-spacing: .
 .thumb-row figure { position: relative; width: 140px; }
 .thumb-row img { width: 140px; height: 100px; object-fit: cover; border-radius: 4px; background: #000; }
 .thumb-row figcaption { font-size: .7rem; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: .25rem; }
-.thumb-row form { position: absolute; top: 4px; right: 4px; }
-.thumb-row form button { margin: 0; padding: .15rem .5rem; font-size: .7rem; background: rgba(0,0,0,.65); color: #fff; border-radius: 3px; }
+
+/* Topic sections in the editor.
+
+   Unlike the client gallery, the untopiced section is labelled here: it has to
+   be a visible drop target, and so do topics holding nothing yet. A gallery
+   with no topics at all renders no headings, so it looks untouched. */
+
+.topic-block { margin-bottom: 1.6rem; }
+.topic-block-head {
+    display: flex;
+    align-items: baseline;
+    gap: .6rem;
+    font-weight: 400;
+    font-size: .8rem;
+    letter-spacing: .12em;
+    text-transform: uppercase;
+    color: var(--muted);
+    padding-bottom: .4rem;
+    margin-bottom: .7rem;
+    border-bottom: 1px solid var(--line);
+}
+.topic-block .thumb-row { min-height: 40px; }
+/* The drop target is the whole section, so an empty one is still hittable. */
+.topic-block.drag-over {
+    outline: 1px dashed var(--fg);
+    outline-offset: 6px;
+    border-radius: 4px;
+}
+.thumb-row figure.is-dragging { opacity: .4; }
+.thumb-row figure[draggable] { cursor: grab; }
+.topic-status { color: var(--danger); margin-bottom: .8rem; }
+
+/* Per-image menu: assign to a topic, or delete. */
+.thumb-menu-btn {
+    position: absolute;
+    top: 4px;
+    right: 4px;
+    margin: 0;
+    padding: .15rem .5rem;
+    font-size: .7rem;
+    line-height: 1.2;
+    background: rgba(0,0,0,.65);
+    color: #fff;
+    border-radius: 3px;
+    letter-spacing: 0;
+}
+.thumb-menu {
+    position: absolute;
+    top: 26px;
+    right: 4px;
+    z-index: 20;
+    min-width: 150px;
+    padding: .35rem 0;
+    background: var(--bg-raise);
+    border: 1px solid var(--line);
+    border-radius: 4px;
+    box-shadow: 0 8px 24px rgba(0,0,0,.5);
+}
+.thumb-menu-label {
+    padding: .25rem .7rem .35rem;
+    font-size: .68rem;
+    letter-spacing: .1em;
+    text-transform: uppercase;
+    color: var(--muted);
+}
+.thumb-menu .topic-pick, .thumb-menu-danger {
+    display: block;
+    width: 100%;
+    margin: 0;
+    padding: .35rem .7rem;
+    background: none;
+    color: var(--fg);
+    font-size: .78rem;
+    letter-spacing: 0;
+    text-transform: none;
+    text-align: left;
+    border-radius: 0;
+}
+.thumb-menu .topic-pick:hover, .thumb-menu-danger:hover { background: rgba(255,255,255,.07); }
+/* The topic this image is already in; shown, but not worth clicking. */
+.thumb-menu .topic-pick.is-current { color: var(--muted); }
+.thumb-menu .topic-pick.is-current::after { content: " ✓"; }
+.thumb-menu form { margin-top: .35rem; border-top: 1px solid var(--line); padding-top: .35rem; }
+.thumb-menu-danger { color: var(--danger); }
 
 /* Upload queue */
 .dropzone {

+ 37 - 7
assets/site.js

@@ -18,13 +18,41 @@
         window.addEventListener('scroll', onScroll, { passive: true });
     }
 
+    /* ---- Topic sections: collapse / expand ------------------------------
+       A gallery may be split into topics, each a heading plus its own .grid.
+       The button carries the state so the chevron and the panel can both be
+       styled from it, and screen readers get it for free. Deliberately not
+       remembered anywhere: every visit starts fully expanded. */
+    document.addEventListener('click', function (e) {
+        var toggle = e.target.closest && e.target.closest('.topic-toggle');
+        if (!toggle) return;
+        var panel = document.getElementById(toggle.getAttribute('aria-controls'));
+        if (!panel) return;
+        var open = toggle.getAttribute('aria-expanded') === 'true';
+        toggle.setAttribute('aria-expanded', open ? 'false' : 'true');
+        panel.hidden = open;
+    });
+
     /* ---- Lightbox for gallery pages -------------------------------------
-       Markup contract: .grid contains <a href="<full-res URL>"><img></a>. */
-    var grid = document.querySelector('.grid');
-    if (!grid) return;
+       Markup contract: every .grid contains <a href="<full-res URL>"><img></a>.
+       A gallery with topics has one .grid per section, so they are treated as a
+       single sequence in document order — which is the order the client reads
+       the page in. */
+    var grids = Array.prototype.slice.call(document.querySelectorAll('.grid'));
+    if (!grids.length) return;
 
-    var links = Array.prototype.slice.call(grid.querySelectorAll('a'));
-    if (!links.length) return;
+    var allLinks = [];
+    grids.forEach(function (grid) {
+        allLinks = allLinks.concat(Array.prototype.slice.call(grid.querySelectorAll('a')));
+    });
+    if (!allLinks.length) return;
+
+    /* Only what the visitor can actually see right now. Recomputed on open, so
+       arrowing through never lands on a photo inside a collapsed topic. */
+    var links = allLinks;
+    function visibleLinks() {
+        return allLinks.filter(function (a) { return a.offsetParent !== null; });
+    }
 
     var box = document.createElement('div');
     box.className = 'lightbox';
@@ -55,10 +83,12 @@
         current = -1;
     }
 
-    links.forEach(function (a, i) {
+    allLinks.forEach(function (a) {
         a.addEventListener('click', function (e) {
             e.preventDefault();
-            show(i);
+            links = visibleLinks();
+            var at = links.indexOf(a);
+            if (at >= 0) show(at);
         });
     });
 

+ 215 - 0
assets/topics.js

@@ -0,0 +1,215 @@
+/*
+ * Gallery editor: putting images into topics.
+ *
+ * Two ways in, one code path: the ⋯ menu on each thumbnail, and dragging a
+ * thumbnail onto another section. Both call admin/topics-api.php and then move
+ * the <figure> in place, because reloading the page after every one of a few
+ * hundred assignments is not an editing experience.
+ *
+ * The move is optimistic and reverts on failure, so the page never shows an
+ * arrangement the server did not accept.
+ *
+ * Deletion is deliberately not here: it stays a plain form post inside the menu,
+ * with the same confirmation and the same server path it has always had.
+ */
+(function () {
+    'use strict';
+
+    var root = document.getElementById('topic-blocks');
+    if (!root) return;
+
+    var api = root.dataset.api;
+    var slug = root.dataset.slug;
+    var csrf = root.dataset.csrf;
+
+    /* A private drag type. The uploader's dropzone reacts to "Files"; this makes
+       the two kinds of drag tell themselves apart, so dragging a thumbnail never
+       looks like dropping a photo from the desktop, or the other way round. */
+    var DRAG_TYPE = 'application/x-gallery-image';
+
+    var status = document.createElement('p');
+    status.className = 'help topic-status';
+    status.hidden = true;
+    root.parentNode.insertBefore(status, root);
+
+    function fail(message) {
+        status.textContent = message;
+        status.hidden = false;
+    }
+
+    function clearStatus() {
+        status.hidden = true;
+    }
+
+    /* ---- Server call ---------------------------------------------------- */
+
+    function assign(key, topic) {
+        var body = new FormData();
+        body.append('slug', slug);
+        body.append('action', 'assign');
+        body.append('key', key);
+        body.append('topic', topic);
+        return fetch(api, {
+            method: 'POST',
+            headers: { 'X-CSRF-Token': csrf },
+            body: body
+        }).then(function (response) {
+            return response.json().catch(function () {
+                return { error: 'Server returned a non-JSON response (HTTP ' + response.status + ')' };
+            }).then(function (data) {
+                if (!response.ok || !data.ok) {
+                    throw new Error(data.error || ('Assign failed (HTTP ' + response.status + ')'));
+                }
+            });
+        });
+    }
+
+    /* ---- Moving the thumbnail ------------------------------------------- */
+
+    function blockFor(topic) {
+        /* Topic ids are [A-Za-z0-9_-] only, so this cannot need escaping. */
+        return root.querySelector('.topic-block[data-topic="' + topic + '"]');
+    }
+
+    function recount() {
+        Array.prototype.forEach.call(root.querySelectorAll('.topic-block'), function (block) {
+            var badge = block.querySelector('.topic-count');
+            if (badge) badge.textContent = String(block.querySelectorAll('figure').length);
+        });
+    }
+
+    /* Insert where the server will render it after the next reload: sections
+       keep the images in their upload order, which is what data-order carries. */
+    function insert(figure, row) {
+        var order = parseInt(figure.dataset.order, 10) || 0;
+        var after = Array.prototype.filter.call(row.children, function (el) {
+            return el !== figure && (parseInt(el.dataset.order, 10) || 0) > order;
+        })[0];
+        row.insertBefore(figure, after || null);
+    }
+
+    function markCurrent(figure, topic) {
+        Array.prototype.forEach.call(figure.querySelectorAll('.topic-pick'), function (pick) {
+            pick.classList.toggle('is-current', pick.dataset.topic === topic);
+        });
+    }
+
+    /* Move the thumbnail first, put it back if the server disagrees. */
+    function move(figure, topic) {
+        var block = blockFor(topic);
+        if (!block) return;
+        var row = block.querySelector('.thumb-row');
+        var from = figure.parentNode;
+        var before = figure.nextElementSibling;
+        if (row === from) {
+            closeMenus();
+            return;
+        }
+
+        clearStatus();
+        insert(figure, row);
+        markCurrent(figure, topic);
+        recount();
+
+        assign(figure.dataset.key, topic).catch(function (err) {
+            from.insertBefore(figure, before);
+            markCurrent(figure, from.parentNode.dataset.topic);
+            recount();
+            fail(err.message);
+        });
+        closeMenus();
+    }
+
+    /* ---- The ⋯ menu ------------------------------------------------------ */
+
+    function closeMenus(except) {
+        Array.prototype.forEach.call(root.querySelectorAll('.thumb-menu'), function (menu) {
+            if (menu === except) return;
+            menu.hidden = true;
+            menu.parentNode.querySelector('.thumb-menu-btn').setAttribute('aria-expanded', 'false');
+        });
+    }
+
+    root.addEventListener('click', function (e) {
+        var button = e.target.closest('.thumb-menu-btn');
+        if (button) {
+            var menu = button.parentNode.querySelector('.thumb-menu');
+            var open = menu.hidden;
+            closeMenus(menu);
+            menu.hidden = !open;
+            button.setAttribute('aria-expanded', open ? 'true' : 'false');
+            return;
+        }
+
+        var pick = e.target.closest('.topic-pick');
+        if (pick) {
+            move(pick.closest('figure'), pick.dataset.topic);
+            return;
+        }
+
+        /* A click anywhere else inside the editor, including on another
+           thumbnail, dismisses an open menu. */
+        if (!e.target.closest('.thumb-menu')) closeMenus();
+    });
+
+    document.addEventListener('click', function (e) {
+        if (!e.target.closest || !e.target.closest('#topic-blocks')) closeMenus();
+    });
+    document.addEventListener('keydown', function (e) {
+        if (e.key === 'Escape') closeMenus();
+    });
+
+    /* ---- Drag and drop --------------------------------------------------- */
+
+    var dragged = null;
+
+    root.addEventListener('dragstart', function (e) {
+        var figure = e.target.closest('figure');
+        if (!figure) return;
+        dragged = figure;
+        figure.classList.add('is-dragging');
+        e.dataTransfer.effectAllowed = 'move';
+        e.dataTransfer.setData(DRAG_TYPE, figure.dataset.key);
+        /* Some browsers refuse a drag that sets no text/plain. */
+        e.dataTransfer.setData('text/plain', figure.dataset.key);
+        closeMenus();
+    });
+
+    root.addEventListener('dragend', function () {
+        if (dragged) dragged.classList.remove('is-dragging');
+        dragged = null;
+        Array.prototype.forEach.call(root.querySelectorAll('.drag-over'), function (block) {
+            block.classList.remove('drag-over');
+        });
+    });
+
+    function ours(e) {
+        var types = e.dataTransfer ? e.dataTransfer.types : null;
+        return !!types && Array.prototype.indexOf.call(types, DRAG_TYPE) !== -1;
+    }
+
+    root.addEventListener('dragover', function (e) {
+        if (!ours(e)) return;   // a file drag belongs to the uploader, not here
+        var block = e.target.closest('.topic-block');
+        if (!block) return;
+        e.preventDefault();
+        e.dataTransfer.dropEffect = 'move';
+        block.classList.add('drag-over');
+    });
+
+    root.addEventListener('dragleave', function (e) {
+        var block = e.target.closest('.topic-block');
+        /* Ignore the leave events fired while crossing the section's own
+           children, or the highlight would flicker across every thumbnail. */
+        if (block && !block.contains(e.relatedTarget)) block.classList.remove('drag-over');
+    });
+
+    root.addEventListener('drop', function (e) {
+        if (!ours(e)) return;
+        var block = e.target.closest('.topic-block');
+        if (!block) return;
+        e.preventDefault();
+        block.classList.remove('drag-over');
+        if (dragged) move(dragged, block.dataset.topic);
+    });
+})();

+ 38 - 2
docs/ADMIN-GUIDE.md

@@ -51,8 +51,41 @@ shows *done*; failed files offer a *retry* link. A small preview thumbnail is
 generated by your browser for the gallery grid; files the browser cannot
 decode (e.g. RAW) are uploaded anyway, just without a preview.
 
-**Delete** an image (✕ on its thumbnail) or a whole gallery — this also
-removes the files from S3 permanently.
+**Delete** an image (the ⋯ button on its thumbnail → *Delete*) or a whole
+gallery — this also removes the files from S3 permanently.
+
+### Topics
+
+Topics split a gallery into sections — the days of a trip, the stops of a
+shoot. They are optional: a gallery with no topics looks exactly as it always
+has.
+
+Add them in the **Topics** card of the gallery editor, where you can also
+rename them, reorder them with ↑ ↓, and delete one. Deleting a topic never
+deletes photos — they simply stop belonging to it.
+
+Put an image into a topic in either of two ways:
+
+- **drag** its thumbnail onto another section, or
+- open the **⋯** menu on the thumbnail and pick a topic.
+
+When you are about to upload a batch that all belongs to the same topic, choose
+it in **Upload into** above the drop area first — far quicker than assigning
+afterwards.
+
+What your client sees: photos with no topic first, with no heading, then each
+topic as a heading they can collapse with the › arrow. In the *Download all*
+ZIP, each topic is a folder and the photos with no topic sit in the root.
+
+Photos uploaded through a **guest link** go into a "Guest uploads" topic, which
+appears by itself the first time someone uses the link. You can rename it, move
+it, and move photos out of it like any other topic; new guest uploads keep
+arriving in it. (Guest photos uploaded before this feature existed cannot be
+identified after the fact and stay without a topic.)
+
+Reorganising a gallery makes its *Download all* archive out of date, so the
+button is disabled while it rebuilds itself in the background — same as after
+adding or deleting photos.
 
 ## Settings
 
@@ -70,3 +103,6 @@ After 5 failed login attempts the login is locked for 15 minutes.
 - All content lives in flat files: `data/` (JSON) and `media/`
   (showreel/hero images). Backing up = copying those two folders plus
   `config/`.
+- After the software has been updated on the server, open **Migration** from
+  the dashboard once and run it. The dashboard tells you when this is
+  outstanding; it is safe to run again at any time and deletes nothing.

+ 76 - 7
docs/ARCHITECTURE.md

@@ -19,8 +19,11 @@ worker.php         background archive builder (self-dispatching, key-protected)
 admin/             backoffice (session-protected)
   api.php          JSON API for the uploader (presign / register)
   archive-api.php  JSON API for building an archive on demand
-assets/            site.css, site.js (nav + lightbox), admin.js (uploader),
-                   archive.js (archive build progress)
+  topics-api.php   JSON API for moving one image into a topic
+  migrate.php      one-time data migrations, run by hand after an update
+assets/            site.css, site.js (nav + lightbox + topic collapse),
+                   admin.js (uploader), archive.js (archive build progress),
+                   topics.js (topic menu + drag-and-drop in the editor)
 media/             local images: hero + showreel (full resolution)
 app/               library code — blocked by .htaccess
   bootstrap.php    config loading, session, helpers
@@ -29,6 +32,7 @@ app/               library code — blocked by .htaccess
   s3.php           AWS Signature v4 (presign, PUT, DELETE, GET, multipart)
   zip.php          store-only ZIP64 writer
   archive.php      archive build slices, dirty queue, worker dispatch
+  migrate.php      numbered schema migrations + the schema version constant
   csrf.php         CSRF tokens
   partials.php     shared HTML header/footer for public + admin pages
 config/            static config (S3, site) + admin credentials — blocked
@@ -49,10 +53,15 @@ router.php         local dev only: applies the .htaccess rules under php -S
     "password_hash": "$2y$...",        // or null
     "expires_at": "2026-12-31",         // or null
     "max_resolution": 2560,             // longest edge in px, or null = original
+    "schema_version": 1,                // absent on files older than admin/migrate.php
+    "topics": [                         // optional sections, in display order
+      { "id": "t7k3f9a", "name": "Day 1" }
+    ],
     "images": [
       { "key":   "<prefix>/<slug>/originals/a1b2c3-DSC_0001.jpg",
         "thumb": "<prefix>/<slug>/thumbs/a1b2c3-DSC_0001.jpg.jpg",
-        "name":  "DSC_0001.jpg", "size": 18349201 }
+        "name":  "DSC_0001.jpg", "size": 18349201,
+        "topic": "t7k3f9a" }            // absent = no topic
     ]
   }
   ```
@@ -63,6 +72,49 @@ Reads take a shared lock. The slug embeds a random token, making gallery URLs
 unguessable; the slug is also validated (`gallery_file()`) before being used
 in a filesystem path.
 
+## Topics
+
+A gallery can be split into named sections — the days of a trip, the stops of a
+shoot. They are optional and additive, so a gallery file written before they
+existed is already valid: it has no `topics`, no image carries a `topic`, and it
+renders, uploads and archives exactly as it always did.
+
+The `images` array stays **one flat list in upload order**. The grouping is
+*derived*, by `gallery_groups()` in `app/storage.php`, wherever it is needed —
+the client gallery, the editor, and the ZIP layout all come from that one
+function, so they can never disagree. Nothing is nested on disk, which is what
+keeps the upload ordering (`batch`/`seq`, see below) untouched by any of this.
+
+- Images without a topic always render first, with no heading. Topics follow in
+  their stored order, each with a heading and a collapse toggle.
+- A `topic` naming a topic that no longer exists reads as *no topic*. A dangling
+  reference must never make a photo disappear from a client's gallery.
+- Guest uploads land in a topic with the reserved id `guest`, created on demand
+  inside `gallery_append_image()`'s lock — two guests uploading at the same
+  moment cannot each create their own. The admin may rename or reorder it; the
+  id stays, so later guest uploads keep landing there.
+- Assignment runs through `admin/topics-api.php` (drag-and-drop cannot reload the
+  page after every drop). Creating, renaming, reordering and deleting topics are
+  plain form posts on the editor page. Deleting a topic keeps its images; they
+  go back to having none.
+- In the ZIP, each topic is a folder and untopiced photos sit in the root
+  (`archive_entries()`). Filenames are deduped per folder, so the same
+  `DSC_0001.jpg` may appear once in every topic.
+
+## One-time migrations (app/migrate.php, admin/migrate.php)
+
+Schema changes have always been absorbed by lazy defaults (`$g['x'] ?? default`),
+and still are — the application never requires a migration to have been run.
+What `app/migrate.php` adds is a place to *finish* a change instead of leaving
+every gallery in one of two shapes for ever: numbered steps in `MIGRATIONS`, a
+`schema_version` recorded per gallery, and `admin/migrate.php` to run them.
+
+Nothing runs automatically. An FTP upload of new files must never be able to
+lock a site out of its own data, so the operator triggers the pass from the
+backoffice; the dashboard shows a banner while anything is outstanding. Every
+step must be idempotent, must write through `json_update()`, and must never
+delete anything.
+
 ## Image storage split
 
 | What | Where | Why |
@@ -102,7 +154,7 @@ admin.js                         api.php                    Hetzner S3
    │  POST multipart (original + thumb, one file) ─▶ │
    │                                                 │  PUT original ─────▶
    │                                                 │  PUT thumb ────────▶
-   │                                                 │  append to gallery JSON
+   │                                                 │  store in gallery JSON
    │ ◀──────────────────────── { ok, key, thumb, count }
 ```
 
@@ -134,18 +186,28 @@ root).
 **Why parallel.** Each request is store-and-forward: PHP buffers the whole body
 to a temp file before `s3_put_file()` starts, so during the webhost→S3 leg (and
 during every thumbnail decode) the browser's uplink sits idle. Overlapping a few
-requests keeps it saturated. Three things make that safe rather than merely
+requests keeps it saturated. Four things make that safe rather than merely
 faster:
 
 - Both endpoints call `session_write_close()` right after authenticating. PHP
   holds an exclusive lock on the session file for the whole request, so without
   it every parallel upload would queue behind the previous one and the uploader
   would be serial again regardless of how many requests it starts.
-- The gallery entry is appended via `gallery_append_image()` →`json_update()`,
+- The gallery entry is stored via `gallery_append_image()` →`json_update()`,
   which holds `flock(LOCK_EX)` on a sidecar `<file>.lock` across the whole
   read-modify-write. (The lock cannot live on the JSON file itself: `json_write()`
   replaces it by `rename()`, so the inode changes on every write.) Unlocked,
   eight simultaneous appends lose about five of them.
+- Gallery order is array order, and uploads finish in an order set by file size
+  and network luck — so the entry is *placed*, not appended. `admin.js` tags each
+  selection (one drop, or one trip through the file dialog) with a random
+  `batch` id and each file with its `seq` within it; `gallery_image_position()`
+  puts the arrival next to its siblings. A batch therefore occupies one
+  contiguous run: the first arrival appends at the end, later ones insert inside
+  that run, so a second selection dropped mid-upload stays separate and in
+  order, and a manual retry rejoins its original place. Entries stored before
+  this existed carry no `batch` and are never moved; an upload without usable
+  ordering (an older cached `admin.js`) simply appends.
 - Transient failures are retried on both sides — up to 3 attempts with backoff
   in `s3_put_file()` (re-signed and rewound per attempt) and in `admin.js` for
   network errors, 408, 429 and 5xx. 4xx is a real rejection and is never
@@ -201,7 +263,14 @@ never half-written. The rest is ordering:
 | abandoned | the queue entry survives; a build with no progress for `archive.abandon_hours` is aborted (freeing the multipart parts S3 bills for) and restarted |
 
 **Staying current.** Every stored or deleted image marks its gallery dirty
-(`archive_mark_dirty()`, hooked into `gallery_append_image()`). While a gallery is
+(`archive_mark_dirty()`, hooked into `gallery_append_image()`), as does anything
+that moves a photo between the ZIP's folders — assigning a topic, renaming one,
+reordering them. Staleness is decided by `archive_source_hash()`, which a build
+records and later compares against. For a gallery using no topics that hash is
+still taken over the image keys alone, exactly as before topics existed, so
+installing this version invalidates no archive that has already been built; once
+topics are in play it is taken over the ZIP paths instead, which covers keys,
+order, membership, topic order and topic names at once. While a gallery is
 dirty its download button renders disabled with a hover explanation, and
 `gallery/download.php` refuses too — a client must never receive a ZIP that
 silently omits the newest photos. Rebuilds are batched by

+ 26 - 0
docs/SETUP.md

@@ -45,6 +45,9 @@ a tight per-site process limit, lower it:
 'concurrency' => 2,   // or 1 to restore strictly serial uploads
 ```
 
+Gallery order does not depend on this: files are stored in the order they were
+selected whatever value you set, and whatever order the uploads finish in.
+
 If uploads start failing with 503s under load, that limit is the first thing to
 check.
 
@@ -151,6 +154,29 @@ works; otherwise `chmod 755` the directories (or `775`/`777` as a last resort).
    - tomorrow the gallery shows "not available" (expiry working).
 5. Delete the test gallery — the S3 objects are removed as well.
 
+## 5a. Updating an existing installation
+
+The application is a plain file tree, so an update is an upload plus one click.
+
+1. **Back up `data/`.** It is the whole database — a few hundred kilobytes.
+   Nothing below deletes anything, but there is no undo either.
+2. Upload the new files over the old ones. Do **not** upload `config/`,
+   `data/` or `media/` — those hold your configuration and content, and are
+   never overwritten by an update. New config keys are always optional and read
+   with defaults, so an existing `config/config.php` keeps working unchanged.
+3. Open **`/admin/` → Migration** and press *Run migration*.
+
+   The dashboard shows a banner while anything is outstanding. The step is safe
+   to run more than once and removes nothing: it brings each gallery's data file
+   up to the current format and reports what it did per gallery. Skipping it is
+   not fatal — old files keep being read correctly — but the site is only fully
+   converted once it has run.
+4. Reload a gallery page and the backoffice to confirm everything looks right.
+
+Existing ZIP archives are **not** invalidated by an update on its own, so no
+gallery starts a multi-gigabyte rebuild just because you deployed. Only an
+actual change to a gallery's photos or their arrangement does that.
+
 ## 6. Local development
 
 ```bash

+ 26 - 8
gallery/index.php

@@ -97,13 +97,31 @@ public_header(e($gallery['title']));
             <?php endif; ?>
         <?php endif; ?>
     </div>
-    <div class="grid">
-        <?php foreach ($gallery['images'] ?? [] as $img): ?>
-            <a href="<?= e(s3_presign_get($img['key'])) ?>">
-                <img src="<?= e(s3_presign_get($img['thumb'] ?? $img['key'])) ?>"
-                     alt="<?= e($img['name'] ?? '') ?>" loading="lazy">
-            </a>
-        <?php endforeach; ?>
-    </div>
+    <?php
+    // Topics are optional sections. A gallery without them yields exactly one
+    // group with no topic, so it renders the single bare .grid it always did.
+    foreach (gallery_groups($gallery) as $group):
+        $topic = $group['topic'];
+        $gridId = $topic === null ? null : 'topic-' . $topic['id'];
+        ?>
+        <?php if ($topic !== null): ?>
+            <h2 class="topic-head">
+                <button type="button" class="topic-toggle" aria-expanded="true"
+                        aria-controls="<?= e($gridId) ?>">
+                    <span class="topic-chevron" aria-hidden="true">&rsaquo;</span>
+                    <span class="topic-name"><?= e($topic['name']) ?></span>
+                    <span class="topic-count"><?= count($group['images']) ?></span>
+                </button>
+            </h2>
+        <?php endif; ?>
+        <div class="grid"<?= $gridId !== null ? ' id="' . e($gridId) . '"' : '' ?>>
+            <?php foreach ($group['images'] as $img): ?>
+                <a href="<?= e(s3_presign_get($img['key'])) ?>">
+                    <img src="<?= e(s3_presign_get($img['thumb'] ?? $img['key'])) ?>"
+                         alt="<?= e($img['name'] ?? '') ?>" loading="lazy">
+                </a>
+            <?php endforeach; ?>
+        </div>
+    <?php endforeach; ?>
 </main>
 <?php public_footer(); ?>

+ 10 - 2
upload-api.php

@@ -4,12 +4,18 @@
  * on upload.php. Same one-multipart-POST-per-image contract as admin/api.php, but
  * authenticated by the per-gallery upload key instead of an admin session.
  *
- * Fields: slug, key, original (required), thumb (optional). Access requires the
+ * Fields: slug, key, original (required), thumb, batch, seq (optional; batch and
+ * seq carry the file's place in the visitor's selection, so parallel uploads are
+ * stored in the order they were picked). Access requires the
  * gallery to have guest uploads enabled, the key to match, the gallery to be
  * unexpired, and — if the gallery has a password — the visitor to have unlocked
  * it in this session (via upload.php). Any failure returns a uniform 403.
  *
  * Uploads are image-only here, so a public link cannot store arbitrary files.
+ *
+ * Guest uploads always land in the gallery's guest topic, created on demand.
+ * The topic is decided here, never taken from the request, so a guest cannot
+ * drop photos into the photographer's own sections.
  */
 require __DIR__ . '/app/bootstrap.php';
 
@@ -55,6 +61,8 @@ if (!$authorized) {
     $gallery,
     $_FILES['original'] ?? null,
     $_FILES['thumb'] ?? null,
-    true
+    true,
+    $_POST,
+    GALLERY_GUEST_TOPIC
 );
 json_response($payload, $status);