Ver Fonte

Public site: landing page, showreel, gallery viewer with password gate and lightbox

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Medowar há 1 mês atrás
pai
commit
a48af489ac
7 ficheiros alterados com 741 adições e 0 exclusões
  1. 1 0
      app/bootstrap.php
  2. 93 0
      app/partials.php
  3. 430 0
      public/assets/site.css
  4. 87 0
      public/assets/site.js
  5. 76 0
      public/gallery.php
  6. 29 0
      public/index.php
  7. 25 0
      public/showreel.php

+ 1 - 0
app/bootstrap.php

@@ -23,6 +23,7 @@ require APP_ROOT . '/app/storage.php';
 require APP_ROOT . '/app/csrf.php';
 require APP_ROOT . '/app/auth.php';
 require APP_ROOT . '/app/s3.php';
+require APP_ROOT . '/app/partials.php';
 
 /**
  * Read a config value by dot path, e.g. config('s3.bucket').

+ 93 - 0
app/partials.php

@@ -0,0 +1,93 @@
+<?php
+/**
+ * Shared HTML fragments for the public site and the admin backoffice.
+ * All pages sit directly in public/ or public/admin/, so asset paths are
+ * passed as a relative $base ('' for public pages, '../' for admin pages).
+ */
+
+declare(strict_types=1);
+
+function public_header(string $title, string $active = '', string $bodyClass = ''): void
+{
+    $site = site_get();
+    $name = $site['intro_title'] ?: config('site.name', 'Portfolio');
+    ?><!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title><?= e($title) ?></title>
+<link rel="stylesheet" href="assets/site.css">
+</head>
+<body class="<?= e($bodyClass) ?>">
+<header class="nav" id="nav">
+    <a class="nav-brand" href="./"><?= e($name) ?></a>
+    <nav class="nav-links">
+        <a href="./" class="<?= $active === 'home' ? 'active' : '' ?>">Home</a>
+        <a href="showreel.php" class="<?= $active === 'showreel' ? 'active' : '' ?>">Showreel</a>
+    </nav>
+</header>
+<?php
+}
+
+function public_footer(): void
+{
+    ?><footer class="footer">
+    <span>&copy; <?= date('Y') ?> <?= e(site_get()['intro_title'] ?: config('site.name', '')) ?></span>
+</footer>
+<script src="assets/site.js"></script>
+</body>
+</html>
+<?php
+}
+
+function admin_header(string $title, string $active = ''): void
+{
+    ?><!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title><?= e($title) ?> · Admin</title>
+<link rel="stylesheet" href="../assets/site.css">
+</head>
+<body class="admin">
+<header class="admin-nav">
+    <a class="nav-brand" href="index.php">Backoffice</a>
+    <nav class="nav-links">
+        <a href="frontpage.php" class="<?= $active === 'frontpage' ? 'active' : '' ?>">Front page</a>
+        <a href="showreel.php" class="<?= $active === 'showreel' ? 'active' : '' ?>">Showreel</a>
+        <a href="galleries.php" class="<?= $active === 'galleries' ? 'active' : '' ?>">Galleries</a>
+        <a href="settings.php" class="<?= $active === 'settings' ? 'active' : '' ?>">Settings</a>
+        <a href="../" target="_blank" rel="noopener">View site ↗</a>
+        <a href="logout.php">Log out</a>
+    </nav>
+</header>
+<main class="admin-main">
+<?php
+}
+
+function admin_footer(): void
+{
+    ?></main>
+</body>
+</html>
+<?php
+}
+
+/** One-shot status message helpers (flash messages via session). */
+function flash_set(string $msg, string $kind = 'ok'): void
+{
+    session_boot();
+    $_SESSION['flash'] = ['msg' => $msg, 'kind' => $kind];
+}
+
+function flash_render(): void
+{
+    session_boot();
+    if (!empty($_SESSION['flash'])) {
+        $f = $_SESSION['flash'];
+        unset($_SESSION['flash']);
+        echo '<div class="flash flash-' . e($f['kind']) . '">' . e($f['msg']) . '</div>';
+    }
+}

+ 430 - 0
public/assets/site.css

