upload
@@ -0,0 +1,78 @@
|
|||||||
|
# Event Bilder Upload
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="preview.png" alt="App Preview" width="500">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Ein einfacher, moderner und responsiver Web-Service zum datumsgenauen Hochladen von Bildern für beliebige Events.
|
||||||
|
|
||||||
|
## Organisatorische Details
|
||||||
|
|
||||||
|
- **Zweck**: Sammeln von Bildern von Teilnehmern eines Events.
|
||||||
|
- **Limitierungen**:
|
||||||
|
- Maximale Dateigröße pro Bild: 15 MB
|
||||||
|
- Erlaubte Formate: JPG, JPEG, PNG, GIF, WEBP, TIFF, HEIC
|
||||||
|
- **Deadline**: Ein Upload-Zeitraum kann festgelegt werden. Nach Ablauf der Deadline sind keine Uploads mehr möglich. Der Countdown wird live auf der Seite angezeigt.
|
||||||
|
- **Dateibenennung**: Die hochgeladenen Dateien werden serverseitig automatisch umbenannt. Das Format ist `Datum - Beschreibung.Endung`. Das Datum wird dabei intelligent aus den EXIF-Daten (Erstellungsdatum) des Bildes ausgelesen.
|
||||||
|
- **Speicherort**: Alle Bilder werden im Unterordner `images/` abgelegt.
|
||||||
|
|
||||||
|
## Technische Details
|
||||||
|
|
||||||
|
- **Technologie-Stack**:
|
||||||
|
- Backend: PHP (Einzelne Datei `index.php`)
|
||||||
|
- Frontend: HTML5, Vanilla JavaScript, Vanilla CSS
|
||||||
|
- **Architektur**:
|
||||||
|
- Keine Datenbank erforderlich.
|
||||||
|
- Asynchrone Uploads via AJAX inklusive Fortschrittsbalken und Einzelstatus pro Bild.
|
||||||
|
- Client-seitige und Server-seitige Validierung (Dateityp, Größe, Deadline).
|
||||||
|
- **Abhängigkeiten**:
|
||||||
|
- Webserver (z. B. Nginx, Apache oder Lighttpd)
|
||||||
|
- PHP 8.x
|
||||||
|
- PHP-Erweiterungen: `session`, `json`, `exif` (für das Auslesen des Aufnahmedatums), `mbstring`
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
Das Skript ist neutral gestaltet und kann für jedes beliebige Event genutzt werden. Alle Anpassungen können direkt im Kopfbereich der Datei `index.php` vorgenommen werden. Es sind keine weiteren Änderungen im HTML-Code nötig.
|
||||||
|
|
||||||
|
```php
|
||||||
|
// EVENT CONFIGURATION
|
||||||
|
const EVENT_NAME = 'Event Bilder Upload';
|
||||||
|
const EVENT_SUBTITLE = 'Speichere deine Momente datumsgerecht.';
|
||||||
|
|
||||||
|
// DEADLINE CONFIGURATION
|
||||||
|
// Format: YYYY-MM-DD HH:MM:SS
|
||||||
|
const UPLOAD_DEADLINE = '2026-11-30 23:59:59';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation auf einem frischen Alpine Linux
|
||||||
|
|
||||||
|
Die folgende Anleitung beschreibt die schnelle Einrichtung auf einem frisch installierten Alpine Linux System unter Verwendung des integrierten PHP-Webservers.
|
||||||
|
|
||||||
|
1. **System aktualisieren**:
|
||||||
|
```sh
|
||||||
|
apk update
|
||||||
|
apk upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **PHP und benötigte Erweiterungen installieren**:
|
||||||
|
```sh
|
||||||
|
apk add php php-json php-exif php-mbstring php-session
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Projektdateien bereitstellen**:
|
||||||
|
Legen Sie die Datei `index.php` in das gewünschte Verzeichnis auf dem Server (z. B. `/var/www/imgsender`).
|
||||||
|
|
||||||
|
4. **Verzeichnisrechte anpassen**:
|
||||||
|
Damit das Skript den Ordner `images/` anlegen und die hochgeladenen Bilder speichern kann, benötigt der ausführende Nutzer Schreibrechte in dem Verzeichnis.
|
||||||
|
```sh
|
||||||
|
cd /var/www/imgsender
|
||||||
|
chmod 777 .
|
||||||
|
```
|
||||||
|
*(Hinweis: Für den dauerhaften produktiven Einsatz mit einem echten Webserver wie Nginx oder Lighttpd sollten die Rechte restriktiver auf den jeweiligen Webserver-Nutzer, z. B. `nobody`, angepasst werden: `chown -R nobody:nobody .`)*
|
||||||
|
|
||||||
|
5. **Server starten**:
|
||||||
|
Für Testzwecke oder simple Deployments kann der integrierte PHP-Server genutzt werden:
|
||||||
|
```sh
|
||||||
|
php -S 0.0.0.0:8080 -t .
|
||||||
|
```
|
||||||
|
Die Anwendung ist nun über den Browser unter `http://<server-ip>:8080` erreichbar.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
.DS_Store
|
||||||
|
images/*
|
||||||
|
After Width: | Height: | Size: 568 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 413 KiB |
|
After Width: | Height: | Size: 458 KiB |
|
After Width: | Height: | Size: 431 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,819 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Image Uploader with EXIF Date Extraction (Multi-File Support)
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Mutiple Files Upload
|
||||||
|
* - Specific Naming Convention: <Date> - <Description>.<Extension>
|
||||||
|
* - Date from EXIF "DateTimeOriginal"
|
||||||
|
* - Max size 15MB per file
|
||||||
|
* - Live Countdown & Progress Bar
|
||||||
|
* - Modern Dark UI
|
||||||
|
*/
|
||||||
|
|
||||||
|
// EVENT CONFIGURATION
|
||||||
|
const EVENT_NAME = 'Event Bilder Upload';
|
||||||
|
const EVENT_SUBTITLE = 'Speichere deine Momente datumsgerecht.';
|
||||||
|
|
||||||
|
// DEADLINE CONFIGURATION
|
||||||
|
// Format: YYYY-MM-DD HH:MM:SS
|
||||||
|
const UPLOAD_DEADLINE = '2026-11-30 23:59:59';
|
||||||
|
|
||||||
|
// Timezone
|
||||||
|
date_default_timezone_set('Europe/Berlin');
|
||||||
|
|
||||||
|
$messages = []; // Array to store status messages for each file
|
||||||
|
|
||||||
|
// Maximum file size in bytes (15 MB)
|
||||||
|
const MAX_FILE_SIZE = 15 * 1024 * 1024;
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
$responseMessages = [];
|
||||||
|
|
||||||
|
// Check Deadline
|
||||||
|
if (time() > strtotime(UPLOAD_DEADLINE)) {
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'error',
|
||||||
|
'messages' => [['type' => 'error', 'text' => 'Der Upload-Zeitraum ist abgelaufen.']]
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetDir = __DIR__ . "/images/";
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
if (!is_dir($targetDir)) {
|
||||||
|
if (!@mkdir($targetDir, 0755, true)) {
|
||||||
|
$errorInfo = error_get_last();
|
||||||
|
$sysError = $errorInfo ? $errorInfo['message'] : 'Unbekannter Fehler';
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'error',
|
||||||
|
'messages' => [['type' => 'error', 'text' => "Server-Fehler: Konnte Verzeichnis 'images' nicht erstellen. ($sysError)"]]
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($_FILES['images'])) {
|
||||||
|
$fileCount = count($_FILES['images']['name']);
|
||||||
|
$descriptions = $_POST['descriptions'] ?? [];
|
||||||
|
|
||||||
|
for ($i = 0; $i < $fileCount; $i++) {
|
||||||
|
$fileName = $_FILES['images']['name'][$i];
|
||||||
|
$fileTmpPath = $_FILES['images']['tmp_name'][$i];
|
||||||
|
$fileSize = $_FILES['images']['size'][$i];
|
||||||
|
$fileError = $_FILES['images']['error'][$i];
|
||||||
|
$description = trim($descriptions[$i] ?? '');
|
||||||
|
|
||||||
|
// Context prefix for messages
|
||||||
|
$msgPrefix = "<strong>$fileName</strong>: ";
|
||||||
|
|
||||||
|
// Validate Description
|
||||||
|
$descriptionSafe = preg_replace('/[^a-zA-Z0-9\-_äöüÄÖÜß ]/u', '', $description);
|
||||||
|
$descriptionSafe = trim(preg_replace('/\s+/', ' ', $descriptionSafe));
|
||||||
|
|
||||||
|
if (empty($descriptionSafe)) {
|
||||||
|
$responseMessages[] = ['type' => 'error', 'text' => $msgPrefix . "Keine gültige Beschreibung."];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($fileError !== UPLOAD_ERR_OK) {
|
||||||
|
$errorMessages = [
|
||||||
|
UPLOAD_ERR_INI_SIZE => 'Datei zu groß (server limit).',
|
||||||
|
UPLOAD_ERR_FORM_SIZE => 'Datei zu groß (form limit).',
|
||||||
|
UPLOAD_ERR_PARTIAL => 'Nur teilweise hochgeladen.',
|
||||||
|
UPLOAD_ERR_NO_FILE => 'Keine Datei.',
|
||||||
|
UPLOAD_ERR_NO_TMP_DIR => 'Temporärer Ordner fehlt.',
|
||||||
|
UPLOAD_ERR_CANT_WRITE => 'Schreibfehler.',
|
||||||
|
];
|
||||||
|
$err = $errorMessages[$fileError] ?? "Fehler Code $fileError";
|
||||||
|
$responseMessages[] = ['type' => 'error', 'text' => $msgPrefix . $err];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($fileSize > MAX_FILE_SIZE) {
|
||||||
|
$responseMessages[] = ['type' => 'error', 'text' => $msgPrefix . "Größer als 15MB."];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extension Check
|
||||||
|
$fileNameCmps = explode(".", $fileName);
|
||||||
|
$fileExtension = strtolower(end($fileNameCmps));
|
||||||
|
$allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'tiff', 'heic'];
|
||||||
|
|
||||||
|
if (!in_array($fileExtension, $allowedExtensions)) {
|
||||||
|
$responseMessages[] = ['type' => 'error', 'text' => $msgPrefix . "Format nicht erlaubt."];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exif Date
|
||||||
|
$dateStr = null;
|
||||||
|
if (function_exists('exif_read_data')) {
|
||||||
|
$exif = @exif_read_data($fileTmpPath);
|
||||||
|
if ($exif && !empty($exif['DateTimeOriginal'])) {
|
||||||
|
$dateTimeObj = DateTime::createFromFormat('Y:m:d H:i:s', $exif['DateTimeOriginal']);
|
||||||
|
if ($dateTimeObj) {
|
||||||
|
$dateStr = $dateTimeObj->format('d-m-Y');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$dateStr) {
|
||||||
|
$dateStr = date('d-m-Y');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct Target Path
|
||||||
|
$newFileName = "{$dateStr} - {$descriptionSafe}.{$fileExtension}";
|
||||||
|
$dest_path = $targetDir . $newFileName;
|
||||||
|
|
||||||
|
// Handle Duplicates
|
||||||
|
$counter = 1;
|
||||||
|
$info = pathinfo($dest_path);
|
||||||
|
while (file_exists($dest_path)) {
|
||||||
|
$dest_path = $info['dirname'] . '/' . $info['filename'] . " ($counter)." . $info['extension'];
|
||||||
|
$newFileName = basename($dest_path);
|
||||||
|
$counter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (move_uploaded_file($fileTmpPath, $dest_path)) {
|
||||||
|
$responseMessages[] = ['type' => 'success', 'text' => $msgPrefix . "Gespeichert als \"$newFileName\"."];
|
||||||
|
} else {
|
||||||
|
$errorInfo = error_get_last();
|
||||||
|
$sysError = $errorInfo ? $errorInfo['message'] : 'Unbekannter Fehler';
|
||||||
|
$responseMessages[] = ['type' => 'error', 'text' => $msgPrefix . "Upload gescheitert: $sysError"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['status' => 'done', 'messages' => $responseMessages]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><?= htmlspecialchars(EVENT_NAME) ?></title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-color: #f5f3f0;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--text-main: #2c2c2c;
|
||||||
|
--text-muted: #7a7a7a;
|
||||||
|
--primary: #4a4a4a;
|
||||||
|
--primary-hover: #333333;
|
||||||
|
--accent: #6b7b6e;
|
||||||
|
--success: #5a7a5e;
|
||||||
|
--error: #a0503c;
|
||||||
|
--border: #ddd8d2;
|
||||||
|
--border-focus: #b0aaa2;
|
||||||
|
--input-bg: #faf9f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-main);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 720px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
p.subtitle {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Countdown */
|
||||||
|
.countdown-box {
|
||||||
|
text-align: center;
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Batch Controls */
|
||||||
|
.batch-controls {
|
||||||
|
background: var(--input-bg);
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group-batch {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group-batch label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: 14px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]::placeholder {
|
||||||
|
color: #b0aaa2;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]:focus {
|
||||||
|
border-color: var(--border-focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 10px 18px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-main);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
border-color: var(--border-focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drop Area */
|
||||||
|
.file-drop-area {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
padding: 36px;
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--input-bg);
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-drop-area:hover,
|
||||||
|
.file-drop-area.dragover {
|
||||||
|
border-color: var(--border-focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fake-btn {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
pointer-events: none;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-msg {
|
||||||
|
color: var(--text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File List */
|
||||||
|
.file-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
background: var(--input-bg);
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 6px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #e8e5e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: block;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
width: 100%;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove:hover {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Bar */
|
||||||
|
.progress-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
background: var(--accent);
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Submit */
|
||||||
|
.btn-submit {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: #ffffff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit:disabled {
|
||||||
|
background: var(--border);
|
||||||
|
cursor: not-allowed;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status */
|
||||||
|
.status-container {
|
||||||
|
margin-top: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-msg {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-msg.success {
|
||||||
|
background: #eef4ef;
|
||||||
|
color: var(--success);
|
||||||
|
border: 1px solid #d3e0d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-msg.error {
|
||||||
|
background: #f8efec;
|
||||||
|
color: var(--error);
|
||||||
|
border: 1px solid #e6d4ce;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Optimization */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.container {
|
||||||
|
padding: 24px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<h1><?= htmlspecialchars(EVENT_NAME) ?></h1>
|
||||||
|
<p class="subtitle"><?= htmlspecialchars(EVENT_SUBTITLE) ?></p>
|
||||||
|
|
||||||
|
<!-- Countdown -->
|
||||||
|
<div id="countdown" class="countdown-box">Lädt...</div>
|
||||||
|
|
||||||
|
<!-- Batch Controls -->
|
||||||
|
<div class="batch-controls">
|
||||||
|
<div class="form-group-batch">
|
||||||
|
<label>Sammel-Beschreibung</label>
|
||||||
|
<input type="text" id="globalDesc" placeholder="Eine Beschreibung für alle...">
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-secondary" id="applyAllBtn">Auf alle anwenden</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action="" method="POST" enctype="multipart/form-data" id="uploadForm">
|
||||||
|
|
||||||
|
<!-- Drop Area -->
|
||||||
|
<div class="file-drop-area" id="dropArea">
|
||||||
|
<span class="fake-btn">Dateien wählen</span>
|
||||||
|
<span class="file-msg">oder per Drag & Drop (Max. 15MB/Bild)</span>
|
||||||
|
<input class="file-input" type="file" name="images[]" id="fileInput" accept="image/*" multiple>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dynamic File List -->
|
||||||
|
<div class="file-list" id="fileList"></div>
|
||||||
|
|
||||||
|
<!-- Progress Bar -->
|
||||||
|
<div class="progress-container" id="progressContainer">
|
||||||
|
<div class="progress-bar" id="progressBar"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn-submit" id="submitBtn">Alles hochladen</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Status Output -->
|
||||||
|
<div class="status-container" id="statusContainer"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Configuration from PHP
|
||||||
|
const DEADLINE_TIMESTAMP = <?php echo strtotime(UPLOAD_DEADLINE) * 1000; ?>;
|
||||||
|
|
||||||
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
const dropArea = document.getElementById('dropArea');
|
||||||
|
const fileListEl = document.getElementById('fileList');
|
||||||
|
const globalDescInput = document.getElementById('globalDesc');
|
||||||
|
const applyAllBtn = document.getElementById('applyAllBtn');
|
||||||
|
const uploadForm = document.getElementById('uploadForm');
|
||||||
|
const progressBar = document.getElementById('progressBar');
|
||||||
|
const progressContainer = document.getElementById('progressContainer');
|
||||||
|
const statusContainer = document.getElementById('statusContainer');
|
||||||
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
|
const countdownEl = document.getElementById('countdown');
|
||||||
|
|
||||||
|
const dataTransfer = new DataTransfer();
|
||||||
|
|
||||||
|
/* --- Countdown Logic --- */
|
||||||
|
function updateCountdown() {
|
||||||
|
const now = new Date().getTime();
|
||||||
|
const distance = DEADLINE_TIMESTAMP - now;
|
||||||
|
|
||||||
|
if (distance < 0) {
|
||||||
|
countdownEl.innerHTML = "Upload-Zeitraum abgelaufen.";
|
||||||
|
countdownEl.style.color = "var(--error)";
|
||||||
|
countdownEl.style.borderColor = "#e6d4ce";
|
||||||
|
countdownEl.style.background = "#f8efec";
|
||||||
|
disableForm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = Math.floor(distance / (1000 * 60 * 60 * 24));
|
||||||
|
const hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||||
|
const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
const seconds = Math.floor((distance % (1000 * 60)) / 1000);
|
||||||
|
|
||||||
|
countdownEl.innerHTML = `Noch ${days}t ${hours}h ${minutes}m ${seconds}s bis zum Upload-Schluss`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableForm() {
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerText = "Upload geschlossen";
|
||||||
|
fileInput.disabled = true;
|
||||||
|
dropArea.style.pointerEvents = 'none'; // Disable drop area interaction
|
||||||
|
dropArea.style.opacity = '0.6'; // Visual cue
|
||||||
|
applyAllBtn.disabled = true;
|
||||||
|
globalDescInput.disabled = true;
|
||||||
|
// Also disable individual description inputs if any are rendered
|
||||||
|
Array.from(document.getElementsByName('descriptions[]')).forEach(input => input.disabled = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(updateCountdown, 1000);
|
||||||
|
updateCountdown(); // Initial call to display immediately
|
||||||
|
|
||||||
|
/* --- File Handling --- */
|
||||||
|
fileInput.addEventListener('change', (e) => handleFiles(e.target.files));
|
||||||
|
|
||||||
|
dropArea.addEventListener('dragenter', () => dropArea.classList.add('dragover'));
|
||||||
|
dropArea.addEventListener('dragleave', () => dropArea.classList.remove('dragover'));
|
||||||
|
dropArea.addEventListener('drop', (e) => dropArea.classList.remove('dragover'));
|
||||||
|
|
||||||
|
function handleFiles(files) {
|
||||||
|
// Check if form is disabled due to deadline
|
||||||
|
if (submitBtn.disabled) {
|
||||||
|
alert("Der Upload-Zeitraum ist abgelaufen. Es können keine neuen Dateien hinzugefügt werden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < files.length; i++) dataTransfer.items.add(files[i]);
|
||||||
|
fileInput.files = dataTransfer.files;
|
||||||
|
renderFileList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFileList() {
|
||||||
|
fileListEl.innerHTML = '';
|
||||||
|
Array.from(fileInput.files).forEach((file, index) => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'file-item';
|
||||||
|
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.className = 'file-preview';
|
||||||
|
img.src = URL.createObjectURL(file);
|
||||||
|
img.onload = () => URL.revokeObjectURL(img.src);
|
||||||
|
|
||||||
|
const infoDiv = document.createElement('div');
|
||||||
|
infoDiv.className = 'file-info';
|
||||||
|
|
||||||
|
const nameSpan = document.createElement('span');
|
||||||
|
nameSpan.className = 'file-name';
|
||||||
|
nameSpan.textContent = file.name;
|
||||||
|
|
||||||
|
const descInput = document.createElement('input');
|
||||||
|
descInput.type = 'text';
|
||||||
|
descInput.name = 'descriptions[]';
|
||||||
|
descInput.placeholder = 'Beschreibung...';
|
||||||
|
descInput.required = true;
|
||||||
|
if (submitBtn.disabled) { // If deadline passed, disable new inputs
|
||||||
|
descInput.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
infoDiv.appendChild(nameSpan);
|
||||||
|
infoDiv.appendChild(descInput);
|
||||||
|
|
||||||
|
const removeBtn = document.createElement('button');
|
||||||
|
removeBtn.type = 'button';
|
||||||
|
removeBtn.className = 'btn-remove';
|
||||||
|
removeBtn.innerHTML = '<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>';
|
||||||
|
removeBtn.onclick = () => removeFile(index);
|
||||||
|
if (submitBtn.disabled) { // If deadline passed, disable remove button
|
||||||
|
removeBtn.disabled = true;
|
||||||
|
removeBtn.style.opacity = '0.5';
|
||||||
|
removeBtn.style.cursor = 'not-allowed';
|
||||||
|
}
|
||||||
|
|
||||||
|
item.appendChild(img);
|
||||||
|
item.appendChild(infoDiv);
|
||||||
|
item.appendChild(removeBtn);
|
||||||
|
|
||||||
|
fileListEl.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFile(index) {
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
const files = fileInput.files;
|
||||||
|
const currentDescs = Array.from(document.getElementsByName('descriptions[]')).map(i => i.value);
|
||||||
|
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
if (i !== index) dt.items.add(files[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInput.files = dt.files;
|
||||||
|
renderFileList();
|
||||||
|
|
||||||
|
const newInputs = document.getElementsByName('descriptions[]');
|
||||||
|
let offset = 0;
|
||||||
|
for (let i = 0; i < currentDescs.length; i++) {
|
||||||
|
if (i === index) {
|
||||||
|
offset = 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (newInputs[i - offset]) newInputs[i - offset].value = currentDescs[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
dataTransfer.items.clear();
|
||||||
|
for (let i = 0; i < dt.files.length; i++) dataTransfer.items.add(dt.files[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyAllBtn.addEventListener('click', () => {
|
||||||
|
const val = globalDescInput.value;
|
||||||
|
if (!val) return;
|
||||||
|
const inputs = document.getElementsByName('descriptions[]');
|
||||||
|
for (let input of inputs) input.value = val;
|
||||||
|
});
|
||||||
|
|
||||||
|
/* --- AJAX Upload --- */
|
||||||
|
uploadForm.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Client-side deadline check (redundant but good UX)
|
||||||
|
if (new Date().getTime() > DEADLINE_TIMESTAMP) {
|
||||||
|
alert("Der Upload-Zeitraum ist abgelaufen. Bitte aktualisieren Sie die Seite.");
|
||||||
|
disableForm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
if (fileInput.files.length === 0) {
|
||||||
|
alert("Bitte wähle mindestens eine Datei aus.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate all description fields are filled
|
||||||
|
const descriptionInputs = document.getElementsByName('descriptions[]');
|
||||||
|
for (let input of descriptionInputs) {
|
||||||
|
if (!input.value.trim()) {
|
||||||
|
alert("Bitte füllen Sie alle Beschreibungsfelder aus.");
|
||||||
|
input.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData(uploadForm);
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
|
// UI Reset
|
||||||
|
statusContainer.innerHTML = ''; // Clear previous messages
|
||||||
|
progressContainer.style.display = 'block';
|
||||||
|
progressBar.style.width = '0%';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerText = 'Wird hochgeladen...';
|
||||||
|
fileInput.disabled = true; // Disable file input during upload
|
||||||
|
dropArea.style.pointerEvents = 'none';
|
||||||
|
applyAllBtn.disabled = true;
|
||||||
|
globalDescInput.disabled = true;
|
||||||
|
Array.from(document.getElementsByName('descriptions[]')).forEach(input => input.disabled = true);
|
||||||
|
Array.from(document.querySelectorAll('.btn-remove')).forEach(btn => btn.disabled = true);
|
||||||
|
|
||||||
|
|
||||||
|
// Progress Listener
|
||||||
|
xhr.upload.addEventListener('progress', (event) => {
|
||||||
|
if (event.lengthComputable) {
|
||||||
|
const percent = (event.loaded / event.total) * 100;
|
||||||
|
progressBar.style.width = percent + '%';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load Listener
|
||||||
|
xhr.addEventListener('load', () => {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerText = 'Alles hochladen';
|
||||||
|
progressContainer.style.display = 'none';
|
||||||
|
fileInput.disabled = false; // Re-enable file input
|
||||||
|
dropArea.style.pointerEvents = 'auto';
|
||||||
|
applyAllBtn.disabled = false;
|
||||||
|
globalDescInput.disabled = false;
|
||||||
|
Array.from(document.getElementsByName('descriptions[]')).forEach(input => input.disabled = false);
|
||||||
|
Array.from(document.querySelectorAll('.btn-remove')).forEach(btn => btn.disabled = false);
|
||||||
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(xhr.responseText);
|
||||||
|
|
||||||
|
if (json.messages && Array.isArray(json.messages)) {
|
||||||
|
json.messages.forEach(msg => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = `status-msg ${msg.type}`;
|
||||||
|
div.innerHTML = msg.text;
|
||||||
|
statusContainer.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If all uploads were successful, clear the file list
|
||||||
|
const allSuccess = json.messages.every(msg => msg.type === 'success');
|
||||||
|
if (allSuccess && json.messages.length > 0) {
|
||||||
|
dataTransfer.items.clear();
|
||||||
|
fileInput.files = dataTransfer.files;
|
||||||
|
renderFileList(); // Clear the displayed list
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'status-msg error';
|
||||||
|
div.textContent = 'Fehler beim Verarbeiten der Antwort: ' + xhr.responseText;
|
||||||
|
statusContainer.appendChild(div);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error Listener
|
||||||
|
xhr.addEventListener('error', () => {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerText = 'Alles hochladen';
|
||||||
|
progressContainer.style.display = 'none';
|
||||||
|
fileInput.disabled = false; // Re-enable file input
|
||||||
|
dropArea.style.pointerEvents = 'auto';
|
||||||
|
applyAllBtn.disabled = false;
|
||||||
|
globalDescInput.disabled = false;
|
||||||
|
Array.from(document.getElementsByName('descriptions[]')).forEach(input => input.disabled = false);
|
||||||
|
Array.from(document.querySelectorAll('.btn-remove')).forEach(btn => btn.disabled = false);
|
||||||
|
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'status-msg error';
|
||||||
|
div.textContent = 'Netzwerkfehler beim Upload.';
|
||||||
|
statusContainer.appendChild(div);
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.open('POST', '', true);
|
||||||
|
xhr.send(formData);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
After Width: | Height: | Size: 179 KiB |