Просмотр исходного кода

Admin backoffice: login, dashboard, front page, showreel, galleries, direct-to-S3 uploader

- Session auth with brute-force throttling and online password change
- Front page editor (intro text + full-res hero upload)
- Showreel manager with ordering
- Gallery CRUD with optional password and expiry date
- Browser-to-S3 bulk uploads via presigned PUTs; client-side canvas
  thumbnails so originals are never modified

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Medowar 1 месяц назад
Родитель
Сommit
219bd2fca3

+ 40 - 0
app/storage.php

@@ -66,6 +66,46 @@ function slugify(string $title): string
     return $s !== '' ? $s : 'gallery';
     return $s !== '' ? $s : 'gallery';
 }
 }
 
 
+// ---------------------------------------------------------------------------
+// Local media (hero + showreel images in public/media/)
+// ---------------------------------------------------------------------------
+
+const MEDIA_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif'];
+
+/**
+ * Store one uploaded image in public/media/, full resolution, unmodified.
+ * Returns the stored filename, or null if the upload is invalid.
+ */
+function media_store_upload(array $file): ?string
+{
+    if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
+        return null;
+    }
+    $ext = strtolower(pathinfo($file['name'] ?? '', PATHINFO_EXTENSION));
+    if (!in_array($ext, MEDIA_EXTENSIONS, true)) {
+        return null;
+    }
+    // Cheap content sanity check without touching the image data.
+    if (function_exists('getimagesize') && @getimagesize($file['tmp_name']) === false) {
+        return null;
+    }
+    $base = pathinfo($file['name'], PATHINFO_FILENAME);
+    $base = preg_replace('/[^A-Za-z0-9._-]+/', '-', $base) ?: 'image';
+    $name = substr($base, 0, 60) . '-' . random_token(6) . '.' . $ext;
+    if (!move_uploaded_file($file['tmp_name'], MEDIA_DIR . '/' . $name)) {
+        return null;
+    }
+    return $name;
+}
+
+/** Delete a local media file (filename only, no paths). */
+function media_delete(string $name): void
+{
+    if ($name !== '' && basename($name) === $name) {
+        @unlink(MEDIA_DIR . '/' . $name);
+    }
+}
+
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 // Site content (landing page + showreel)
 // Site content (landing page + showreel)
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------

+ 1 - 1
config/credentials.sample.php

@@ -9,5 +9,5 @@
  */
  */
 return [
 return [
     'username'      => 'admin',
     'username'      => 'admin',
-    'password_hash' => '$2y$10$Y6vXsF0ykl0S8PbW3rSPPeK1ZBCwYpuBABv3JCVSt17c40cA8B4C6',
+    'password_hash' => '$2y$12$oLK/BpvD7Opj0.VlxbkMiu1QrqD5wQi.e5HCKIyOjdroMwF503Gi.',
 ];
 ];

+ 71 - 0
public/admin/api.php

