Bladeren bron

Implement upload functionality with file selection and camera capture options; add disclaimer section and improve form layout. Update styles for better mobile responsiveness.

Josef Straßl 1 maand geleden
bovenliggende
commit
bdd257dad6
7 gewijzigde bestanden met toevoegingen van 215 en 26 verwijderingen
  1. 7 0
      README.md
  2. 56 5
      assets/css/base.css
  3. 61 0
      assets/js/form.js
  4. 5 0
      docs/AI_OVERVIEW.md
  5. 4 0
      docs/FORM_SCHEMA.md
  6. 32 14
      index.php
  7. 50 7
      src/Storage/FileUploadStore.php

+ 7 - 0
README.md

@@ -2,6 +2,12 @@
 
 Schlankes PHP-Flatfile-Projekt für einen digitalen Mitgliedsantrag (deutsches Frontend).
 
+## Allgemeine Prinzipien
+
+- Mobile first und responsive für alle Seiten/Ansichten.
+- Frontend-Sprache ist Deutsch.
+- Architektur so einfach wie möglich bei vollständiger Zielerfüllung.
+
 ## Features
 
 - Mehrstufiges Wizard-Formular
@@ -9,6 +15,7 @@ Schlankes PHP-Flatfile-Projekt für einen digitalen Mitgliedsantrag (deutsches F
 - E-Mail wird im Browser gemerkt und Formular beim Besuch automatisch geladen
 - Genau ein Antrag pro E-Mail
 - Uploads mit Original-Dateiname in kurzem Zufallsordner
+- Upload-Felder bieten „Datei auswählen“ und „Foto aufnehmen“ (mobil optimiert)
 - Selbstbedienung: Gespeicherte Daten zur aktuellen E-Mail löschen und neu starten
 - Abschlussversand per E-Mail (intern + Antragsteller)
 - Admin-Bereich mit Login, Detailansicht, Download einzeln/ZIP, Löschen

+ 56 - 5
assets/css/base.css

@@ -4,7 +4,7 @@
 
 body {
   margin: 0;
-  padding: var(--space-4);
+  padding: var(--space-2);
   background: linear-gradient(140deg, #f9fbfc 0%, #edf2f6 100%);
   color: var(--text);
   font-family: var(--font-body);
@@ -70,7 +70,7 @@ button:hover {
 
 .wizard-actions {
   display: grid;
-  grid-template-columns: repeat(3, 1fr);
+  grid-template-columns: 1fr;
   gap: var(--space-2);
 }
 
@@ -111,6 +111,49 @@ small {
   margin-top: 0.25rem;
 }
 
+.upload-control {
+  border: 1px dashed var(--border);
+  border-radius: 10px;
+  padding: var(--space-2);
+  background: #fbfdff;
+}
+
+.upload-actions {
+  display: grid;
+  grid-template-columns: 1fr;
+  gap: var(--space-2);
+}
+
+.upload-action-btn {
+  display: block;
+  width: 100%;
+  text-align: center;
+  border: 1px solid var(--border);
+  border-radius: 8px;
+  padding: var(--space-2);
+  background: #f3f8fd;
+  font-weight: 600;
+  cursor: pointer;
+}
+
+.upload-action-btn-camera {
+  background: #eef7ef;
+}
+
+.upload-action-btn:hover {
+  filter: brightness(0.98);
+}
+
+.upload-native-input {
+  display: none;
+}
+
+.upload-selected {
+  margin: var(--space-2) 0 0;
+  color: var(--muted);
+  font-size: 0.9rem;
+}
+
 .status-text {
   margin-top: var(--space-2);
   color: var(--muted);
@@ -130,12 +173,20 @@ td {
   text-align: left;
 }
 
-@media (max-width: 640px) {
+@media (min-width: 720px) {
   body {
-    padding: var(--space-2);
+    padding: var(--space-4);
   }
 
   .wizard-actions {
-    grid-template-columns: 1fr;
+    grid-template-columns: repeat(3, 1fr);
+  }
+
+  .inline-actions {
+    grid-template-columns: repeat(2, 1fr);
+  }
+
+  .upload-actions {
+    grid-template-columns: repeat(2, 1fr);
   }
 }

+ 61 - 0
assets/js/form.js

@@ -173,6 +173,60 @@
     });
   }
 
+  function updateUploadSelectionText(fieldKey) {
+    const target = document.querySelector('[data-upload-selected="' + fieldKey + '"]');
+    if (!target) {
+      return;
+    }
+
+    const fileInput = applicationForm.querySelector('[name="' + fieldKey + '"]');
+    const cameraInput = applicationForm.querySelector('[name="' + fieldKey + '__camera"]');
+
+    let label = 'Keine Datei gewählt';
+    if (fileInput && fileInput.files && fileInput.files[0]) {
+      label = 'Ausgewählt: ' + fileInput.files[0].name;
+    }
+    if (cameraInput && cameraInput.files && cameraInput.files[0]) {
+      label = 'Ausgewählt: ' + cameraInput.files[0].name + ' (Foto)';
+    }
+
+    target.textContent = label;
+  }
+
+  function initUploadControls() {
+    const controls = Array.from(document.querySelectorAll('[data-upload-key]'));
+
+    controls.forEach((control) => {
+      const fieldKey = control.getAttribute('data-upload-key') || '';
+      if (!fieldKey) {
+        return;
+      }
+
+      const fileInput = applicationForm.querySelector('[name="' + fieldKey + '"]');
+      const cameraInput = applicationForm.querySelector('[name="' + fieldKey + '__camera"]');
+
+      if (fileInput) {
+        fileInput.addEventListener('change', () => {
+          if (fileInput.files && fileInput.files[0] && cameraInput) {
+            cameraInput.value = '';
+          }
+          updateUploadSelectionText(fieldKey);
+        });
+      }
+
+      if (cameraInput) {
+        cameraInput.addEventListener('change', () => {
+          if (cameraInput.files && cameraInput.files[0] && fileInput) {
+            fileInput.value = '';
+          }
+          updateUploadSelectionText(fieldKey);
+        });
+      }
+
+      updateUploadSelectionText(fieldKey);
+    });
+  }
+
   async function postForm(url, formData) {
     const response = await fetch(url, {
       method: 'POST',
@@ -255,6 +309,12 @@
       Array.from(applicationForm.querySelectorAll('input[type="file"]')).forEach((input) => {
         input.value = '';
       });
+      Array.from(document.querySelectorAll('[data-upload-key]')).forEach((control) => {
+        const fieldKey = control.getAttribute('data-upload-key');
+        if (fieldKey) {
+          updateUploadSelectionText(fieldKey);
+        }
+      });
     }
 
     return response;
@@ -402,5 +462,6 @@
     }
   }
 
+  initUploadControls();
   updateProgress();
 })();

+ 5 - 0
docs/AI_OVERVIEW.md

@@ -4,6 +4,11 @@
 
 Digitaler Mitgliedsantrag für Feuerwehrverein mit Flatfile-Speicherung und Admin-Backend.
 
+## General Principles
+
+- Mobile first und responsive über alle Frontend-Seiten.
+- Upload UX mit zwei Pfaden: Datei auswählen oder Foto aufnehmen.
+
 ## Architekturkarte
 
 - Frontend Wizard: `index.php` + `assets/js/form.js`

+ 4 - 0
docs/FORM_SCHEMA.md

@@ -43,3 +43,7 @@
 - Original-Dateiname wird serverseitig bereinigt und erhalten.
 - Speichern in kurzem Random-Unterordner (`rand8`) zur Kollisionsvermeidung.
 - Metadaten werden im Draft/Submission JSON abgelegt.
+- Für jedes `type: file` Feld rendert das Frontend zwei Eingabepfade:
+  - Datei auswählen (`name = <field_key>`)
+  - Foto aufnehmen (`name = <field_key>__camera`, `accept=image/*`, `capture=environment`)
+- Backend priorisiert regulären Datei-Upload, verwendet sonst Kamera-Upload.

+ 32 - 14
index.php

@@ -13,6 +13,9 @@ $schema = new FormSchema();
 $steps = $schema->getSteps();
 $csrf = Csrf::token();
 $app = Bootstrap::config('app');
+$disclaimerTitle = (string) ($app['disclaimer']['title'] ?? 'Hinweis');
+$disclaimerText = (string) ($app['disclaimer']['text'] ?? '');
+$disclaimerAcceptLabel = (string) ($app['disclaimer']['accept_label'] ?? 'Hinweis gelesen, weiter');
 
 /** @param array<string, mixed> $field */
 function renderField(array $field): void
@@ -45,8 +48,17 @@ function renderField(array $field): void
             echo '</select>';
         } elseif ($type === 'file') {
             $accept = htmlspecialchars((string) ($field['accept'] ?? ''));
-            echo '<input id="' . $key . '" type="file" name="' . $key . '" accept="' . $accept . '">';
-            echo '<small>Original-Dateiname wird übernommen und im System sicher gespeichert.</small>';
+            $fileInputId = $key . '_file';
+            $cameraInputId = $key . '_camera';
+            echo '<div class="upload-control" data-upload-key="' . $key . '">';
+            echo '<div class="upload-actions">';
+            echo '<label class="upload-action-btn" for="' . $fileInputId . '">Datei auswählen</label>';
+            echo '<label class="upload-action-btn upload-action-btn-camera" for="' . $cameraInputId . '">Foto aufnehmen</label>';
+            echo '</div>';
+            echo '<input id="' . $fileInputId . '" class="upload-native-input" type="file" name="' . $key . '" accept="' . $accept . '">';
+            echo '<input id="' . $cameraInputId . '" class="upload-native-input" type="file" name="' . $key . '__camera" accept="image/*" capture="environment">';
+            echo '<p class="upload-selected" data-upload-selected="' . $key . '">Keine Datei gewählt</p>';
+            echo '</div>';
             echo '<div class="upload-list" data-upload-list="' . $key . '"></div>';
         } else {
             $inputType = htmlspecialchars($type);
@@ -76,24 +88,34 @@ function renderField(array $field): void
 <main class="container">
     <h1>Digitaler Mitgliedsantrag Feuerwehrverein</h1>
 
-    <section id="startSection" class="card">
-        <h2>Start & Status</h2>
-        <p>Bitte E-Mail eingeben. Bestehende Entwürfe werden automatisch geladen.</p>
+    <section id="disclaimerSection" class="card">
+        <h2><?= htmlspecialchars($disclaimerTitle) ?></h2>
+        <p class="disclaimer-text"><?= nl2br(htmlspecialchars($disclaimerText)) ?></p>
+        <button id="acceptDisclaimerBtn" type="button"><?= htmlspecialchars($disclaimerAcceptLabel) ?></button>
+    </section>
+
+    <section id="startSection" class="card hidden">
+        <h2>Start</h2>
+        <p id="startIntroText">Bitte E-Mail eingeben. Bestehende Entwürfe werden automatisch geladen.</p>
         <form id="startForm" novalidate>
             <input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
             <div class="hp-field" aria-hidden="true">
                 <label for="website">Website</label>
                 <input id="website" type="text" name="website" autocomplete="off" tabindex="-1">
             </div>
-            <div class="field">
+            <div class="field" id="startEmailField">
                 <label for="startEmail">E-Mail</label>
-                <input id="startEmail" type="email" name="email" required>
+                <input id="startEmail" type="email" name="email" required inputmode="email" autocomplete="email">
             </div>
-            <div class="inline-actions">
+            <div class="inline-actions" id="startActions">
                 <button id="startSubmitBtn" type="submit">Formular laden</button>
-                <button id="resetDataBtn" type="button" class="hidden">Gespeicherte Daten löschen und neu starten</button>
             </div>
-            <p id="statusMessage" class="status-text" role="status" aria-live="polite"></p>
+            <div id="compactStatusBox" class="compact-status hidden">
+                <p><strong>E-Mail:</strong> <span id="statusEmailValue">-</span></p>
+                <p><strong>Speicherstatus:</strong> <span id="draftStatusValue">Noch nicht gespeichert</span></p>
+                <button id="resetDataBtn" type="button">Gespeicherte Daten löschen und neu starten</button>
+            </div>
+            <p id="feedbackMessage" class="status-text" role="status" aria-live="polite"></p>
         </form>
     </section>
 
@@ -113,10 +135,6 @@ function renderField(array $field): void
                     <?php foreach (($step['fields'] ?? []) as $field): ?>
                         <?php if (is_array($field)) { renderField($field); } ?>
                     <?php endforeach; ?>
-
-                    <?php if ((int) ($index + 1) === 3): ?>
-                        <button type="button" id="uploadNowBtn">Dateien jetzt speichern</button>
-                    <?php endif; ?>
                 </section>
             <?php endforeach; ?>
 

+ 50 - 7
src/Storage/FileUploadStore.php

@@ -27,12 +27,8 @@ final class FileUploadStore
         $errors = [];
 
         foreach ($uploadFields as $key => $fieldConfig) {
-            if (!isset($files[$key])) {
-                continue;
-            }
-
-            $file = $files[$key];
-            if (!is_array($file)) {
+            $file = $this->pickUploadedFile($files, $key);
+            if ($file === null) {
                 continue;
             }
 
@@ -59,14 +55,16 @@ final class FileUploadStore
             }
 
             $safeName = $this->sanitizeFilename((string) ($file['name'] ?? 'datei'));
+            $mime = $this->detectMime($tmpName);
+            $safeName = $this->ensureExtensionForMime($safeName, $mime);
             $extension = strtolower(pathinfo($safeName, PATHINFO_EXTENSION));
+
             $allowedExtensions = $fieldConfig['extensions'] ?? $this->app['uploads']['allowed_extensions'];
             if (!in_array($extension, $allowedExtensions, true)) {
                 $errors[$key] = 'Dateityp ist nicht erlaubt.';
                 continue;
             }
 
-            $mime = $this->detectMime($tmpName);
             $allowedMimes = $fieldConfig['mimes'] ?? $this->app['uploads']['allowed_mimes'];
             if (!in_array($mime, $allowedMimes, true)) {
                 $errors[$key] = 'MIME-Typ ist nicht erlaubt.';
@@ -106,6 +104,29 @@ final class FileUploadStore
         return ['uploads' => $uploaded, 'errors' => $errors];
     }
 
+    /** @param array<string, mixed> $files */
+    private function pickUploadedFile(array $files, string $fieldKey): ?array
+    {
+        $primary = $files[$fieldKey] ?? null;
+        $camera = $files[$fieldKey . '__camera'] ?? null;
+
+        if (is_array($primary) && ((int) ($primary['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK)) {
+            return $primary;
+        }
+        if (is_array($camera) && ((int) ($camera['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK)) {
+            return $camera;
+        }
+
+        if (is_array($primary) && ((int) ($primary['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE)) {
+            return $primary;
+        }
+        if (is_array($camera) && ((int) ($camera['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE)) {
+            return $camera;
+        }
+
+        return null;
+    }
+
     private function sanitizeFilename(string $name): string
     {
         $name = str_replace(["\0", '/', '\\'], '', $name);
@@ -148,6 +169,28 @@ final class FileUploadStore
         return $candidate;
     }
 
+    private function ensureExtensionForMime(string $fileName, string $mime): string
+    {
+        $ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
+        if ($ext !== '') {
+            return $fileName;
+        }
+
+        $map = [
+            'image/jpeg' => 'jpg',
+            'image/png' => 'png',
+            'image/webp' => 'webp',
+            'application/pdf' => 'pdf',
+        ];
+
+        $mapped = $map[$mime] ?? '';
+        if ($mapped === '') {
+            return $fileName;
+        }
+
+        return $fileName . '.' . $mapped;
+    }
+
     private function detectMime(string $path): string
     {
         $finfo = finfo_open(FILEINFO_MIME_TYPE);