Pārlūkot izejas kodu

Add Impressum, Datenschutz and Contact pages

Public site gains three pages:

- impressum.php / datenschutz.php render basic-Markdown legal text
  edited under admin → Settings. A tiny, safe renderer (app/markdown.php)
  supports headings, lists, bold/italic and http(s)/mailto links; all
  input is HTML-escaped first and disallowed URL schemes are dropped.
- contact.php uses the landing-page style (centered intro) and shows
  the heading, intro text and email/phone/Instagram links configured
  in a new admin → Contact editor.

Content lives in data/site.json alongside the existing site fields.
Footer links to Impressum/Datenschutz; Home nav and admin nav gain a
Contact entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Medowar 1 mēnesi atpakaļ
vecāks
revīzija
561da2fdd9
10 mainītis faili ar 368 papildinājumiem un 5 dzēšanām
  1. 52 0
      admin/contact.php
  2. 34 1
      admin/settings.php
  3. 1 0
      app/bootstrap.php
  4. 103 0
      app/markdown.php
  5. 6 0
      app/partials.php
  6. 11 4
      app/storage.php
  7. 68 0
      assets/site.css
  8. 47 0
      contact.php
  9. 23 0
      datenschutz.php
  10. 23 0
      impressum.php

+ 52 - 0
admin/contact.php

@@ -0,0 +1,52 @@
+<?php
+/**
+ * Contact page editor: heading, intro text and contact methods shown on the
+ * public contact.php page.
+ */
+require dirname(__DIR__) . '/app/bootstrap.php';
+auth_require();
+
+$site = site_get();
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    csrf_verify();
+
+    $site['contact_title']     = trim((string)($_POST['contact_title'] ?? ''));
+    $site['contact_text']      = trim((string)($_POST['contact_text'] ?? ''));
+    $site['contact_email']     = trim((string)($_POST['contact_email'] ?? ''));
+    $site['contact_phone']     = trim((string)($_POST['contact_phone'] ?? ''));
+    $site['contact_instagram'] = trim((string)($_POST['contact_instagram'] ?? ''));
+
+    site_save($site);
+    flash_set('Contact page saved.');
+    redirect('contact.php');
+}
+
+admin_header('Contact', 'contact');
+flash_render();
+?>
+<h1>Contact page</h1>
+<form method="post" class="card">
+    <?= csrf_field() ?>
+
+    <label for="ct">Heading</label>
+    <input type="text" id="ct" name="contact_title" value="<?= e($site['contact_title'] ?? '') ?>">
+
+    <label for="cx">Intro text</label>
+    <textarea id="cx" name="contact_text"><?= e($site['contact_text'] ?? '') ?></textarea>
+    <p class="help">Shown below the heading. Line breaks are kept.</p>
+
+    <label for="ce">Email</label>
+    <input type="text" id="ce" name="contact_email" value="<?= e($site['contact_email'] ?? '') ?>" placeholder="hello@example.com">
+
+    <label for="cp">Phone</label>
+    <input type="text" id="cp" name="contact_phone" value="<?= e($site['contact_phone'] ?? '') ?>" placeholder="+49 30 1234567">
+
+    <label for="ci">Instagram</label>
+    <input type="text" id="ci" name="contact_instagram" value="<?= e($site['contact_instagram'] ?? '') ?>" placeholder="@handle or full URL">
+
+    <p class="help">Leave a field empty to hide it on the contact page.</p>
+    <button type="submit">Save</button>
+    <a class="btn btn-ghost" href="../contact.php" target="_blank" rel="noopener" style="margin-left:.6rem">View page ↗</a>
+</form>
+<?php admin_footer(); ?>

+ 34 - 1
admin/settings.php