@@ -0,0 +1,71 @@
+<?php
+/**
+ * Admin JSON API used by the browser-side uploader (assets/admin.js).
+ *
+ * POST JSON body: { "action": "...", ... } with X-CSRF-Token header.
+ *
+ * Actions:
+ *   presign  { slug, name }
+ *     → presigned PUT URLs for the full-resolution original and its
+ *       browser-generated thumbnail.
+ *   register { slug, key, thumb, name, size }
+ *     → append an uploaded image to the gallery's JSON file.
+ */
+require dirname(__DIR__, 2) . '/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();
+
+$body = json_decode((string)file_get_contents('php://input'), true) ?: [];
+$action = (string)($body['action'] ?? '');
+
+$gallery = gallery_load((string)($body['slug'] ?? ''));
+if ($gallery === null) {
+    json_response(['error' => 'Unknown gallery'], 404);
+}
+$slug = $gallery['slug'];
+
+switch ($action) {
+    case 'presign':
+        $name = basename((string)($body['name'] ?? ''));
+        $name = preg_replace('/[^A-Za-z0-9._-]+/', '-', $name) ?: 'file';
+        $name = substr($name, 0, 120);
+        // Random prefix avoids overwrites when two files share a name.
+        $token = random_token(6);
+        $key   = "galleries/$slug/originals/$token-$name";
+        $thumb = "galleries/$slug/thumbs/$token-$name.jpg";
+        json_response([
+            'key'          => $key,
+            'thumb'        => $thumb,
+            'put_original' => s3_presign_put($key, 3600),
+            'put_thumb'    => s3_presign_put($thumb, 3600),
+        ]);
+
+    case 'register':
+        $key   = (string)($body['key'] ?? '');
+        $thumb = (string)($body['thumb'] ?? '');
+        if (!str_starts_with($key, "galleries/$slug/")) {
+            json_response(['error' => 'Key does not belong to this gallery'], 400);
+        }
+        if ($thumb !== '' && !str_starts_with($thumb, "galleries/$slug/")) {
+            json_response(['error' => 'Thumb key does not belong to this gallery'], 400);
+        }
+        // Re-load under current state to reduce lost updates between requests.
+        $gallery = gallery_load($slug);
+        $gallery['images'][] = [
+            'key'   => $key,
+            'thumb' => $thumb !== '' ? $thumb : null,
+            'name'  => substr((string)($body['name'] ?? basename($key)), 0, 200),
+            'size'  => (int)($body['size'] ?? 0),
+        ];
+        gallery_save($gallery);
+        json_response(['ok' => true, 'count' => count($gallery['images'])]);
+
+    default:
+        json_response(['error' => 'Unknown action'], 400);
+}

+ 59 - 0
public/admin/frontpage.php

@@ -0,0 +1,59 @@
+<?php
+/**
+ * Front page editor: artist intro title/text + full-page hero image.
+ */
+require dirname(__DIR__, 2) . '/app/bootstrap.php';
+auth_require();
+
+$site = site_get();
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    csrf_verify();
+
+    $site['intro_title'] = trim((string)($_POST['intro_title'] ?? ''));
+    $site['intro_text']  = trim((string)($_POST['intro_text'] ?? ''));
+
+    if (!empty($_FILES['hero']['name'])) {
+        $stored = media_store_upload($_FILES['hero']);
+        if ($stored === null) {
+            flash_set('Hero image upload failed — use a JPG/PNG/WebP/AVIF file.', 'error');
+            site_save($site);
+            redirect('frontpage.php');
+        }
+        if (!empty($site['hero_image'])) {
+            media_delete($site['hero_image']);
+        }
+        $site['hero_image'] = $stored;
+    }
+
+    site_save($site);
+    flash_set('Front page saved.');
+    redirect('frontpage.php');
+}
+
+admin_header('Front page', 'frontpage');
+flash_render();
+?>
+<h1>Front page</h1>
+<form method="post" enctype="multipart/form-data" class="card">
+    <?= csrf_field() ?>
+    <label for="t">Artist name / title</label>
+    <input type="text" id="t" name="intro_title" value="<?= e($site['intro_title']) ?>">
+
+    <label for="x">Introduction text</label>
+    <textarea id="x" name="intro_text"><?= e($site['intro_text']) ?></textarea>
+    <p class="help">Shown below the hero image. Line breaks are kept.</p>
+
+    <label for="h">Hero image (full-page background)</label>
+    <?php if (!empty($site['hero_image'])): ?>
+        <p class="help" style="margin-bottom:.6rem">
+            Current: <?= e($site['hero_image']) ?> —
+            <a href="../media/<?= e(rawurlencode($site['hero_image'])) ?>" target="_blank" rel="noopener">view ↗</a>
+        </p>
+    <?php endif; ?>
+    <input type="file" id="h" name="hero" accept="image/*">
+    <p class="help">Uploaded in full resolution, unmodified. Choose a file to replace the current image.</p>
+
+    <button type="submit">Save</button>
+</form>
+<?php admin_footer(); ?>

+ 95 - 0
public/admin/galleries.php

