Medowar 1 mēnesi atpakaļ
vecāks
revīzija
e9ab959d43
20 mainītis faili ar 1478 papildinājumiem un 57 dzēšanām
  1. 7 1
      README.md
  2. 11 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. 9 2
      app/s3.php
  11. 315 2
      app/storage.php
  12. 26 4
      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. 63 4
      docs/ARCHITECTURE.md
  18. 23 0
      docs/SETUP.md
  19. 26 8
      gallery/index.php
  20. 6 1
      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
 

+ 11 - 1
admin/api.php

@@ -15,6 +15,7 @@
  *   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.
  */
@@ -48,12 +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,
     false,
-    $_POST
+    $_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;
+}

+ 9 - 2
app/s3.php

@@ -647,6 +647,12 @@ function upload_order_fields(array $fields): array
  * 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).
@@ -656,7 +662,8 @@ function gallery_store_s3_upload(
     ?array $original,
     ?array $thumb,
     bool $imagesOnly = false,
-    array $fields = []
+    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))]];
@@ -708,7 +715,7 @@ function gallery_store_s3_upload(
         'thumb' => $thumbKey,
         'name'  => substr((string)($original['name'] ?? basename($key)), 0, 200),
         'size'  => (int)($original['size'] ?? 0),
-    ] + upload_order_fields($fields));
+    ] + 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.

+ 315 - 2
app/storage.php

@@ -328,17 +328,30 @@ function gallery_image_position(array $images, array $image): ?int
  * 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
         }
+        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) {
@@ -357,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',

+ 26 - 4
assets/admin.js

@@ -29,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;
@@ -55,13 +58,29 @@
     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 = ''; }
@@ -85,7 +104,9 @@
             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, batch: batch, seq: i });
+            /* 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();
     }
@@ -267,6 +288,7 @@
             // 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.

+ 63 - 4
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 |
@@ -211,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

+ 23 - 0
docs/SETUP.md

@@ -154,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(); ?>

+ 6 - 1
upload-api.php

@@ -12,6 +12,10 @@
  * 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';
 
@@ -58,6 +62,7 @@ if (!$authorized) {
     $_FILES['original'] ?? null,
     $_FILES['thumb'] ?? null,
     true,
-    $_POST
+    $_POST,
+    GALLERY_GUEST_TOPIC
 );
 json_response($payload, $status);