This commit is contained in:
joel
2026-07-17 18:31:58 +02:00
commit 29a2c9eb02
11 changed files with 899 additions and 0 deletions
+819
View File
@@ -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>