@@ -0,0 +1,95 @@
+<?php
+/**
+ * Gallery overview: create new galleries, list and delete existing ones.
+ */
+require dirname(__DIR__, 2) . '/app/bootstrap.php';
+auth_require();
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    csrf_verify();
+    $action = $_POST['action'] ?? '';
+
+    if ($action === 'create') {
+        $title = trim((string)($_POST['title'] ?? ''));
+        if ($title === '') {
+            flash_set('Please enter a gallery title.', 'error');
+            redirect('galleries.php');
+        }
+        $slug = slugify($title) . '-' . random_token(6);
+        $password = (string)($_POST['password'] ?? '');
+        $gallery = [
+            'slug'          => $slug,
+            'title'         => $title,
+            'created_at'    => date('Y-m-d H:i:s'),
+            'password_hash' => $password !== '' ? password_hash($password, PASSWORD_DEFAULT) : null,
+            'expires_at'    => trim((string)($_POST['expires_at'] ?? '')) ?: null,
+            'images'        => [],
+        ];
+        gallery_save($gallery);
+        flash_set('Gallery created. Now add images.');
+        redirect('gallery-edit.php?g=' . rawurlencode($slug));
+    }
+
+    if ($action === 'delete') {
+        $gallery = gallery_load((string)($_POST['slug'] ?? ''));
+        if ($gallery !== null) {
+            s3_delete_gallery_objects($gallery);
+            gallery_delete($gallery['slug']);
+            flash_set('Gallery and its S3 images deleted.');
+        }
+        redirect('galleries.php');
+    }
+}
+
+$galleries = galleries_all();
+
+admin_header('Galleries', 'galleries');
+flash_render();
+?>
+<h1>Galleries</h1>
+
+<form method="post" class="card">
+    <?= csrf_field() ?>
+    <input type="hidden" name="action" value="create">
+    <h2 style="margin-top:0">New gallery</h2>
+    <label for="t">Title</label>
+    <input type="text" id="t" name="title" placeholder="Wedding Miller — June 2026" required>
+    <label for="p">Password <span style="text-transform:none;letter-spacing:0">(optional — leave blank for a public link)</span></label>
+    <input type="text" id="p" name="password" autocomplete="off">
+    <label for="ex">Expiry date <span style="text-transform:none;letter-spacing:0">(optional — gallery is hidden after this day)</span></label>
+    <input type="date" id="ex" name="expires_at">
+    <button type="submit">Create gallery</button>
+</form>
+
+<h2>Existing galleries (<?= count($galleries) ?>)</h2>
+<?php if (!$galleries): ?>
+    <p class="help">No galleries yet.</p>
+<?php else: ?>
+<div class="card"><table>
+    <tr><th>Title</th><th>Images</th><th>Protection</th><th>Expires</th><th>Created</th><th style="width:200px">Actions</th></tr>
+    <?php foreach ($galleries as $g): $expired = gallery_is_expired($g); ?>
+    <tr>
+        <td><?= e($g['title']) ?></td>
+        <td><?= count($g['images'] ?? []) ?></td>
+        <td><?= !empty($g['password_hash']) ? '<span class="tag tag-lock">password</span>' : '<span class="tag">open</span>' ?></td>
+        <td>
+            <?= e($g['expires_at'] ?? '—') ?>
+            <?= $expired ? ' <span class="tag tag-expired">expired</span>' : '' ?>
+        </td>
+        <td><?= e(substr($g['created_at'] ?? '', 0, 10)) ?></td>
+        <td>
+            <a href="gallery-edit.php?g=<?= e(rawurlencode($g['slug'])) ?>">Edit</a> ·
+            <a href="../gallery.php?g=<?= e(rawurlencode($g['slug'])) ?>" target="_blank" rel="noopener">View ↗</a>
+            <form method="post" style="display:inline"
+                  onsubmit="return confirm('Delete this gallery AND all its images on S3? This cannot be undone.')">
+                <?= csrf_field() ?>
+                <input type="hidden" name="action" value="delete">
+                <input type="hidden" name="slug" value="<?= e($g['slug']) ?>">
+                <button class="btn-danger" style="margin:0;padding:.25rem .7rem">Delete</button>
+            </form>
+        </td>
+    </tr>
+    <?php endforeach; ?>
+</table></div>
+<?php endif; ?>
+<?php admin_footer(); ?>