@@ -0,0 +1,430 @@
+/* ==========================================================================
+   Dark, minimal photography theme — shared by public site and admin.
+   ========================================================================== */
+
+:root {
+    --bg: #0b0b0c;
+    --bg-raise: #151517;
+    --fg: #f4f4f2;
+    --muted: #9a9a96;
+    --line: #29292c;
+    --danger: #e05b4d;
+    --ok: #6fbf8f;
+    --nav-h: 64px;
+}
+
+* { box-sizing: border-box; margin: 0; padding: 0; }
+
+html { scroll-behavior: smooth; }
+
+body {
+    background: var(--bg);
+    color: var(--fg);
+    font-family: "Helvetica Neue", Helvetica, Arial, system-ui, sans-serif;
+    font-size: 16px;
+    line-height: 1.6;
+    -webkit-font-smoothing: antialiased;
+}
+
+img { max-width: 100%; display: block; }
+a { color: inherit; text-decoration: none; }
+
+/* --------------------------------------------------------------------------
+   Public navigation — fixed, hides on scroll-down, returns on scroll-up
+   -------------------------------------------------------------------------- */
+
+.nav {
+    position: fixed;
+    inset: 0 0 auto 0;
+    height: var(--nav-h);
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 0 clamp(1.25rem, 4vw, 3rem);
+    z-index: 100;
+    background: linear-gradient(to bottom, rgba(0,0,0,.55), rgba(0,0,0,0));
+    transition: transform .35s ease, opacity .35s ease;
+}
+
+.nav.nav-hidden {
+    transform: translateY(-100%);
+    opacity: 0;
+    pointer-events: none;
+}
+
+.nav-brand {
+    font-size: 1.05rem;
+    letter-spacing: .18em;
+    text-transform: uppercase;
+    font-weight: 500;
+}
+
+.nav-links {
+    display: flex;
+    gap: 2rem;
+}
+
+.nav-links a {
+    font-size: .8rem;
+    letter-spacing: .22em;
+    text-transform: uppercase;
+    color: var(--muted);
+    transition: color .2s;
+}
+
+.nav-links a:hover, .nav-links a.active { color: var(--fg); }
+
+/* --------------------------------------------------------------------------
+   Landing page
+   -------------------------------------------------------------------------- */
+
+.hero {
+    position: relative;
+    height: 100svh;
+    overflow: hidden;
+}
+
+.hero img {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+}
+
+.hero-placeholder {
+    width: 100%;
+    height: 100%;
+    background: radial-gradient(120% 90% at 50% 20%, #1d1d21 0%, #0b0b0c 70%);
+}
+
+.hero-caption {
+    position: absolute;
+    inset: auto 0 0 0;
+    padding: clamp(2rem, 8vh, 6rem) clamp(1.25rem, 4vw, 3rem);
+    background: linear-gradient(to top, rgba(0,0,0,.65), rgba(0,0,0,0));
+}
+
+.hero-caption h1 {
+    font-size: clamp(2.2rem, 6vw, 4.5rem);
+    font-weight: 300;
+    letter-spacing: .12em;
+    text-transform: uppercase;
+}
+
+.hero-scroll-hint {
+    position: absolute;
+    bottom: 1.2rem;
+    left: 50%;
+    translate: -50% 0;
+    color: rgba(255,255,255,.7);
+    font-size: 1.4rem;
+    animation: hint 2.2s ease-in-out infinite;
+}
+
+@keyframes hint {
+    0%, 100% { transform: translateY(0); opacity: .5; }
+    50%      { transform: translateY(8px); opacity: 1; }
+}
+
+.intro {
+    max-width: 42rem;
+    margin: 0 auto;
+    padding: clamp(4rem, 14vh, 9rem) 1.5rem;
+    text-align: center;
+}
+
+.intro p {
+    font-size: clamp(1.05rem, 1.6vw, 1.3rem);
+    font-weight: 300;
+    color: var(--fg);
+    white-space: pre-line;
+    line-height: 1.9;
+}
+
+.intro .cta {
+    display: inline-block;
+    margin-top: 3rem;
+    padding: .9rem 2.6rem;
+    border: 1px solid var(--fg);
+    font-size: .8rem;
+    letter-spacing: .25em;
+    text-transform: uppercase;
+    transition: background .25s, color .25s;
+}
+
+.intro .cta:hover { background: var(--fg); color: var(--bg); }
+
+/* --------------------------------------------------------------------------
+   Showreel — one image per viewport, scroll snap
+   -------------------------------------------------------------------------- */
+
+body.reel-page { scroll-snap-type: y mandatory; }
+
+.reel-slide {
+    height: 100svh;
+    scroll-snap-align: start;
+    scroll-snap-stop: always;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background: #000;
+}
+
+.reel-slide img {
+    max-width: 100%;
+    max-height: 100svh;
+    width: auto;
+    height: auto;
+    object-fit: contain;
+}
+
+.reel-empty {
+    height: 100svh;
+    display: grid;
+    place-items: center;
+    color: var(--muted);
+    letter-spacing: .1em;
+}
+
+/* --------------------------------------------------------------------------
+   Gallery — password gate, thumbnail grid, lightbox
+   -------------------------------------------------------------------------- */
+
+.page {
+    max-width: 1400px;
+    margin: 0 auto;
+    padding: calc(var(--nav-h) + 3rem) clamp(1rem, 3vw, 2.5rem) 4rem;
+}
+
+.page-title {
+    font-weight: 300;
+    letter-spacing: .1em;
+    text-transform: uppercase;
+    font-size: clamp(1.4rem, 3vw, 2.2rem);
+    margin-bottom: .4rem;
+}
+
+.page-sub { color: var(--muted); font-size: .9rem; margin-bottom: 2.5rem; }
+
+.gate {
+    min-height: 100svh;
+    display: grid;
+    place-items: center;
+    padding: 1.5rem;
+}
+
+.gate-card {
+    width: 100%;
+    max-width: 24rem;
+    text-align: center;
+}
+
+.gate-card h1 {
+    font-weight: 300;
+    letter-spacing: .12em;
+    text-transform: uppercase;
+    font-size: 1.4rem;
+    margin-bottom: 1.8rem;
+}
+
+.grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
+    gap: 6px;
+}
+
+.grid a {
+    position: relative;
+    aspect-ratio: 3 / 2;
+    overflow: hidden;
+    background: var(--bg-raise);
+}
+
+.grid img {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+    transition: transform .4s ease, opacity .4s;
+    opacity: .92;
+}
+
+.grid a:hover img { transform: scale(1.03); opacity: 1; }
+
+/* Lightbox */
+
+.lightbox {
+    position: fixed;
+    inset: 0;
+    z-index: 200;
+    background: rgba(0,0,0,.96);
+    display: none;
+    align-items: center;
+    justify-content: center;
+}
+
+.lightbox.open { display: flex; }
+
+.lightbox img {
+    max-width: 96vw;
+    max-height: 96svh;
+    object-fit: contain;
+}
+
+.lightbox button {
+    position: absolute;
+    background: none;
+    border: none;
+    color: rgba(255,255,255,.75);
+    font-size: 2rem;
+    cursor: pointer;
+    padding: 1rem;
+    line-height: 1;
+    transition: color .2s;
+}
+
+.lightbox button:hover { color: #fff; }
+.lb-close { top: .5rem; right: .8rem; }
+.lb-prev  { left: .4rem; top: 50%; translate: 0 -50%; }
+.lb-next  { right: .4rem; top: 50%; translate: 0 -50%; }
+.lb-count {
+    position: absolute;
+    bottom: 1rem;
+    left: 50%;
+    translate: -50% 0;
+    color: var(--muted);
+    font-size: .8rem;
+    letter-spacing: .15em;
+}
+
+/* --------------------------------------------------------------------------
+   Footer
+   -------------------------------------------------------------------------- */
+
+.footer {
+    padding: 2.5rem clamp(1.25rem, 4vw, 3rem);
+    color: var(--muted);
+    font-size: .75rem;
+    letter-spacing: .15em;
+    text-transform: uppercase;
+    text-align: center;
+}
+
+/* --------------------------------------------------------------------------
+   Forms (shared)
+   -------------------------------------------------------------------------- */
+
+label { display: block; font-size: .8rem; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); margin: 1.1rem 0 .35rem; }
+
+input[type=text], input[type=password], input[type=date], textarea, select {
+    width: 100%;
+    padding: .7rem .9rem;
+    background: var(--bg-raise);
+    border: 1px solid var(--line);
+    color: var(--fg);
+    font: inherit;
+    border-radius: 4px;
+}
+
+input:focus, textarea:focus { outline: 1px solid var(--muted); }
+
+textarea { min-height: 9rem; resize: vertical; }
+
+button, .btn {
+    display: inline-block;
+    margin-top: 1.4rem;
+    padding: .75rem 1.8rem;
+    background: var(--fg);
+    color: var(--bg);
+    border: none;
+    font: inherit;
+    font-size: .8rem;
+    letter-spacing: .18em;
+    text-transform: uppercase;
+    cursor: pointer;
+    border-radius: 4px;
+}
+
+.btn-ghost {
+    background: none;
+    color: var(--fg);
+    border: 1px solid var(--line);
+}
+
+.btn-danger { background: var(--danger); color: #fff; }
+
+.flash { padding: .8rem 1.1rem; border-radius: 4px; margin-bottom: 1.5rem; font-size: .9rem; }
+.flash-ok    { background: rgba(111,191,143,.12); border: 1px solid rgba(111,191,143,.4); color: var(--ok); }
+.flash-error { background: rgba(224,91,77,.12);  border: 1px solid rgba(224,91,77,.4);  color: var(--danger); }
+
+/* --------------------------------------------------------------------------
+   Admin
+   -------------------------------------------------------------------------- */
+
+body.admin { background: #101012; }
+
+.admin-nav {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    flex-wrap: wrap;
+    gap: .8rem;
+    padding: 1rem clamp(1rem, 3vw, 2rem);
+    border-bottom: 1px solid var(--line);
+    background: var(--bg);
+}
+
+.admin-nav .nav-links { flex-wrap: wrap; gap: 1.2rem; }
+
+.admin-main { max-width: 1000px; margin: 0 auto; padding: 2.5rem clamp(1rem, 3vw, 2rem) 5rem; }
+
+.admin-main h1 { font-weight: 400; font-size: 1.5rem; margin-bottom: 1.6rem; }
+.admin-main h2 { font-weight: 400; font-size: 1.1rem; margin: 2.2rem 0 1rem; }
+
+.card {
+    background: var(--bg-raise);
+    border: 1px solid var(--line);
+    border-radius: 6px;
+    padding: 1.5rem;
+    margin-bottom: 1.5rem;
+}
+
+table { width: 100%; border-collapse: collapse; font-size: .92rem; }
+th, td { text-align: left; padding: .65rem .5rem; border-bottom: 1px solid var(--line); vertical-align: middle; }
+th { color: var(--muted); font-weight: 400; font-size: .75rem; letter-spacing: .12em; text-transform: uppercase; }
+
+.tag { display: inline-block; padding: .1rem .55rem; border-radius: 99px; font-size: .72rem; border: 1px solid var(--line); color: var(--muted); }
+.tag-lock   { border-color: rgba(111,191,143,.5); color: var(--ok); }
+.tag-expired{ border-color: rgba(224,91,77,.5);  color: var(--danger); }
+
+.thumb-row { display: flex; flex-wrap: wrap; gap: 10px; }
+.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; }
+
+/* Upload queue */
+.dropzone {
+    border: 2px dashed var(--line);
+    border-radius: 6px;
+    padding: 2.5rem 1.5rem;
+    text-align: center;
+    color: var(--muted);
+    cursor: pointer;
+    transition: border-color .2s, color .2s;
+}
+.dropzone.drag { border-color: var(--fg); color: var(--fg); }
+
+.upload-list { margin-top: 1rem; font-size: .85rem; }
+.upload-item { display: flex; align-items: center; gap: .8rem; padding: .4rem 0; border-bottom: 1px solid var(--line); }
+.upload-item .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.upload-item .state { color: var(--muted); min-width: 90px; text-align: right; }
+.upload-item .state.done { color: var(--ok); }
+.upload-item .state.error { color: var(--danger); }
+.upload-item .bar { width: 120px; height: 4px; background: var(--line); border-radius: 2px; overflow: hidden; }
+.upload-item .bar i { display: block; height: 100%; width: 0; background: var(--fg); transition: width .2s; }
+
+.help { color: var(--muted); font-size: .85rem; margin-top: .5rem; }
+
+.login-wrap { min-height: 80svh; display: grid; place-items: center; padding: 1.5rem; }
+.login-card { width: 100%; max-width: 22rem; }
+.login-card h1 { text-align: center; font-weight: 300; letter-spacing: .15em; text-transform: uppercase; font-size: 1.2rem; }

+ 87 - 0
public/assets/site.js

@@ -0,0 +1,87 @@
+/* Public site behavior: hiding navigation + gallery lightbox. Vanilla JS. */
+(function () {
+    'use strict';
+
+    /* ---- Navigation: hide on scroll-down, show on scroll-up ------------- */
+    var nav = document.getElementById('nav');
+    if (nav) {
+        var lastY = window.scrollY;
+        var onScroll = function () {
+            var y = window.scrollY;
+            if (y > lastY && y > 80) {
+                nav.classList.add('nav-hidden');
+            } else if (y < lastY - 2 || y <= 80) {
+                nav.classList.remove('nav-hidden');
+            }
+            lastY = y;
+        };
+        window.addEventListener('scroll', onScroll, { passive: true });
+    }
+
+    /* ---- Lightbox for gallery pages -------------------------------------
+       Markup contract: .grid contains <a href="<full-res URL>"><img></a>. */
+    var grid = document.querySelector('.grid');
+    if (!grid) return;
+
+    var links = Array.prototype.slice.call(grid.querySelectorAll('a'));
+    if (!links.length) return;
+
+    var box = document.createElement('div');
+    box.className = 'lightbox';
+    box.innerHTML =
+        '<button class="lb-close" aria-label="Close">&times;</button>' +
+        '<button class="lb-prev" aria-label="Previous">&#8249;</button>' +
+        '<img alt="">' +
+        '<button class="lb-next" aria-label="Next">&#8250;</button>' +
+        '<span class="lb-count"></span>';
+    document.body.appendChild(box);
+
+    var img = box.querySelector('img');
+    var count = box.querySelector('.lb-count');
+    var current = -1;
+
+    function show(i) {
+        current = (i + links.length) % links.length;
+        img.src = links[current].href;
+        count.textContent = (current + 1) + ' / ' + links.length;
+        box.classList.add('open');
+        document.body.style.overflow = 'hidden';
+    }
+
+    function close() {
+        box.classList.remove('open');
+        img.src = '';
+        document.body.style.overflow = '';
+        current = -1;
+    }
+
+    links.forEach(function (a, i) {
+        a.addEventListener('click', function (e) {
+            e.preventDefault();
+            show(i);
+        });
+    });
+
+    box.querySelector('.lb-close').addEventListener('click', close);
+    box.querySelector('.lb-prev').addEventListener('click', function () { show(current - 1); });
+    box.querySelector('.lb-next').addEventListener('click', function () { show(current + 1); });
+    box.addEventListener('click', function (e) { if (e.target === box) close(); });
+
+    document.addEventListener('keydown', function (e) {
+        if (current < 0) return;
+        if (e.key === 'Escape') close();
+        if (e.key === 'ArrowLeft') show(current - 1);
+        if (e.key === 'ArrowRight') show(current + 1);
+    });
+
+    /* Basic swipe support */
+    var touchX = null;
+    box.addEventListener('touchstart', function (e) { touchX = e.touches[0].clientX; }, { passive: true });
+    box.addEventListener('touchend', function (e) {
+        if (touchX === null || current < 0) return;
+        var dx = e.changedTouches[0].clientX - touchX;
+        if (dx > 50) show(current - 1);
+        else if (dx < -50) show(current + 1);
+        touchX = null;
+    }, { passive: true });
+})();

+ 76 - 0
public/gallery.php

@@ -0,0 +1,76 @@
+<?php
+/**
+ * Client gallery viewer: /gallery.php?g=<slug>
+ *
+ * - Unknown or expired galleries show the same neutral "not available" page.
+ * - Password-protected galleries show a password form; a successful unlock
+ *   is remembered in the session for that gallery only.
+ * - Thumbnails and full-res originals are loaded by the browser directly
+ *   from S3 via short-lived presigned URLs.
+ */
+require dirname(__DIR__) . '/app/bootstrap.php';
+
+session_boot();
+
+$slug = (string)($_GET['g'] ?? '');
+$gallery = $slug !== '' ? gallery_load($slug) : null;
+
+if ($gallery === null || gallery_is_expired($gallery)) {
+    http_response_code(404);
+    public_header('Gallery not available');
+    echo '<div class="gate"><div class="gate-card"><h1>Gallery not available</h1>'
+       . '<p class="page-sub">This gallery does not exist or is no longer online.</p></div></div>';
+    public_footer();
+    exit;
+}
+
+$needsPassword = !empty($gallery['password_hash']);
+$unlocked = !$needsPassword || !empty($_SESSION['gallery_unlocked'][$slug]);
+
+if ($needsPassword && !$unlocked && $_SERVER['REQUEST_METHOD'] === 'POST') {
+    csrf_verify();
+    if (password_verify((string)($_POST['password'] ?? ''), $gallery['password_hash'])) {
+        $_SESSION['gallery_unlocked'][$slug] = true;
+        redirect('gallery.php?g=' . rawurlencode($slug));
+    }
+    $error = 'Wrong password.';
+}
+
+if ($needsPassword && !$unlocked) {
+    public_header(e($gallery['title']));
+    ?>
+    <div class="gate"><div class="gate-card">
+        <h1><?= e($gallery['title']) ?></h1>
+        <?php if (!empty($error)): ?><div class="flash flash-error"><?= e($error) ?></div><?php endif; ?>
+        <form method="post">
+            <?= csrf_field() ?>
+            <label for="pw">Password</label>
+            <input type="password" id="pw" name="password" autofocus autocomplete="off">
+            <button type="submit">Open gallery</button>
+        </form>
+    </div></div>
+    <?php
+    public_footer();
+    exit;
+}
+
+public_header(e($gallery['title']));
+?>
+<main class="page">
+    <h1 class="page-title"><?= e($gallery['title']) ?></h1>
+    <p class="page-sub">
+        <?= count($gallery['images'] ?? []) ?> photos
+        <?php if (!empty($gallery['expires_at'])): ?>
+            · available until <?= e($gallery['expires_at']) ?>
+        <?php endif; ?>
+    </p>
+    <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>
+</main>
+<?php public_footer(); ?>

+ 29 - 0
public/index.php

@@ -0,0 +1,29 @@
+<?php
+/**
+ * Landing page: full-screen hero image with the artist introduction.
+ */
+require dirname(__DIR__) . '/app/bootstrap.php';
+
+$site = site_get();
+$hero = $site['hero_image'] ?? null;
+$heroUrl = $hero ? 'media/' . rawurlencode($hero) : null;
+
+public_header($site['intro_title'] ?: config('site.name', 'Portfolio'), 'home');
+?>
+<section class="hero">
+    <?php if ($heroUrl): ?>
+        <img src="<?= e($heroUrl) ?>" alt="<?= e($site['intro_title']) ?>" fetchpriority="high">
+    <?php else: ?>
+        <div class="hero-placeholder"></div>
+    <?php endif; ?>
+    <div class="hero-caption">
+        <h1><?= e($site['intro_title']) ?></h1>
+    </div>
+    <span class="hero-scroll-hint">&#8595;</span>
+</section>
+
+<section class="intro">
+    <p><?= e($site['intro_text']) ?></p>
+    <a class="cta" href="showreel.php">View Showreel</a>
+</section>
+<?php public_footer(); ?>

+ 25 - 0
public/showreel.php

@@ -0,0 +1,25 @@
+<?php
+/**
+ * Showreel: selected portfolio images, one per viewport, scroll-snap.
+ * Images are local files in public/media/, shown in the order configured
+ * in the admin backoffice.
+ */
+require dirname(__DIR__) . '/app/bootstrap.php';
+
+$site = site_get();
+$images = $site['showreel'] ?? [];
+
+public_header('Showreel · ' . ($site['intro_title'] ?: config('site.name', '')), 'showreel', 'reel-page');
+
+if (!$images): ?>
+    <div class="reel-empty">No images in the showreel yet.</div>
+<?php else:
+    foreach ($images as $i => $file): ?>
+    <section class="reel-slide">
+        <img src="media/<?= e(rawurlencode($file)) ?>" alt=""
+             <?= $i === 0 ? 'fetchpriority="high"' : 'loading="lazy"' ?>>
+    </section>
+<?php endforeach;
+endif;
+
+public_footer();