gallery-edit.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. <?php
  2. /**
  3. * Per-gallery editor: settings, share link, proxied bulk uploader
  4. * (browser → webhost → S3), and image removal.
  5. */
  6. require dirname(__DIR__) . '/app/bootstrap.php';
  7. auth_require();
  8. $gallery = gallery_load((string)($_GET['g'] ?? ''));
  9. if ($gallery === null) {
  10. flash_set('Gallery not found.', 'error');
  11. redirect('galleries.php');
  12. }
  13. $slug = $gallery['slug'];
  14. if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  15. csrf_verify();
  16. $action = $_POST['action'] ?? '';
  17. if ($action === 'settings') {
  18. $gallery['title'] = trim((string)($_POST['title'] ?? '')) ?: $gallery['title'];
  19. $gallery['expires_at'] = trim((string)($_POST['expires_at'] ?? '')) ?: null;
  20. // Unlike the fields above there is no keep-current fallback: the select
  21. // always posts, and an empty value genuinely means "back to Original".
  22. $gallery['max_resolution'] = parse_max_resolution($_POST);
  23. // Same for the checkbox: unticked means it is simply absent from $_POST.
  24. $gallery['strip_exif'] = !empty($_POST['strip_exif']);
  25. if (!empty($_POST['remove_password'])) {
  26. $gallery['password_hash'] = null;
  27. } elseif (($pw = (string)($_POST['password'] ?? '')) !== '') {
  28. $gallery['password_hash'] = password_hash($pw, PASSWORD_DEFAULT);
  29. }
  30. gallery_save($gallery);
  31. flash_set('Gallery settings saved.');
  32. redirect('gallery-edit.php?g=' . rawurlencode($slug));
  33. }
  34. if ($action === 'uploads') {
  35. $mode = (string)($_POST['mode'] ?? '');
  36. if ($mode === 'enable' && empty($gallery['upload_key'])) {
  37. $gallery['upload_key'] = random_token(24);
  38. gallery_save($gallery);
  39. flash_set('Guest uploads enabled.');
  40. } elseif ($mode === 'regenerate') {
  41. $gallery['upload_key'] = random_token(24);
  42. gallery_save($gallery);
  43. flash_set('New upload link generated; the old link no longer works.');
  44. } elseif ($mode === 'disable') {
  45. $gallery['upload_key'] = null;
  46. gallery_save($gallery);
  47. flash_set('Guest uploads disabled.');
  48. }
  49. redirect('gallery-edit.php?g=' . rawurlencode($slug));
  50. }
  51. if ($action === 'downloads') {
  52. $gallery['downloads_enabled'] = !empty($_POST['enabled']);
  53. gallery_save($gallery);
  54. if ($gallery['downloads_enabled']) {
  55. // Queue the first build; the background worker picks it up once the
  56. // gallery has been quiet for archive.settle_seconds.
  57. archive_mark_dirty($slug, $gallery);
  58. flash_set('Downloads enabled. The archive will be built in the background.');
  59. } else {
  60. archive_delete($slug);
  61. flash_set('Downloads disabled and the archive removed.');
  62. }
  63. redirect('gallery-edit.php?g=' . rawurlencode($slug));
  64. }
  65. // Topic management. Assignment is not here: it runs through topics-api.php,
  66. // because drag-and-drop cannot reload the page after every drop.
  67. if (str_starts_with($action, 'topic-')) {
  68. $id = (string)($_POST['id'] ?? '');
  69. if ($action === 'topic-add') {
  70. $added = gallery_topic_add($slug, (string)($_POST['name'] ?? ''));
  71. flash_set(
  72. $added !== null ? 'Topic "' . $added['name'] . '" added.' : 'Give the topic a name.',
  73. $added !== null ? 'ok' : 'error'
  74. );
  75. } elseif ($action === 'topic-rename') {
  76. gallery_topic_rename($slug, $id, (string)($_POST['name'] ?? ''));
  77. flash_set('Topic renamed.');
  78. } elseif ($action === 'topic-delete') {
  79. // The images survive; they simply stop belonging to a topic and
  80. // reappear in the untopiced section at the top.
  81. gallery_topic_delete($slug, $id);
  82. flash_set('Topic removed. Its images are now without a topic.');
  83. } elseif ($action === 'topic-move') {
  84. gallery_topic_move($slug, $id, (string)($_POST['dir'] ?? ''));
  85. }
  86. redirect('gallery-edit.php?g=' . rawurlencode($slug));
  87. }
  88. if ($action === 'delete-image') {
  89. $key = (string)($_POST['key'] ?? '');
  90. foreach ($gallery['images'] ?? [] as $i => $img) {
  91. if (($img['key'] ?? '') === $key) {
  92. s3_delete($img['key']);
  93. if (!empty($img['thumb'])) {
  94. s3_delete($img['thumb']);
  95. }
  96. array_splice($gallery['images'], $i, 1);
  97. gallery_save($gallery);
  98. // The archive no longer matches the gallery's contents.
  99. archive_mark_dirty($slug, $gallery);
  100. flash_set('Image deleted.');
  101. break;
  102. }
  103. }
  104. redirect('gallery-edit.php?g=' . rawurlencode($slug));
  105. }
  106. }
  107. // Build the share link from the page the admin is currently on, not from config,
  108. // so it matches whatever host/path this app is actually served under.
  109. $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
  110. $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
  111. // The site root is one directory up from admin/ (cf. the "../gallery/" link below).
  112. $basePath = str_replace('\\', '/', dirname(dirname($_SERVER['SCRIPT_NAME'] ?? '/admin/gallery-edit.php')));
  113. $basePath = rtrim($basePath, '/');
  114. $shareUrl = $scheme . '://' . $host . $basePath . '/gallery/?g=' . rawurlencode($slug);
  115. // Guest upload link (only when a key is set). Same host/path derivation as above.
  116. $uploadUrl = !empty($gallery['upload_key'])
  117. ? $scheme . '://' . $host . $basePath . '/upload.php?g=' . rawurlencode($slug)
  118. . '&k=' . rawurlencode($gallery['upload_key'])
  119. : '';
  120. // Topics split the gallery into sections; a gallery with none renders exactly
  121. // as it did before they existed.
  122. $topics = gallery_topics($gallery);
  123. admin_header($gallery['title'], 'galleries');
  124. flash_render();
  125. ?>
  126. <h1><?= e($gallery['title']) ?></h1>
  127. <p class="help" style="margin-bottom:1.5rem">
  128. Share link: <a href="../gallery/?g=<?= e(rawurlencode($slug)) ?>" target="_blank" rel="noopener"><?= e($shareUrl) ?></a>
  129. </p>
  130. <div class="card">
  131. <h2 style="margin-top:0">Upload images</h2>
  132. <?php if ($topics !== []): ?>
  133. <!-- Uploading straight into a topic; the alternative is assigning a few
  134. hundred photos one at a time after the fact. -->
  135. <label for="upload-topic">Upload into</label>
  136. <select id="upload-topic">
  137. <option value="">No topic</option>
  138. <?php foreach ($topics as $topic): ?>
  139. <option value="<?= e($topic['id']) ?>"><?= e($topic['name']) ?></option>
  140. <?php endforeach; ?>
  141. </select>
  142. <?php endif; ?>
  143. <div class="dropzone" id="dropzone"
  144. data-api="api.php"
  145. data-slug="<?= e($slug) ?>"
  146. data-csrf="<?= e(csrf_token()) ?>"
  147. data-thumb-size="<?= (int)config('uploads.thumb_size', 600) ?>"
  148. data-thumb-quality="<?= e((string)config('uploads.thumb_quality', 0.8)) ?>"
  149. data-concurrency="<?= (int)config('uploads.concurrency', 3) ?>"
  150. data-max-resolution="<?= (int)($gallery['max_resolution'] ?? 0) ?>"
  151. data-resize-quality="<?= e((string)config('uploads.resize_quality', 0.9)) ?>">
  152. Drop images here or click to select.<br>
  153. <small>Uploaded through the site to S3, <?= e(upload_treatment_text($gallery)) ?>.</small>
  154. </div>
  155. <input type="file" id="file-input" accept="image/*" multiple style="display:none">
  156. <div class="upload-list" id="upload-list"></div>
  157. </div>
  158. <div class="card">
  159. <h2 style="margin-top:0">Guest uploads</h2>
  160. <?php if ($uploadUrl !== ''): ?>
  161. <p class="help" style="margin-bottom:1rem">
  162. Share this link so guests can upload into this gallery without an admin
  163. account. It is key-protected and honors the gallery's password and
  164. expiry, if set.
  165. </p>
  166. <p style="margin-bottom:1rem">
  167. <a href="<?= e($uploadUrl) ?>" target="_blank" rel="noopener"><?= e($uploadUrl) ?></a>
  168. </p>
  169. <form method="post" style="display:inline"
  170. onsubmit="return confirm('Generate a new link? The current link will stop working.')">
  171. <?= csrf_field() ?>
  172. <input type="hidden" name="action" value="uploads">
  173. <input type="hidden" name="mode" value="regenerate">
  174. <button style="margin:0">Regenerate link</button>
  175. </form>
  176. <form method="post" style="display:inline"
  177. onsubmit="return confirm('Disable guest uploads? The link will stop working.')">
  178. <?= csrf_field() ?>
  179. <input type="hidden" name="action" value="uploads">
  180. <input type="hidden" name="mode" value="disable">
  181. <button class="btn-danger" style="margin:0">Disable</button>
  182. </form>
  183. <?php else: ?>
  184. <p class="help" style="margin-bottom:1rem">
  185. Guest uploads are disabled. Enable them to get a shareable link that
  186. lets people upload into this gallery without an admin account.
  187. </p>
  188. <form method="post">
  189. <?= csrf_field() ?>
  190. <input type="hidden" name="action" value="uploads">
  191. <input type="hidden" name="mode" value="enable">
  192. <button style="margin:0">Enable guest uploads</button>
  193. </form>
  194. <?php endif; ?>
  195. </div>
  196. <?php $status = archive_status($gallery); ?>
  197. <div class="card" id="archive-card"
  198. data-api="archive-api.php"
  199. data-slug="<?= e($slug) ?>"
  200. data-csrf="<?= e(csrf_token()) ?>">
  201. <h2 style="margin-top:0">Download all</h2>
  202. <p class="help" style="margin-bottom:1rem">
  203. Offers visitors a single ZIP of every photo. It is assembled once and
  204. stored on S3, so the download itself never runs through this webhost —
  205. and it is rebuilt automatically whenever images are added or removed.
  206. While a rebuild is pending the button on the gallery is disabled, so
  207. nobody receives an archive that is missing the newest photos.
  208. </p>
  209. <form method="post" style="margin-bottom:1rem">
  210. <?= csrf_field() ?>
  211. <input type="hidden" name="action" value="downloads">
  212. <input type="hidden" name="enabled" value="<?= empty($gallery['downloads_enabled']) ? '1' : '0' ?>">
  213. <?php if (empty($gallery['downloads_enabled'])): ?>
  214. <button style="margin:0">Enable downloads</button>
  215. <?php else: ?>
  216. <button class="btn-danger" style="margin:0"
  217. onclick="return confirm('Disable downloads and delete the archive from S3?')">
  218. Disable downloads
  219. </button>
  220. <?php endif; ?>
  221. </form>
  222. <?php if (!empty($gallery['downloads_enabled'])): ?>
  223. <p id="archive-status" class="help" style="margin-bottom:.6rem">
  224. <?php if ($status['archive'] && !$status['stale']): ?>
  225. Ready · <?= e(human_bytes((int)$status['archive']['size'])) ?>
  226. · <?= (int)$status['archive']['count'] ?> photos
  227. · built <?= e($status['archive']['built_at']) ?>
  228. <?php elseif ($status['building']): ?>
  229. Building — <?= (int)$status['done'] ?> of <?= (int)$status['total'] ?> photos done.
  230. <?php elseif ($status['queued'] && $status['due_in'] > 0): ?>
  231. Queued — the rebuild starts in about <?= (int)ceil($status['due_in'] / 60) ?> min.
  232. <?php elseif ($status['queued']): ?>
  233. Queued — the rebuild starts shortly.
  234. <?php elseif ($status['archive']): ?>
  235. Out of date — the archive does not match the current images.
  236. <?php else: ?>
  237. No archive yet.
  238. <?php endif; ?>
  239. </p>
  240. <div id="archive-bar" class="archive-bar" hidden><span></span></div>
  241. <button id="archive-build" style="margin:0"
  242. <?= $status['building'] ? 'data-resume="1"' : '' ?>>
  243. <?= $status['building'] ? 'Resume build' : ($status['archive'] ? 'Rebuild now' : 'Build now') ?>
  244. </button>
  245. <button id="archive-cancel" class="btn-ghost" style="margin:0" hidden>Cancel</button>
  246. <script src="../assets/archive.js" defer></script>
  247. <?php endif; ?>
  248. </div>
  249. <form method="post" class="card">
  250. <?= csrf_field() ?>
  251. <input type="hidden" name="action" value="settings">
  252. <h2 style="margin-top:0">Settings</h2>
  253. <label for="t">Title</label>
  254. <input type="text" id="t" name="title" value="<?= e($gallery['title']) ?>">
  255. <label for="ex">Expiry date (blank = never)</label>
  256. <input type="date" id="ex" name="expires_at" value="<?= e($gallery['expires_at'] ?? '') ?>">
  257. <?php resolution_field(isset($gallery['max_resolution']) ? (int)$gallery['max_resolution'] : null) ?>
  258. <?php strip_exif_field(!empty($gallery['strip_exif'])) ?>
  259. <label for="p">Set new password (blank = keep current)</label>
  260. <input type="text" id="p" name="password" autocomplete="off">
  261. <?php if (!empty($gallery['password_hash'])): ?>
  262. <p class="help"><label style="display:inline;text-transform:none;letter-spacing:0">
  263. <input type="checkbox" name="remove_password" value="1"> Remove password protection
  264. </label></p>
  265. <?php endif; ?>
  266. <button type="submit">Save settings</button>
  267. </form>
  268. <div class="card">
  269. <h2 style="margin-top:0">Topics</h2>
  270. <p class="help" style="margin-bottom:1rem">
  271. Topics split the gallery into sections — the days of a trip, the stops of
  272. a shoot. They are optional: images without a topic always come first, and
  273. a gallery with no topics looks exactly as it always did. Guests upload
  274. into "<?= e(GALLERY_GUEST_TOPIC_NAME) ?>", which appears by itself the
  275. first time someone uses the guest link.
  276. </p>
  277. <?php if ($topics !== []): ?>
  278. <table style="margin-bottom:1rem">
  279. <tr><th>Topic</th><th style="width:180px">Order</th><th style="width:90px"></th></tr>
  280. <?php foreach ($topics as $i => $topic): ?>
  281. <tr>
  282. <td>
  283. <form method="post" style="display:flex;gap:.5rem;align-items:center"><?= csrf_field() ?>
  284. <input type="hidden" name="action" value="topic-rename">
  285. <input type="hidden" name="id" value="<?= e($topic['id']) ?>">
  286. <input type="text" name="name" value="<?= e($topic['name']) ?>"
  287. maxlength="80" style="margin:0;flex:1">
  288. <button class="btn-ghost" style="margin:0;padding:.3rem .7rem">Rename</button>
  289. </form>
  290. </td>
  291. <td>
  292. <form method="post" style="display:inline"><?= csrf_field() ?>
  293. <input type="hidden" name="action" value="topic-move">
  294. <input type="hidden" name="id" value="<?= e($topic['id']) ?>">
  295. <input type="hidden" name="dir" value="up">
  296. <button class="btn-ghost" style="margin:0;padding:.3rem .7rem" <?= $i === 0 ? 'disabled' : '' ?>>↑</button>
  297. </form>
  298. <form method="post" style="display:inline"><?= csrf_field() ?>
  299. <input type="hidden" name="action" value="topic-move">
  300. <input type="hidden" name="id" value="<?= e($topic['id']) ?>">
  301. <input type="hidden" name="dir" value="down">
  302. <button class="btn-ghost" style="margin:0;padding:.3rem .7rem" <?= $i === count($topics) - 1 ? 'disabled' : '' ?>>↓</button>
  303. </form>
  304. </td>
  305. <td>
  306. <form method="post" style="display:inline"
  307. onsubmit="return confirm('Remove this topic? Its images are kept and move back to no topic.')"><?= csrf_field() ?>
  308. <input type="hidden" name="action" value="topic-delete">
  309. <input type="hidden" name="id" value="<?= e($topic['id']) ?>">
  310. <button class="btn-danger" style="margin:0;padding:.3rem .7rem">Delete</button>
  311. </form>
  312. </td>
  313. </tr>
  314. <?php endforeach; ?>
  315. </table>
  316. <?php endif; ?>
  317. <form method="post"><?= csrf_field() ?>
  318. <input type="hidden" name="action" value="topic-add">
  319. <label for="topic-name">New topic</label>
  320. <input type="text" id="topic-name" name="name" maxlength="80" placeholder="e.g. Day 1 – Reykjavík">
  321. <button type="submit">Add topic</button>
  322. </form>
  323. </div>
  324. <h2>Images (<span id="img-count"><?= count($gallery['images'] ?? []) ?></span>)</h2>
  325. <?php
  326. // Grouped for editing, empty topics included: an empty section is still a drop
  327. // target, and without it a new topic could never receive its first image.
  328. $groups = gallery_groups($gallery, true);
  329. // Position in the flat images array, which is what decides the order inside a
  330. // section. Handed to the browser so a dragged image can be dropped into the
  331. // place the page will show it in after the next reload, rather than at the end.
  332. $order = array_flip(array_column($gallery['images'] ?? [], 'key'));
  333. ?>
  334. <div class="topic-blocks" id="topic-blocks"
  335. data-api="topics-api.php"
  336. data-slug="<?= e($slug) ?>"
  337. data-csrf="<?= e(csrf_token()) ?>">
  338. <?php foreach ($groups as $group): $topic = $group['topic']; ?>
  339. <section class="topic-block" data-topic="<?= e($topic['id'] ?? '') ?>">
  340. <?php if ($topics !== []): ?>
  341. <h3 class="topic-block-head">
  342. <?= $topic === null ? 'No topic' : e($topic['name']) ?>
  343. <span class="topic-count"><?= count($group['images']) ?></span>
  344. </h3>
  345. <?php endif; ?>
  346. <div class="thumb-row">
  347. <?php foreach ($group['images'] as $img): ?>
  348. <figure draggable="true" data-key="<?= e($img['key']) ?>" data-order="<?= (int)($order[$img['key']] ?? 0) ?>">
  349. <img src="<?= e(s3_presign_get($img['thumb'] ?? $img['key'])) ?>" alt="" loading="lazy" draggable="false">
  350. <figcaption title="<?= e($img['name'] ?? '') ?>"><?= e($img['name'] ?? '') ?></figcaption>
  351. <button type="button" class="thumb-menu-btn" aria-haspopup="true" aria-expanded="false"
  352. title="Assign topic or delete">⋯</button>
  353. <div class="thumb-menu" hidden>
  354. <?php if ($topics !== []): ?>
  355. <p class="thumb-menu-label">Assign topic</p>
  356. <button type="button" class="topic-pick<?= $topic === null ? ' is-current' : '' ?>" data-topic="">No topic</button>
  357. <?php foreach ($topics as $t): ?>
  358. <button type="button" class="topic-pick<?= ($topic['id'] ?? null) === $t['id'] ? ' is-current' : '' ?>"
  359. data-topic="<?= e($t['id']) ?>"><?= e($t['name']) ?></button>
  360. <?php endforeach; ?>
  361. <?php else: ?>
  362. <p class="thumb-menu-label">Add a topic above to sort images into sections.</p>
  363. <?php endif; ?>
  364. <!-- Deletion stays a real form post: same server path, same
  365. confirmation, and it keeps working without the menu JS. -->
  366. <form method="post" onsubmit="return confirm('Delete this image from S3?')">
  367. <?= csrf_field() ?>
  368. <input type="hidden" name="action" value="delete-image">
  369. <input type="hidden" name="key" value="<?= e($img['key']) ?>">
  370. <button class="thumb-menu-danger">Delete</button>
  371. </form>
  372. </div>
  373. </figure>
  374. <?php endforeach; ?>
  375. </div>
  376. </section>
  377. <?php endforeach; ?>
  378. </div>
  379. <script src="../assets/admin.js"></script>
  380. <script src="../assets/topics.js"></script>
  381. <?php admin_footer(); ?>