+ 111 - 0
public/admin/gallery-edit.php

@@ -0,0 +1,111 @@
+<?php
+/**
+ * Per-gallery editor: settings, share link, direct-to-S3 bulk uploader,
+ * and image removal.
+ */
+require dirname(__DIR__, 2) . '/app/bootstrap.php';
+auth_require();
+
+$gallery = gallery_load((string)($_GET['g'] ?? ''));
+if ($gallery === null) {
+    flash_set('Gallery not found.', 'error');
+    redirect('galleries.php');
+}
+$slug = $gallery['slug'];
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    csrf_verify();
+    $action = $_POST['action'] ?? '';
+
+    if ($action === 'settings') {
+        $gallery['title'] = trim((string)($_POST['title'] ?? '')) ?: $gallery['title'];
+        $gallery['expires_at'] = trim((string)($_POST['expires_at'] ?? '')) ?: null;
+        if (!empty($_POST['remove_password'])) {
+            $gallery['password_hash'] = null;
+        } elseif (($pw = (string)($_POST['password'] ?? '')) !== '') {
+            $gallery['password_hash'] = password_hash($pw, PASSWORD_DEFAULT);
+        }
+        gallery_save($gallery);
+        flash_set('Gallery settings saved.');
+        redirect('gallery-edit.php?g=' . rawurlencode($slug));
+    }
+
+    if ($action === 'delete-image') {
+        $key = (string)($_POST['key'] ?? '');
+        foreach ($gallery['images'] ?? [] as $i => $img) {
+            if (($img['key'] ?? '') === $key) {
+                s3_delete($img['key']);
+                if (!empty($img['thumb'])) {
+                    s3_delete($img['thumb']);
+                }
+                array_splice($gallery['images'], $i, 1);
+                gallery_save($gallery);
+                flash_set('Image deleted.');
+                break;
+            }
+        }
+        redirect('gallery-edit.php?g=' . rawurlencode($slug));
+    }
+}
+
+$shareUrl = rtrim(config('site.base_url', ''), '/') . '/gallery.php?g=' . rawurlencode($slug);
+
+admin_header($gallery['title'], 'galleries');
+flash_render();
+?>
+<h1><?= e($gallery['title']) ?></h1>
+<p class="help" style="margin-bottom:1.5rem">
+    Share link: <a href="../gallery.php?g=<?= e(rawurlencode($slug)) ?>" target="_blank" rel="noopener"><?= e($shareUrl) ?></a>
+</p>
+
+<div class="card">
+    <h2 style="margin-top:0">Upload images</h2>
+    <div class="dropzone" id="dropzone"
+         data-api="api.php"
+         data-slug="<?= e($slug) ?>"
+         data-csrf="<?= e(csrf_token()) ?>"
+         data-thumb-size="<?= (int)config('uploads.thumb_size', 600) ?>"
+         data-thumb-quality="<?= e((string)config('uploads.thumb_quality', 0.8)) ?>">
+        Drop images here or click to select.<br>
+        <small>Files go directly from your browser to S3, in full resolution, unmodified.</small>
+    </div>
+    <input type="file" id="file-input" accept="image/*" multiple style="display:none">
+    <div class="upload-list" id="upload-list"></div>
+</div>
+
+<form method="post" class="card">
+    <?= csrf_field() ?>
+    <input type="hidden" name="action" value="settings">
+    <h2 style="margin-top:0">Settings</h2>
+    <label for="t">Title</label>
+    <input type="text" id="t" name="title" value="<?= e($gallery['title']) ?>">
+    <label for="ex">Expiry date (blank = never)</label>
+    <input type="date" id="ex" name="expires_at" value="<?= e($gallery['expires_at'] ?? '') ?>">
+    <label for="p">Set new password (blank = keep current)</label>
+    <input type="text" id="p" name="password" autocomplete="off">
+    <?php if (!empty($gallery['password_hash'])): ?>
+        <p class="help"><label style="display:inline;text-transform:none;letter-spacing:0">
+            <input type="checkbox" name="remove_password" value="1"> Remove password protection
+        </label></p>
+    <?php endif; ?>
+    <button type="submit">Save settings</button>
+</form>
+
+<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 endforeach; ?>
+</div>
+
+<script src="../assets/admin.js"></script>
+<?php admin_footer(); ?>