@@ -1,12 +1,23 @@
 <?php
 /**
- * Settings: online admin password change (rewrites config/credentials.php).
+ * Settings: admin password change plus the legal pages (Impressum /
+ * Datenschutz), which are stored as basic Markdown in data/site.json.
  */
 require dirname(__DIR__) . '/app/bootstrap.php';
 auth_require();
 
 if ($_SERVER['REQUEST_METHOD'] === 'POST') {
     csrf_verify();
+
+    if (($_POST['section'] ?? '') === 'legal') {
+        $site = site_get();
+        $site['impressum']   = trim((string)($_POST['impressum'] ?? ''));
+        $site['datenschutz'] = trim((string)($_POST['datenschutz'] ?? ''));
+        site_save($site);
+        flash_set('Legal pages saved.');
+        redirect('settings.php');
+    }
+
     $new = (string)($_POST['new_password'] ?? '');
     if ($new !== (string)($_POST['new_password2'] ?? '')) {
         flash_set('New passwords do not match.', 'error');
@@ -17,10 +28,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
     redirect('settings.php');
 }
 
+$site = site_get();
+
 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>
@@ -33,6 +47,25 @@ flash_render();
     <p class="help">At least 8 characters. Written to config/credentials.php.</p>
     <button type="submit">Change password</button>
 </form>
+
+<form method="post" class="card">
+    <?= csrf_field() ?>
+    <input type="hidden" name="section" value="legal">
+    <h2 style="margin-top:0">Legal pages</h2>
+    <p class="help" style="margin-top:0">
+        Basic Markdown is supported: <code># Heading</code>, <code>- list item</code>,
+        <code>**bold**</code>, <code>*italic*</code>, <code>[text](https://…)</code>.
+    </p>
+
+    <label for="imp">Impressum <a href="../impressum.php" target="_blank" rel="noopener">view ↗</a></label>
+    <textarea id="imp" name="impressum" style="min-height:14rem"><?= e($site['impressum'] ?? '') ?></textarea>
+
+    <label for="dsg">Datenschutz <a href="../datenschutz.php" target="_blank" rel="noopener">view ↗</a></label>
+    <textarea id="dsg" name="datenschutz" style="min-height:14rem"><?= e($site['datenschutz'] ?? '') ?></textarea>
+
+    <button type="submit">Save legal pages</button>
+</form>
+
 <p class="help">
     Username and S3 settings are edited directly in the files under <code>config/</code>.
 </p>

+ 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/markdown.php';
 require APP_ROOT . '/app/partials.php';
 
 /**

+ 103 - 0
app/markdown.php

@@ -0,0 +1,103 @@
+<?php
+/**
+ * Ultra-small Markdown renderer for the legal pages (Impressum / Datenschutz).
+ *
+ * Deliberately supports only a safe, basic subset. The input is always
+ * HTML-escaped first, so no raw HTML from the editor is ever emitted; only the
+ * handful of constructs below are turned into tags:
+ *
+ *   # / ## / ###     headings
+ *   - item           unordered lists (also "* item")
+ *   **bold**  *italic*
+ *   [text](url)      links (http/https/mailto schemes only)
+ *   blank line       new paragraph; a single newline becomes <br>
+ */
+
+declare(strict_types=1);
+
+/** Render a basic-Markdown string to safe HTML. */
+function markdown_basic(string $text): string
+{
+    $lines = explode("\n", str_replace("\r\n", "\n", $text));
+
+    $html   = '';
+    $inList = false;
+    $para   = [];
+
+    $flushPara = static function () use (&$para, &$html): void {
+        if ($para) {
+            $html .= '<p>' . implode('<br>', $para) . "</p>\n";
+            $para = [];
+        }
+    };
+    $closeList = static function () use (&$inList, &$html): void {
+        if ($inList) {
+            $html .= "</ul>\n";
+            $inList = false;
+        }
+    };
+
+    foreach ($lines as $line) {
+        $trimmed = trim($line);
+
+        if ($trimmed === '') {
+            $flushPara();
+            $closeList();
+            continue;
+        }
+
+        if (preg_match('/^(#{1,3})\s+(.*)$/', $trimmed, $m)) {
+            $flushPara();
+            $closeList();
+            $level = strlen($m[1]);
+            $html .= "<h$level>" . markdown_inline($m[2]) . "</h$level>\n";
+            continue;
+        }
+
+        if (preg_match('/^[-*]\s+(.*)$/', $trimmed, $m)) {
+            $flushPara();
+            if (!$inList) {
+                $html .= "<ul>\n";
+                $inList = true;
+            }
+            $html .= '<li>' . markdown_inline($m[1]) . "</li>\n";
+            continue;
+        }
+
+        // Ordinary text: collect into the current paragraph; consecutive
+        // non-blank lines are joined with <br>.
+        $closeList();
+        $para[] = markdown_inline($trimmed);
+    }
+
+    $flushPara();
+    $closeList();
+
+    return $html;
+}
+
+/** Inline formatting for one line. Escapes first, then applies the subset. */
+function markdown_inline(string $text): string
+{
+    // Escape everything up front so no raw HTML survives from the input.
+    $text = e($text);
+
+    // Links [text](url) — only http/https/mailto schemes are turned into <a>.
+    $text = preg_replace_callback(
+        '/\[([^\]]+)\]\(([^)\s]+)\)/',
+        static function (array $m): string {
+            [$whole, $label, $url] = $m;
+            if (!preg_match('#^(https?:|mailto:)#i', $url)) {
+                return $whole; // leave untouched if the scheme is not allowed
+            }
+            return '<a href="' . $url . '" target="_blank" rel="noopener noreferrer">' . $label . '</a>';
+        },
+        $text
+    );
+
+    // Bold first (**...**), then remaining single-asterisk italics (*...*).
+    $text = preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $text);
+    $text = preg_replace('/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/', '<em>$1</em>', $text);
+
+    return $text;
+}

+ 6 - 0
app/partials.php

@@ -25,6 +25,7 @@ function public_header(string $title, string $active = '', string $bodyClass = '
     <nav class="nav-links">
         <a href="./" class="<?= $active === 'home' ? 'active' : '' ?>">Home</a>
         <a href="showreel.php" class="<?= $active === 'showreel' ? 'active' : '' ?>">Showreel</a>
+        <a href="contact.php" class="<?= $active === 'contact' ? 'active' : '' ?>">Contact</a>
     </nav>
 </header>
 <?php
@@ -33,6 +34,10 @@ function public_header(string $title, string $active = '', string $bodyClass = '
 function public_footer(): void
 {
     ?><footer class="footer">
+    <nav class="footer-links">
+        <a href="impressum.php">Impressum</a>
+        <a href="datenschutz.php">Datenschutz</a>
+    </nav>
     <span>&copy; <?= date('Y') ?> <?= e(site_get()['intro_title'] ?: config('site.name', '')) ?></span>
 </footer>
 <script src="assets/site.js"></script>
@@ -58,6 +63,7 @@ function admin_header(string $title, string $active = ''): void
         <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="contact.php" class="<?= $active === 'contact' ? 'active' : '' ?>">Contact</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>

+ 11 - 4
app/storage.php

@@ -148,10 +148,17 @@ function media_delete(string $name): void
 function site_get(): array
 {
     return json_read(DATA_DIR . '/site.json', [
-        'intro_title' => 'Jane Doe',
-        'intro_text'  => "Photographer based in Berlin.\nAvailable for portraits, weddings and events.",
-        'hero_image'  => null,
-        'showreel'    => [],
+        'intro_title'   => 'Jane Doe',
+        'intro_text'    => "Photographer based in Berlin.\nAvailable for portraits, weddings and events.",
+        'hero_image'    => null,
+        'showreel'      => [],
+        'contact_title' => 'Get in touch',
+        'contact_text'  => "For bookings and enquiries, drop me a line.",
+        'contact_email' => '',
+        'contact_phone' => '',
+        'contact_instagram' => '',
+        'impressum'     => '',
+        'datenschutz'   => '',
     ]);
 }
 

+ 68 - 0
assets/site.css

@@ -161,6 +161,59 @@ a { color: inherit; text-decoration: none; }
 
 .intro .cta:hover { background: var(--fg); color: var(--bg); }
 
+/* --------------------------------------------------------------------------
+   Contact page — landing-style centered layout
+   -------------------------------------------------------------------------- */
+
+.contact {
+    min-height: 100svh;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+}
+
+.contact-title {
+    font-size: clamp(1.8rem, 5vw, 3.2rem);
+    font-weight: 300;
+    letter-spacing: .1em;
+    margin-bottom: 1.4rem;
+}
+
+.contact-methods {
+    margin-top: 2.6rem;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 1rem;
+}
+
+.contact-methods a {
+    font-size: 1.05rem;
+    letter-spacing: .05em;
+    color: var(--fg);
+    border-bottom: 1px solid var(--line);
+    padding-bottom: .2rem;
+    transition: border-color .25s;
+}
+
+.contact-methods a:hover { border-color: var(--fg); }
+
+/* --------------------------------------------------------------------------
+   Prose — rendered Markdown for the legal pages (Impressum / Datenschutz)
+   -------------------------------------------------------------------------- */
+
+.prose { max-width: 46rem; font-weight: 300; }
+.prose h1, .prose h2, .prose h3 { font-weight: 400; letter-spacing: .04em; margin: 2rem 0 .8rem; }
+.prose h1 { font-size: 1.5rem; }
+.prose h2 { font-size: 1.2rem; }
+.prose h3 { font-size: 1.05rem; }
+.prose p { margin-bottom: 1rem; }
+.prose ul { margin: 0 0 1rem 1.3rem; }
+.prose li { margin-bottom: .35rem; }
+.prose a { text-decoration: underline; }
+.prose a:hover { color: var(--muted); }
+.prose-empty { color: var(--muted); }
+
 /* --------------------------------------------------------------------------
    Showreel — one image per viewport, scroll snap
    -------------------------------------------------------------------------- */
@@ -316,6 +369,21 @@ body.reel-page { scroll-snap-type: y mandatory; }
     text-align: center;
 }
 
+.footer-links {
+    display: flex;
+    justify-content: center;
+    gap: 1.6rem;
+    margin-bottom: 1rem;
+}
+
+.footer-links a {
+    text-transform: uppercase;
+    color: var(--muted);
+    transition: color .2s;
+}
+
+.footer-links a:hover { color: var(--fg); }
+
 /* --------------------------------------------------------------------------
    Forms (shared)
    -------------------------------------------------------------------------- */

+ 47 - 0
contact.php

@@ -0,0 +1,47 @@
+<?php
+/**
+ * Contact page — same minimal, centered style as the landing intro.
+ * Content (title, text and contact methods) is edited in admin → Contact.
+ */
+require __DIR__ . '/app/bootstrap.php';
+
+$site  = site_get();
+$title = $site['contact_title'] ?: 'Get in touch';
+$email = trim((string)($site['contact_email'] ?? ''));
+$phone = trim((string)($site['contact_phone'] ?? ''));
+$insta = trim((string)($site['contact_instagram'] ?? ''));
+
+/** Turn an Instagram handle or URL into a link + label. */
+$instaLink  = null;
+$instaLabel = null;
+if ($insta !== '') {
+    if (preg_match('#^https?://#i', $insta)) {
+        $instaLink  = $insta;
+        $instaLabel = preg_replace('#^https?://(www\.)?instagram\.com/#i', '@', rtrim($insta, '/'));
+    } else {
+        $handle     = ltrim($insta, '@');
+        $instaLink  = 'https://instagram.com/' . rawurlencode($handle);
+        $instaLabel = '@' . $handle;
+    }
+}
+
+public_header($title . ' · ' . ($site['intro_title'] ?: config('site.name', '')), 'contact');
+?>
+<section class="intro contact">
+    <h1 class="contact-title"><?= e($title) ?></h1>
+    <?php if (trim((string)($site['contact_text'] ?? '')) !== ''): ?>
+        <p><?= e($site['contact_text']) ?></p>
+    <?php endif; ?>
+    <div class="contact-methods">
+        <?php if ($email !== ''): ?>
+            <a href="mailto:<?= e($email) ?>"><?= e($email) ?></a>
+        <?php endif; ?>
+        <?php if ($phone !== ''): ?>
+            <a href="tel:<?= e(preg_replace('/[^\d+]/', '', $phone)) ?>"><?= e($phone) ?></a>
+        <?php endif; ?>
+        <?php if ($instaLink !== null): ?>
+            <a href="<?= e($instaLink) ?>" target="_blank" rel="noopener noreferrer"><?= e($instaLabel) ?></a>
+        <?php endif; ?>
+    </div>
+</section>
+<?php public_footer(); ?>

+ 23 - 0
datenschutz.php

@@ -0,0 +1,23 @@
+<?php
+/**
+ * Datenschutzerklärung (privacy policy). Content is edited under
+ * admin → Settings and stored as basic Markdown in data/site.json.
+ */
+require __DIR__ . '/app/bootstrap.php';
+
+$site = site_get();
+$body = trim((string)($site['datenschutz'] ?? ''));
+
+public_header('Datenschutz · ' . ($site['intro_title'] ?: config('site.name', '')));
+?>
+<main class="page">
+    <h1 class="page-title">Datenschutz</h1>
+    <div class="prose">
+        <?php if ($body !== ''): ?>
+            <?= markdown_basic($body) ?>
+        <?php else: ?>
+            <p class="prose-empty">No content yet.</p>
+        <?php endif; ?>
+    </div>
+</main>
+<?php public_footer(); ?>

+ 23 - 0
impressum.php

@@ -0,0 +1,23 @@
+<?php
+/**
+ * Impressum (legal notice). Content is edited under admin → Settings and
+ * stored as basic Markdown in data/site.json.
+ */
+require __DIR__ . '/app/bootstrap.php';
+
+$site = site_get();
+$body = trim((string)($site['impressum'] ?? ''));
+
+public_header('Impressum · ' . ($site['intro_title'] ?: config('site.name', '')));
+?>
+<main class="page">
+    <h1 class="page-title">Impressum</h1>
+    <div class="prose">
+        <?php if ($body !== ''): ?>
+            <?= markdown_basic($body) ?>
+        <?php else: ?>
+            <p class="prose-empty">No content yet.</p>
+        <?php endif; ?>
+    </div>
+</main>
+<?php public_footer(); ?>