+ 27 - 0
public/admin/index.php

@@ -0,0 +1,27 @@
+<?php
+require dirname(__DIR__, 2) . '/app/bootstrap.php';
+auth_require();
+
+$site = site_get();
+$galleries = galleries_all();
+$active = count(array_filter($galleries, fn($g) => !gallery_is_expired($g)));
+
+admin_header('Dashboard');
+flash_render();
+?>
+<h1>Dashboard</h1>
+<div class="card">
+    <table>
+        <tr><td>Showreel images</td><td><?= count($site['showreel'] ?? []) ?></td>
+            <td><a href="showreel.php">Manage →</a></td></tr>
+        <tr><td>Galleries</td><td><?= count($galleries) ?> (<?= $active ?> active)</td>
+            <td><a href="galleries.php">Manage →</a></td></tr>
+        <tr><td>Front page</td><td><?= e($site['intro_title']) ?></td>
+            <td><a href="frontpage.php">Edit →</a></td></tr>
+    </table>
+</div>
+<p class="help">
+    Tip: create a gallery under <em>Galleries</em>, then share its link with your client.
+    Password and expiry date are optional per gallery.
+</p>
+<?php admin_footer(); ?>

+ 46 - 0
public/admin/login.php

@@ -0,0 +1,46 @@
+<?php
+require dirname(__DIR__, 2) . '/app/bootstrap.php';
+
+session_boot();
+if (auth_check()) {
+    redirect('index.php');
+}
+
+$error = null;
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    csrf_verify();
+    $lock = auth_locked_for();
+    if ($lock > 0) {
+        $error = 'Too many failed attempts. Try again in ' . ceil($lock / 60) . ' min.';
+    } elseif (auth_attempt((string)($_POST['username'] ?? ''), (string)($_POST['password'] ?? ''))) {
+        redirect('index.php');
+    } else {
+        $lock = auth_locked_for();
+        $error = $lock > 0
+            ? 'Too many failed attempts. Try again in ' . ceil($lock / 60) . ' min.'
+            : 'Invalid username or password.';
+    }
+}
+?><!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Admin login</title>
+<link rel="stylesheet" href="../assets/site.css">
+</head>
+<body class="admin">
+<div class="login-wrap"><div class="login-card">
+    <h1>Backoffice</h1>
+    <?php if ($error): ?><div class="flash flash-error" style="margin-top:1.2rem"><?= e($error) ?></div><?php endif; ?>
+    <form method="post">
+        <?= csrf_field() ?>
+        <label for="u">Username</label>
+        <input type="text" id="u" name="username" autofocus autocomplete="username">
+        <label for="p">Password</label>
+        <input type="password" id="p" name="password" autocomplete="current-password">
+        <button type="submit" style="width:100%">Log in</button>
+    </form>
+</div></div>
+</body>
+</html>

+ 4 - 0
public/admin/logout.php

@@ -0,0 +1,4 @@
+<?php
+require dirname(__DIR__, 2) . '/app/bootstrap.php';
+auth_logout();
+redirect('login.php');

+ 39 - 0
public/admin/settings.php

@@ -0,0 +1,39 @@
+<?php
+/**
+ * Settings: online admin password change (rewrites config/credentials.php).
+ */
+require dirname(__DIR__, 2) . '/app/bootstrap.php';
+auth_require();
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    csrf_verify();
+    $new = (string)($_POST['new_password'] ?? '');
+    if ($new !== (string)($_POST['new_password2'] ?? '')) {
+        flash_set('New passwords do not match.', 'error');
+    } else {
+        $err = auth_change_password((string)($_POST['current_password'] ?? ''), $new);
+        flash_set($err ?? 'Password changed.', $err ? 'error' : 'ok');
+    }
+    redirect('settings.php');
+}
+
+admin_header('Settings', 'settings');
+flash_render();
+?>
+<h1>Settings</h1>
+<form method="post" class="card" style="max-width:26rem">
+    <?= csrf_field() ?>
+    <h2 style="margin-top:0">Change password</h2>
+    <label for="c">Current password</label>
+    <input type="password" id="c" name="current_password" autocomplete="current-password">
+    <label for="n1">New password</label>
+    <input type="password" id="n1" name="new_password" autocomplete="new-password">
+    <label for="n2">Repeat new password</label>
+    <input type="password" id="n2" name="new_password2" autocomplete="new-password">
+    <p class="help">At least 8 characters. Written to config/credentials.php.</p>
+    <button type="submit">Change password</button>
+</form>
+<p class="help">
+    Username and S3 settings are edited directly in the files under <code>config/</code>.
+</p>
+<?php admin_footer(); ?>

+ 106 - 0
public/admin/showreel.php

@@ -0,0 +1,106 @@
+<?php
+/**
+ * Showreel manager: upload, reorder and remove the portfolio images.
+ * These are stored locally in public/media/ in full resolution.
+ */
+require dirname(__DIR__, 2) . '/app/bootstrap.php';
+auth_require();
+
+$site = site_get();
+$reel = $site['showreel'] ?? [];
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    csrf_verify();
+    $action = $_POST['action'] ?? '';
+
+    if ($action === 'upload') {
+        $count = 0;
+        foreach ($_FILES['images']['name'] ?? [] as $i => $name) {
+            $file = [
+                'name'     => $name,
+                'tmp_name' => $_FILES['images']['tmp_name'][$i],
+                'error'    => $_FILES['images']['error'][$i],
+            ];
+            $stored = media_store_upload($file);
+            if ($stored !== null) {
+                $reel[] = $stored;
+                $count++;
+            }
+        }
+        flash_set($count > 0 ? "$count image(s) added to the showreel." : 'No valid images uploaded.', $count > 0 ? 'ok' : 'error');
+    }
+
+    if ($action === 'delete') {
+        $name = (string)($_POST['file'] ?? '');
+        $reel = array_values(array_filter($reel, fn($f) => $f !== $name));
+        media_delete($name);
+        flash_set('Image removed.');
+    }
+
+    if ($action === 'move') {
+        $i = (int)($_POST['index'] ?? -1);
+        $dir = $_POST['dir'] === 'up' ? -1 : 1;
+        $j = $i + $dir;
+        if (isset($reel[$i], $reel[$j])) {
+            [$reel[$i], $reel[$j]] = [$reel[$j], $reel[$i]];
+        }
+    }
+
+    $site['showreel'] = array_values($reel);
+    site_save($site);
+    redirect('showreel.php');
+}
+
+admin_header('Showreel', 'showreel');
+flash_render();
+?>
+<h1>Showreel</h1>
+
+<form method="post" enctype="multipart/form-data" class="card">
+    <?= csrf_field() ?>
+    <input type="hidden" name="action" value="upload">
+    <label for="imgs">Add images</label>
+    <input type="file" id="imgs" name="images[]" accept="image/*" multiple>
+    <p class="help">
+        Full resolution, unmodified. These upload through the webhost, so add a few
+        at a time if your hosting has upload size limits.
+    </p>
+    <button type="submit">Upload</button>
+</form>
+
+<h2>Current order (<?= count($reel) ?>)</h2>
+<?php if (!$reel): ?>
+    <p class="help">The showreel is empty.</p>
+<?php else: ?>
+<div class="card"><table>
+    <tr><th></th><th>Image</th><th>File</th><th style="width:170px">Actions</th></tr>
+    <?php foreach ($reel as $i => $file): ?>
+    <tr>
+        <td><?= $i + 1 ?></td>
+        <td><img src="../media/<?= e(rawurlencode($file)) ?>" alt="" style="width:110px;height:74px;object-fit:cover;border-radius:4px"></td>
+        <td><?= e($file) ?></td>
+        <td>
+            <form method="post" style="display:inline"><?= csrf_field() ?>
+                <input type="hidden" name="action" value="move">
+                <input type="hidden" name="index" value="<?= $i ?>">
+                <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="move">
+                <input type="hidden" name="index" value="<?= $i ?>">
+                <input type="hidden" name="dir" value="down">
+                <button class="btn-ghost" style="margin:0;padding:.3rem .7rem" <?= $i === count($reel) - 1 ? 'disabled' : '' ?>>↓</button>
+            </form>
+            <form method="post" style="display:inline"
+                  onsubmit="return confirm('Remove this image from the showreel?')"><?= csrf_field() ?>
+                <input type="hidden" name="action" value="delete">
+                <input type="hidden" name="file" value="<?= e($file) ?>">
+                <button class="btn-danger" style="margin:0;padding:.3rem .7rem">✕</button>
+            </form>
+        </td>
+    </tr>
+    <?php endforeach; ?>
+</table></div>
+<?php endif; ?>
+<?php admin_footer(); ?>

+ 177 - 0
public/assets/admin.js

@@ -0,0 +1,177 @@
+/*
+ * Gallery bulk uploader.
+ *
+ * Per file:
+ *   1. ask admin/api.php for presigned PUT URLs (original + thumbnail)
+ *   2. PUT the original to S3 — byte-for-byte, full resolution, unmodified
+ *   3. draw a small JPEG thumbnail on a canvas (browser-side) and PUT it too
+ *   4. register the image in the gallery's flat file
+ *
+ * The webhost never receives the image data; only tiny JSON requests.
+ */
+(function () {
+    'use strict';
+
+    var zone = document.getElementById('dropzone');
+    if (!zone) return;
+
+    var input = document.getElementById('file-input');
+    var list = document.getElementById('upload-list');
+    var countEl = document.getElementById('img-count');
+
+    var api = zone.dataset.api;
+    var slug = zone.dataset.slug;
+    var csrf = zone.dataset.csrf;
+    var thumbSize = parseInt(zone.dataset.thumbSize, 10) || 600;
+    var thumbQuality = parseFloat(zone.dataset.thumbQuality) || 0.8;
+
+    var queue = [];
+    var busy = false;
+
+    zone.addEventListener('click', function () { input.click(); });
+    input.addEventListener('change', function () { enqueue(input.files); input.value = ''; });
+
+    ['dragenter', 'dragover'].forEach(function (ev) {
+        zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.add('drag'); });
+    });
+    ['dragleave', 'drop'].forEach(function (ev) {
+        zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.remove('drag'); });
+    });
+    zone.addEventListener('drop', function (e) { enqueue(e.dataTransfer.files); });
+
+    window.addEventListener('beforeunload', function (e) {
+        if (busy || queue.length) { e.preventDefault(); e.returnValue = ''; }
+    });
+
+    function enqueue(files) {
+        Array.prototype.forEach.call(files, function (file) {
+            var row = document.createElement('div');
+            row.className = 'upload-item';
+            row.innerHTML = '<span class="name"></span><span class="bar"><i></i></span><span class="state">queued</span>';
+            row.querySelector('.name').textContent = file.name;
+            list.appendChild(row);
+            queue.push({ file: file, row: row });
+        });
+        pump();
+    }
+
+    function pump() {
+        if (busy || !queue.length) return;
+        busy = true;
+        var job = queue.shift();
+        uploadOne(job.file, job.row)
+            .then(function () { setState(job.row, 'done', 'done'); bumpCount(); })
+            .catch(function (err) {
+                setState(job.row, 'failed', 'error');
+                job.row.title = String(err);
+                var retry = document.createElement('a');
+                retry.href = '#';
+                retry.textContent = ' retry';
+                retry.addEventListener('click', function (e) {
+                    e.preventDefault();
+                    retry.remove();
+                    setState(job.row, 'queued', '');
+                    queue.push(job);
+                    pump();
+                });
+                job.row.appendChild(retry);
+            })
+            .finally(function () { busy = false; pump(); });
+    }
+
+    function setState(row, text, cls) {
+        var el = row.querySelector('.state');
+        el.textContent = text;
+        el.className = 'state ' + (cls || '');
+    }
+
+    function bumpCount() {
+        if (countEl) countEl.textContent = String(parseInt(countEl.textContent, 10) + 1);
+    }
+
+    function apiCall(payload) {
+        return fetch(api, {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
+            body: JSON.stringify(payload)
+        }).then(function (res) {
+            if (!res.ok) throw new Error('API error ' + res.status);
+            return res.json();
+        });
+    }
+
+    /* PUT with upload progress (fetch has no upload progress → XHR). */
+    function putToS3(url, data, contentType, onProgress) {
+        return new Promise(function (resolve, reject) {
+            var xhr = new XMLHttpRequest();
+            xhr.open('PUT', url);
+            if (contentType) xhr.setRequestHeader('Content-Type', contentType);
+            xhr.upload.addEventListener('progress', function (e) {
+                if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total);
+            });
+            xhr.addEventListener('load', function () {
+                (xhr.status >= 200 && xhr.status < 300)
+                    ? resolve()
+                    : reject(new Error('S3 upload failed (' + xhr.status + ')'));
+            });
+            xhr.addEventListener('error', function () { reject(new Error('S3 upload network error')); });
+            xhr.send(data);
+        });
+    }
+
+    /* Thumbnail as JPEG blob; null when the browser cannot decode the file
+       (e.g. RAW) — the original still uploads untouched. */
+    function makeThumb(file) {
+        var decode = window.createImageBitmap
+            ? createImageBitmap(file, { imageOrientation: 'from-image' })
+            : new Promise(function (resolve, reject) {
+                var img = new Image();
+                img.onload = function () { resolve(img); };
+                img.onerror = reject;
+                img.src = URL.createObjectURL(file);
+            });
+
+        return decode.then(function (src) {
+            var w = src.width, h = src.height;
+            var scale = Math.min(1, thumbSize / Math.max(w, h));
+            var canvas = document.createElement('canvas');
+            canvas.width = Math.max(1, Math.round(w * scale));
+            canvas.height = Math.max(1, Math.round(h * scale));
+            canvas.getContext('2d').drawImage(src, 0, 0, canvas.width, canvas.height);
+            if (src.close) src.close();
+            return new Promise(function (resolve) {
+                canvas.toBlob(function (blob) { resolve(blob); }, 'image/jpeg', thumbQuality);
+            });
+        }).catch(function () { return null; });
+    }
+
+    function uploadOne(file, row) {
+        var bar = row.querySelector('.bar i');
+        setState(row, 'preparing');
+
+        return apiCall({ action: 'presign', slug: slug, name: file.name })
+            .then(function (p) {
+                setState(row, 'uploading');
+                return putToS3(p.put_original, file, file.type || 'application/octet-stream', function (f) {
+                    bar.style.width = Math.round(f * 100) + '%';
+                }).then(function () {
+                    setState(row, 'thumbnail');
+                    return makeThumb(file);
+                }).then(function (thumbBlob) {
+                    if (!thumbBlob) return { p: p, thumb: '' };
+                    return putToS3(p.put_thumb, thumbBlob, 'image/jpeg')
+                        .then(function () { return { p: p, thumb: p.thumb }; });
+                }).then(function (r) {
+                    setState(row, 'saving');
+                    return apiCall({
+                        action: 'register',
+                        slug: slug,
+                        key: r.p.key,
+                        thumb: r.thumb,
+                        name: file.name,
+                        size: file.size
+                    });
+                });
+            });
+    }
+})();