Add FamilyAlbums family photo portal with Nextcloud integration
A PHP-based family photo album portal featuring: - Public gallery with year/month filtering and search - Mobile-responsive design with Tailwind CSS - Comment system for family members - Admin interface for album management - Flat-file JSON database (no MySQL needed) - CSRF protection and XSS prevention - Rate limiting and honeypot spam protection
This commit is contained in:
@@ -0,0 +1,48 @@
|
|||||||
|
# FamilyAlbums - Apache Configuration
|
||||||
|
|
||||||
|
# Security Headers
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
Header set X-Content-Type-Options "nosniff"
|
||||||
|
Header set X-Frame-Options "SAMEORIGIN"
|
||||||
|
Header set X-XSS-Protection "1; mode=block"
|
||||||
|
Header set Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Deny access to config file
|
||||||
|
<Files "config.php">
|
||||||
|
<IfModule mod_authz_core.c>
|
||||||
|
Require all denied
|
||||||
|
</IfModule>
|
||||||
|
<IfModule !mod_authz_core.c>
|
||||||
|
Order deny,allow
|
||||||
|
Deny from all
|
||||||
|
</IfModule>
|
||||||
|
</Files>
|
||||||
|
|
||||||
|
# Deny access to hidden files
|
||||||
|
<FilesMatch "^\.">
|
||||||
|
<IfModule mod_authz_core.c>
|
||||||
|
Require all denied
|
||||||
|
</IfModule>
|
||||||
|
<IfModule !mod_authz_core.c>
|
||||||
|
Order deny,allow
|
||||||
|
Deny from all
|
||||||
|
</IfModule>
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
|
# Enable compression
|
||||||
|
<IfModule mod_deflate.c>
|
||||||
|
AddOutputFilterByType DEFLATE text/html text/plain text/css application/json application/javascript
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
<IfModule mod_expires.c>
|
||||||
|
ExpiresActive On
|
||||||
|
ExpiresByType image/jpeg "access plus 1 month"
|
||||||
|
ExpiresByType image/png "access plus 1 month"
|
||||||
|
ExpiresByType image/gif "access plus 1 month"
|
||||||
|
ExpiresByType image/webp "access plus 1 month"
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Default charset
|
||||||
|
AddDefaultCharset UTF-8
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# FamilyAlbums - Familien-Fotoalbum-Portal
|
||||||
|
|
||||||
|
Ein einfaches, PHP-basiertes Portal zur Verwaltung und Anzeige von Familien-Fotoalben mit Links zu Nextcloud.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Öffentliche Galerie-Ansicht mit Jahr/Monat-Filter
|
||||||
|
- Stichwortsuche über Titel, Tags und Beschreibung
|
||||||
|
- Kommentarfunktion für Familienmitglieder
|
||||||
|
- Admin-Interface zur Albumverwaltung
|
||||||
|
- Responsive Design (Tailwind CSS)
|
||||||
|
- Flat-File Datenbank (JSON) - kein MySQL erforderlich
|
||||||
|
- Spam-Schutz (Honeypot + Rate-Limiting)
|
||||||
|
- CSRF-Schutz für Admin-Aktionen
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### 1. Dateien kopieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Auf den Webserver kopieren
|
||||||
|
sudo cp -r familyalbums /var/www/
|
||||||
|
|
||||||
|
# Berechtigungen setzen
|
||||||
|
sudo chown -R www-data:www-data /var/www/familyalbums
|
||||||
|
sudo chmod -R 755 /var/www/familyalbums
|
||||||
|
sudo chmod 770 /var/www/familyalbums/data
|
||||||
|
sudo chmod 770 /var/www/familyalbums/thumbnails
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Admin-Passwort ändern
|
||||||
|
|
||||||
|
**WICHTIG:** Das Standard-Passwort muss vor dem produktiven Einsatz geändert werden!
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Neuen Passwort-Hash generieren
|
||||||
|
php -r "echo password_hash('DeinSicheresPasswort', PASSWORD_DEFAULT);"
|
||||||
|
```
|
||||||
|
|
||||||
|
Den generierten Hash in `config.php` eintragen:
|
||||||
|
|
||||||
|
```php
|
||||||
|
define('ADMIN_PASSWORD_HASH', '$2y$10$DEIN_GENERIERTER_HASH_HIER');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Apache Virtual Host (optional)
|
||||||
|
|
||||||
|
```apache
|
||||||
|
<VirtualHost *:80>
|
||||||
|
ServerName familyalbums.example.com
|
||||||
|
DocumentRoot /var/www/familyalbums
|
||||||
|
|
||||||
|
<Directory /var/www/familyalbums>
|
||||||
|
AllowOverride All
|
||||||
|
Require all granted
|
||||||
|
</Directory>
|
||||||
|
</VirtualHost>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verwendung
|
||||||
|
|
||||||
|
### Öffentliche Galerie
|
||||||
|
|
||||||
|
- URL: `https://deine-domain.ch/`
|
||||||
|
- Filter nach Jahr und Monat
|
||||||
|
- Stichwortsuche
|
||||||
|
- Kommentare zu Alben hinterlassen
|
||||||
|
|
||||||
|
### Admin-Bereich
|
||||||
|
|
||||||
|
- URL: `https://deine-domain.ch/admin.php`
|
||||||
|
- Login mit dem konfigurierten Passwort
|
||||||
|
- Alben hinzufügen, bearbeiten, löschen
|
||||||
|
- Optional: Vorschaubilder hochladen
|
||||||
|
- Kommentare moderieren
|
||||||
|
|
||||||
|
## Datenstruktur
|
||||||
|
|
||||||
|
### albums.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"albums": [
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"title": "Albumtitel",
|
||||||
|
"url": "https://nextcloud.../apps/photos/public/...",
|
||||||
|
"date": "2024-12-25",
|
||||||
|
"tags": ["tag1", "tag2"],
|
||||||
|
"description": "Beschreibung",
|
||||||
|
"thumbnail": "thumbnails/bild.jpg",
|
||||||
|
"created_at": "2024-12-26T10:00:00+01:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### comments.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"comments": [
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"album_id": "album-uuid",
|
||||||
|
"author": "Name",
|
||||||
|
"text": "Kommentar",
|
||||||
|
"created_at": "2024-12-27T14:30:00+01:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
- Admin-Passwort mit bcrypt gehasht
|
||||||
|
- CSRF-Token für alle Admin-Aktionen
|
||||||
|
- XSS-Schutz durch `htmlspecialchars()`
|
||||||
|
- Rate-Limiting für Kommentare (5/Minute pro IP)
|
||||||
|
- Honeypot-Feld gegen Spam-Bots
|
||||||
|
- `.htaccess` schützt config.php und data/
|
||||||
|
|
||||||
|
## Anforderungen
|
||||||
|
|
||||||
|
- PHP 8.0+
|
||||||
|
- Apache mit mod_rewrite (optional)
|
||||||
|
- Schreibrechte für data/ und thumbnails/
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
Privates Projekt für Familien-Nutzung.
|
||||||
@@ -0,0 +1,658 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* FamilyAlbums - Admin Interface
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
$pageTitle = SITE_TITLE . ' - Administration';
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><?= e($pageTitle) ?></title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||||
|
<style>
|
||||||
|
.tag-input { display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.5rem; }
|
||||||
|
.tag-item { background: #dbeafe; color: #1d4ed8; padding: 0.25rem 0.5rem; border-radius: 9999px; display: flex; align-items: center; gap: 0.25rem; }
|
||||||
|
.tag-item button { color: #1d4ed8; cursor: pointer; }
|
||||||
|
.tag-input input { flex: 1; min-width: 100px; border: none; outline: none; }
|
||||||
|
.suggestions { position: absolute; top: 100%; left: 0; right: 0; background: white; border: 1px solid #d1d5db; border-radius: 0.5rem; max-height: 200px; overflow-y: auto; z-index: 10; }
|
||||||
|
.suggestions div { padding: 0.5rem 1rem; cursor: pointer; }
|
||||||
|
.suggestions div:hover { background: #f3f4f6; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 min-h-screen">
|
||||||
|
<!-- Login-Bereich (wird per JS gesteuert) -->
|
||||||
|
<div id="login-section" class="hidden min-h-screen flex items-center justify-center">
|
||||||
|
<div class="bg-white p-8 rounded-xl shadow-lg w-full max-w-md">
|
||||||
|
<h1 class="text-2xl font-bold text-center mb-6">
|
||||||
|
<i class="fas fa-lock mr-2 text-blue-600"></i>Admin Login
|
||||||
|
</h1>
|
||||||
|
<form id="login-form">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-gray-700 mb-2">Passwort</label>
|
||||||
|
<input type="password" id="login-password" required
|
||||||
|
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Admin-Passwort eingeben">
|
||||||
|
</div>
|
||||||
|
<div id="login-error" class="hidden text-red-500 text-sm mb-4"></div>
|
||||||
|
<button type="submit" class="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 transition">
|
||||||
|
<i class="fas fa-sign-in-alt mr-2"></i>Anmelden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="mt-4 text-center">
|
||||||
|
<a href="index.php" class="text-blue-600 hover:underline">
|
||||||
|
<i class="fas fa-arrow-left mr-1"></i>Zurück zur Galerie
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin-Bereich -->
|
||||||
|
<div id="admin-section" class="hidden">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="bg-gradient-to-r from-gray-800 to-gray-900 text-white shadow-lg">
|
||||||
|
<div class="container mx-auto px-4 py-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-xl font-bold">
|
||||||
|
<i class="fas fa-cog mr-2"></i><?= e($pageTitle) ?>
|
||||||
|
</h1>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<a href="index.php" class="text-white/80 hover:text-white">
|
||||||
|
<i class="fas fa-eye mr-1"></i>Galerie
|
||||||
|
</a>
|
||||||
|
<button onclick="logout()" class="text-white/80 hover:text-white">
|
||||||
|
<i class="fas fa-sign-out-alt mr-1"></i>Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="bg-white shadow">
|
||||||
|
<div class="container mx-auto px-4">
|
||||||
|
<nav class="flex gap-4">
|
||||||
|
<button onclick="showTab('albums')" id="tab-albums"
|
||||||
|
class="tab-btn py-4 px-2 border-b-2 border-blue-600 text-blue-600 font-medium">
|
||||||
|
<i class="fas fa-images mr-1"></i>Alben
|
||||||
|
</button>
|
||||||
|
<button onclick="showTab('comments')" id="tab-comments"
|
||||||
|
class="tab-btn py-4 px-2 border-b-2 border-transparent text-gray-500 hover:text-gray-700">
|
||||||
|
<i class="fas fa-comments mr-1"></i>Kommentare
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<!-- Alben-Tab -->
|
||||||
|
<div id="content-albums">
|
||||||
|
<!-- Album hinzufügen -->
|
||||||
|
<div class="bg-white rounded-xl shadow-md p-6 mb-8">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">
|
||||||
|
<i class="fas fa-plus-circle mr-2 text-green-600"></i>
|
||||||
|
<span id="form-title">Neues Album hinzufügen</span>
|
||||||
|
</h2>
|
||||||
|
<form id="album-form" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<input type="hidden" id="album-id">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-gray-700 mb-1">Titel *</label>
|
||||||
|
<input type="text" id="album-title" required
|
||||||
|
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="z.B. Weihnachten bei Oma">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-gray-700 mb-1">Datum *</label>
|
||||||
|
<input type="date" id="album-date" required
|
||||||
|
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="block text-gray-700 mb-1">Nextcloud-Link *</label>
|
||||||
|
<input type="url" id="album-url" required
|
||||||
|
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="https://nextcloud.example.com/apps/photos/public/...">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="block text-gray-700 mb-1">Beschreibung</label>
|
||||||
|
<textarea id="album-description" rows="2"
|
||||||
|
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Kurze Beschreibung des Albums"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2 relative">
|
||||||
|
<label class="block text-gray-700 mb-1">Tags</label>
|
||||||
|
<div class="tag-input" id="tags-container">
|
||||||
|
<input type="text" id="tag-input" placeholder="Tag eingeben und Enter drücken">
|
||||||
|
</div>
|
||||||
|
<div id="tag-suggestions" class="suggestions hidden"></div>
|
||||||
|
<input type="hidden" id="album-tags">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="block text-gray-700 mb-1">Vorschaubild (optional)</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="file" id="thumbnail-file" accept="image/*"
|
||||||
|
class="flex-1 px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500">
|
||||||
|
<button type="button" onclick="uploadThumbnail()" class="px-4 py-2 bg-gray-200 rounded-lg hover:bg-gray-300">
|
||||||
|
<i class="fas fa-upload"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="album-thumbnail">
|
||||||
|
<div id="thumbnail-preview" class="mt-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2 flex gap-2">
|
||||||
|
<button type="submit" class="bg-green-600 text-white px-6 py-2 rounded-lg hover:bg-green-700 transition">
|
||||||
|
<i class="fas fa-save mr-2"></i><span id="submit-text">Speichern</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="resetForm()" class="bg-gray-200 px-6 py-2 rounded-lg hover:bg-gray-300 transition">
|
||||||
|
<i class="fas fa-times mr-2"></i>Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Album-Liste -->
|
||||||
|
<div class="bg-white rounded-xl shadow-md p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">
|
||||||
|
<i class="fas fa-list mr-2 text-blue-600"></i>Alle Alben
|
||||||
|
</h2>
|
||||||
|
<div id="albums-list" class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-gray-600">Titel</th>
|
||||||
|
<th class="px-4 py-3 text-left text-gray-600">Datum</th>
|
||||||
|
<th class="px-4 py-3 text-left text-gray-600">Tags</th>
|
||||||
|
<th class="px-4 py-3 text-right text-gray-600">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="albums-table-body">
|
||||||
|
<!-- Wird per JS befüllt -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kommentare-Tab -->
|
||||||
|
<div id="content-comments" class="hidden">
|
||||||
|
<div class="bg-white rounded-xl shadow-md p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">
|
||||||
|
<i class="fas fa-comments mr-2 text-blue-600"></i>Alle Kommentare
|
||||||
|
</h2>
|
||||||
|
<div id="comments-list" class="space-y-4">
|
||||||
|
<!-- Wird per JS befüllt -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bestätigungs-Modal -->
|
||||||
|
<div id="confirm-modal" class="hidden fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<div class="bg-white rounded-xl shadow-2xl max-w-md w-full p-6">
|
||||||
|
<h3 class="text-lg font-semibold mb-4" id="confirm-title">Bestätigung</h3>
|
||||||
|
<p id="confirm-message" class="text-gray-600 mb-6"></p>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button onclick="closeConfirm()" class="px-4 py-2 bg-gray-200 rounded-lg hover:bg-gray-300">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button id="confirm-btn" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700">
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// === State ===
|
||||||
|
let csrfToken = '';
|
||||||
|
let allTags = [];
|
||||||
|
let currentTags = [];
|
||||||
|
let editingAlbumId = null;
|
||||||
|
let confirmCallback = null;
|
||||||
|
|
||||||
|
// === Auth ===
|
||||||
|
async function checkAuth() {
|
||||||
|
const response = await fetch('api.php?action=check_auth');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.authenticated) {
|
||||||
|
csrfToken = data.csrf;
|
||||||
|
document.getElementById('login-section').classList.add('hidden');
|
||||||
|
document.getElementById('admin-section').classList.remove('hidden');
|
||||||
|
loadAlbums();
|
||||||
|
loadAllTags();
|
||||||
|
} else {
|
||||||
|
document.getElementById('login-section').classList.remove('hidden');
|
||||||
|
document.getElementById('admin-section').classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const password = document.getElementById('login-password').value;
|
||||||
|
const errorDiv = document.getElementById('login-error');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('api.php?action=login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
csrfToken = data.csrf;
|
||||||
|
document.getElementById('login-section').classList.add('hidden');
|
||||||
|
document.getElementById('admin-section').classList.remove('hidden');
|
||||||
|
loadAlbums();
|
||||||
|
loadAllTags();
|
||||||
|
} else {
|
||||||
|
errorDiv.textContent = data.error || 'Login fehlgeschlagen';
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorDiv.textContent = 'Verbindungsfehler';
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await fetch('api.php?action=logout', { method: 'POST' });
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Tabs ===
|
||||||
|
function showTab(tab) {
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
|
btn.classList.remove('border-blue-600', 'text-blue-600');
|
||||||
|
btn.classList.add('border-transparent', 'text-gray-500');
|
||||||
|
});
|
||||||
|
document.getElementById(`tab-${tab}`).classList.add('border-blue-600', 'text-blue-600');
|
||||||
|
document.getElementById(`tab-${tab}`).classList.remove('border-transparent', 'text-gray-500');
|
||||||
|
|
||||||
|
document.getElementById('content-albums').classList.add('hidden');
|
||||||
|
document.getElementById('content-comments').classList.add('hidden');
|
||||||
|
document.getElementById(`content-${tab}`).classList.remove('hidden');
|
||||||
|
|
||||||
|
if (tab === 'comments') {
|
||||||
|
loadAllComments();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Albums ===
|
||||||
|
async function loadAlbums() {
|
||||||
|
const response = await fetch('api.php?action=albums');
|
||||||
|
const data = await response.json();
|
||||||
|
renderAlbumsTable(data.albums || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAlbumsTable(albums) {
|
||||||
|
const tbody = document.getElementById('albums-table-body');
|
||||||
|
|
||||||
|
if (albums.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="4" class="text-center py-8 text-gray-500">Noch keine Alben vorhanden</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = albums.map(album => `
|
||||||
|
<tr class="border-t hover:bg-gray-50">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="font-medium">${escapeHtml(album.title)}</div>
|
||||||
|
<div class="text-sm text-gray-500 truncate max-w-xs">${escapeHtml(album.url)}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-gray-600">${album.date}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
${album.tags.slice(0, 3).map(tag =>
|
||||||
|
`<span class="bg-blue-100 text-blue-700 text-xs px-2 py-0.5 rounded-full">${escapeHtml(tag)}</span>`
|
||||||
|
).join('')}
|
||||||
|
${album.tags.length > 3 ? `<span class="text-gray-400 text-xs">+${album.tags.length - 3}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<button onclick='editAlbum(${JSON.stringify(album).replace(/'/g, "'")})' class="text-blue-600 hover:text-blue-800 mr-2">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</button>
|
||||||
|
<button onclick="confirmDelete('album', '${album.id}', '${escapeHtml(album.title)}')" class="text-red-600 hover:text-red-800">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAllTags() {
|
||||||
|
const response = await fetch('api.php?action=tags');
|
||||||
|
const data = await response.json();
|
||||||
|
allTags = data.tags || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Album Form ===
|
||||||
|
document.getElementById('album-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const album = {
|
||||||
|
csrf: csrfToken,
|
||||||
|
title: document.getElementById('album-title').value,
|
||||||
|
url: document.getElementById('album-url').value,
|
||||||
|
date: document.getElementById('album-date').value,
|
||||||
|
description: document.getElementById('album-description').value,
|
||||||
|
tags: currentTags,
|
||||||
|
thumbnail: document.getElementById('album-thumbnail').value
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = 'api.php?action=album';
|
||||||
|
let method = 'POST';
|
||||||
|
|
||||||
|
if (editingAlbumId) {
|
||||||
|
album.id = editingAlbumId;
|
||||||
|
method = 'PUT';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(album)
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
resetForm();
|
||||||
|
loadAlbums();
|
||||||
|
loadAllTags();
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Fehler beim Speichern');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Verbindungsfehler');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function editAlbum(album) {
|
||||||
|
editingAlbumId = album.id;
|
||||||
|
document.getElementById('album-id').value = album.id;
|
||||||
|
document.getElementById('album-title').value = album.title;
|
||||||
|
document.getElementById('album-url').value = album.url;
|
||||||
|
document.getElementById('album-date').value = album.date;
|
||||||
|
document.getElementById('album-description').value = album.description || '';
|
||||||
|
document.getElementById('album-thumbnail').value = album.thumbnail || '';
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
currentTags = [...album.tags];
|
||||||
|
renderTags();
|
||||||
|
|
||||||
|
// Thumbnail preview
|
||||||
|
if (album.thumbnail) {
|
||||||
|
document.getElementById('thumbnail-preview').innerHTML =
|
||||||
|
`<img src="${escapeHtml(album.thumbnail)}" class="h-20 rounded">`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('form-title').textContent = 'Album bearbeiten';
|
||||||
|
document.getElementById('submit-text').textContent = 'Aktualisieren';
|
||||||
|
|
||||||
|
// Scroll to form
|
||||||
|
document.getElementById('album-form').scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
editingAlbumId = null;
|
||||||
|
document.getElementById('album-form').reset();
|
||||||
|
document.getElementById('album-thumbnail').value = '';
|
||||||
|
document.getElementById('thumbnail-preview').innerHTML = '';
|
||||||
|
currentTags = [];
|
||||||
|
renderTags();
|
||||||
|
document.getElementById('form-title').textContent = 'Neues Album hinzufügen';
|
||||||
|
document.getElementById('submit-text').textContent = 'Speichern';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAlbum(id) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('api.php?action=album', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id, csrf: csrfToken })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
loadAlbums();
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Fehler beim Löschen');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Verbindungsfehler');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Tags ===
|
||||||
|
function renderTags() {
|
||||||
|
const container = document.getElementById('tags-container');
|
||||||
|
const input = document.getElementById('tag-input');
|
||||||
|
|
||||||
|
// Remove existing tag items
|
||||||
|
container.querySelectorAll('.tag-item').forEach(el => el.remove());
|
||||||
|
|
||||||
|
// Add tag items before input
|
||||||
|
currentTags.forEach((tag, index) => {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'tag-item';
|
||||||
|
span.innerHTML = `${escapeHtml(tag)}<button type="button" onclick="removeTag(${index})">×</button>`;
|
||||||
|
container.insertBefore(span, input);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTag(index) {
|
||||||
|
currentTags.splice(index, 1);
|
||||||
|
renderTags();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('tag-input').addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ',') {
|
||||||
|
e.preventDefault();
|
||||||
|
const value = e.target.value.trim();
|
||||||
|
if (value && !currentTags.includes(value)) {
|
||||||
|
currentTags.push(value);
|
||||||
|
renderTags();
|
||||||
|
}
|
||||||
|
e.target.value = '';
|
||||||
|
document.getElementById('tag-suggestions').classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('tag-input').addEventListener('input', (e) => {
|
||||||
|
const value = e.target.value.toLowerCase();
|
||||||
|
const suggestions = document.getElementById('tag-suggestions');
|
||||||
|
|
||||||
|
if (value.length < 1) {
|
||||||
|
suggestions.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = allTags.filter(tag =>
|
||||||
|
tag.toLowerCase().includes(value) && !currentTags.includes(tag)
|
||||||
|
).slice(0, 5);
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
suggestions.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestions.innerHTML = matches.map(tag =>
|
||||||
|
`<div onclick="selectTag('${escapeHtml(tag)}')">${escapeHtml(tag)}</div>`
|
||||||
|
).join('');
|
||||||
|
suggestions.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectTag(tag) {
|
||||||
|
if (!currentTags.includes(tag)) {
|
||||||
|
currentTags.push(tag);
|
||||||
|
renderTags();
|
||||||
|
}
|
||||||
|
document.getElementById('tag-input').value = '';
|
||||||
|
document.getElementById('tag-suggestions').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Thumbnail Upload ===
|
||||||
|
async function uploadThumbnail() {
|
||||||
|
const fileInput = document.getElementById('thumbnail-file');
|
||||||
|
if (!fileInput.files[0]) {
|
||||||
|
alert('Bitte wähle zuerst ein Bild aus');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('thumbnail', fileInput.files[0]);
|
||||||
|
formData.append('csrf', csrfToken);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('api.php?action=upload_thumbnail', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
document.getElementById('album-thumbnail').value = data.path;
|
||||||
|
document.getElementById('thumbnail-preview').innerHTML =
|
||||||
|
`<img src="${escapeHtml(data.path)}" class="h-20 rounded">`;
|
||||||
|
fileInput.value = '';
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Upload fehlgeschlagen');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Verbindungsfehler');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Comments ===
|
||||||
|
async function loadAllComments() {
|
||||||
|
const albumsResponse = await fetch('api.php?action=albums');
|
||||||
|
const albumsData = await albumsResponse.json();
|
||||||
|
const albums = albumsData.albums || [];
|
||||||
|
|
||||||
|
const commentsContainer = document.getElementById('comments-list');
|
||||||
|
commentsContainer.innerHTML = '<p class="text-center"><i class="fas fa-spinner fa-spin"></i> Lade Kommentare...</p>';
|
||||||
|
|
||||||
|
// Kommentare für alle Alben laden
|
||||||
|
const allComments = [];
|
||||||
|
for (const album of albums) {
|
||||||
|
const response = await fetch(`api.php?action=comments&album_id=${album.id}`);
|
||||||
|
const data = await response.json();
|
||||||
|
(data.comments || []).forEach(comment => {
|
||||||
|
comment.albumTitle = album.title;
|
||||||
|
allComments.push(comment);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nach Datum sortieren
|
||||||
|
allComments.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
||||||
|
|
||||||
|
if (allComments.length === 0) {
|
||||||
|
commentsContainer.innerHTML = '<p class="text-center text-gray-500 py-8">Noch keine Kommentare vorhanden</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
commentsContainer.innerHTML = allComments.map(comment => `
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<div class="flex justify-between items-start mb-2">
|
||||||
|
<div>
|
||||||
|
<span class="font-semibold">${escapeHtml(comment.author)}</span>
|
||||||
|
<span class="text-gray-400 text-sm ml-2">${formatDateTime(comment.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<button onclick="confirmDelete('comment', '${comment.id}', 'diesen Kommentar')" class="text-red-600 hover:text-red-800">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-700 mb-2">${escapeHtml(comment.text)}</p>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
<i class="fas fa-images mr-1"></i>${escapeHtml(comment.albumTitle)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteComment(id) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('api.php?action=comment', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id, csrf: csrfToken })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
loadAllComments();
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Fehler beim Löschen');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Verbindungsfehler');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Confirm Modal ===
|
||||||
|
function confirmDelete(type, id, name) {
|
||||||
|
document.getElementById('confirm-message').textContent =
|
||||||
|
`Möchtest du "${name}" wirklich löschen?`;
|
||||||
|
|
||||||
|
confirmCallback = () => {
|
||||||
|
if (type === 'album') {
|
||||||
|
deleteAlbum(id);
|
||||||
|
} else if (type === 'comment') {
|
||||||
|
deleteComment(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('confirm-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfirm() {
|
||||||
|
document.getElementById('confirm-modal').classList.add('hidden');
|
||||||
|
confirmCallback = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('confirm-btn').addEventListener('click', () => {
|
||||||
|
if (confirmCallback) {
|
||||||
|
confirmCallback();
|
||||||
|
}
|
||||||
|
closeConfirm();
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Helpers ===
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(isoStr) {
|
||||||
|
const date = new Date(isoStr);
|
||||||
|
return date.toLocaleDateString('de-CH', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Init ===
|
||||||
|
checkAuth();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* FamilyAlbums - API Endpunkte
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
// Hilfsfunktion: JSON Response
|
||||||
|
function json_response(array $data, int $code = 200): void {
|
||||||
|
http_response_code($code);
|
||||||
|
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hilfsfunktion: Admin-Check
|
||||||
|
function require_admin(): void {
|
||||||
|
if (empty($_SESSION['admin_logged_in'])) {
|
||||||
|
json_response(['error' => 'Nicht autorisiert'], 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === ALBEN ===
|
||||||
|
|
||||||
|
if ($action === 'albums' && $method === 'GET') {
|
||||||
|
// Alle Alben abrufen (öffentlich)
|
||||||
|
$data = read_json(ALBUMS_FILE);
|
||||||
|
$albums = $data['albums'] ?? [];
|
||||||
|
|
||||||
|
// Filter: Jahr
|
||||||
|
if (!empty($_GET['year'])) {
|
||||||
|
$year = $_GET['year'];
|
||||||
|
$albums = array_filter($albums, fn($a) => substr($a['date'], 0, 4) === $year);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter: Monat
|
||||||
|
if (!empty($_GET['month'])) {
|
||||||
|
$month = $_GET['month'];
|
||||||
|
$albums = array_filter($albums, fn($a) => substr($a['date'], 5, 2) === $month);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter: Suche
|
||||||
|
if (!empty($_GET['search'])) {
|
||||||
|
$search = mb_strtolower($_GET['search']);
|
||||||
|
$albums = array_filter($albums, function($a) use ($search) {
|
||||||
|
$haystack = mb_strtolower($a['title'] . ' ' . $a['description'] . ' ' . implode(' ', $a['tags']));
|
||||||
|
return str_contains($haystack, $search);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sortierung
|
||||||
|
$sort = $_GET['sort'] ?? 'newest';
|
||||||
|
usort($albums, function($a, $b) use ($sort) {
|
||||||
|
if ($sort === 'oldest') {
|
||||||
|
return strcmp($a['date'], $b['date']);
|
||||||
|
}
|
||||||
|
return strcmp($b['date'], $a['date']); // newest first
|
||||||
|
});
|
||||||
|
|
||||||
|
json_response(['albums' => array_values($albums)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'album' && $method === 'POST') {
|
||||||
|
// Album erstellen (Admin)
|
||||||
|
require_admin();
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!csrf_validate($input['csrf'] ?? '')) {
|
||||||
|
json_response(['error' => 'Ungültiges CSRF-Token'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($input['title']) || empty($input['url']) || empty($input['date'])) {
|
||||||
|
json_response(['error' => 'Titel, URL und Datum sind Pflichtfelder'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$album = [
|
||||||
|
'id' => generate_uuid(),
|
||||||
|
'title' => trim($input['title']),
|
||||||
|
'url' => trim($input['url']),
|
||||||
|
'date' => $input['date'],
|
||||||
|
'tags' => array_map('trim', $input['tags'] ?? []),
|
||||||
|
'description' => trim($input['description'] ?? ''),
|
||||||
|
'thumbnail' => $input['thumbnail'] ?? '',
|
||||||
|
'created_at' => date('c')
|
||||||
|
];
|
||||||
|
|
||||||
|
$data = read_json(ALBUMS_FILE);
|
||||||
|
$data['albums'][] = $album;
|
||||||
|
write_json(ALBUMS_FILE, $data);
|
||||||
|
|
||||||
|
json_response(['success' => true, 'album' => $album]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'album' && $method === 'PUT') {
|
||||||
|
// Album bearbeiten (Admin)
|
||||||
|
require_admin();
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!csrf_validate($input['csrf'] ?? '')) {
|
||||||
|
json_response(['error' => 'Ungültiges CSRF-Token'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $input['id'] ?? '';
|
||||||
|
|
||||||
|
$data = read_json(ALBUMS_FILE);
|
||||||
|
$found = false;
|
||||||
|
|
||||||
|
foreach ($data['albums'] as &$album) {
|
||||||
|
if ($album['id'] === $id) {
|
||||||
|
$album['title'] = trim($input['title'] ?? $album['title']);
|
||||||
|
$album['url'] = trim($input['url'] ?? $album['url']);
|
||||||
|
$album['date'] = $input['date'] ?? $album['date'];
|
||||||
|
$album['tags'] = array_map('trim', $input['tags'] ?? $album['tags']);
|
||||||
|
$album['description'] = trim($input['description'] ?? $album['description']);
|
||||||
|
$album['thumbnail'] = $input['thumbnail'] ?? $album['thumbnail'];
|
||||||
|
$found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$found) {
|
||||||
|
json_response(['error' => 'Album nicht gefunden'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
write_json(ALBUMS_FILE, $data);
|
||||||
|
json_response(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'album' && $method === 'DELETE') {
|
||||||
|
// Album löschen (Admin)
|
||||||
|
require_admin();
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!csrf_validate($input['csrf'] ?? '')) {
|
||||||
|
json_response(['error' => 'Ungültiges CSRF-Token'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $input['id'] ?? '';
|
||||||
|
|
||||||
|
$data = read_json(ALBUMS_FILE);
|
||||||
|
$data['albums'] = array_filter($data['albums'], fn($a) => $a['id'] !== $id);
|
||||||
|
$data['albums'] = array_values($data['albums']);
|
||||||
|
write_json(ALBUMS_FILE, $data);
|
||||||
|
|
||||||
|
// Zugehörige Kommentare löschen
|
||||||
|
$comments = read_json(COMMENTS_FILE);
|
||||||
|
$comments['comments'] = array_filter($comments['comments'], fn($c) => $c['album_id'] !== $id);
|
||||||
|
$comments['comments'] = array_values($comments['comments']);
|
||||||
|
write_json(COMMENTS_FILE, $comments);
|
||||||
|
|
||||||
|
json_response(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === KOMMENTARE ===
|
||||||
|
|
||||||
|
if ($action === 'comments' && $method === 'GET') {
|
||||||
|
// Kommentare für Album abrufen (öffentlich)
|
||||||
|
$album_id = $_GET['album_id'] ?? '';
|
||||||
|
|
||||||
|
$data = read_json(COMMENTS_FILE);
|
||||||
|
$comments = array_filter($data['comments'] ?? [], fn($c) => $c['album_id'] === $album_id);
|
||||||
|
|
||||||
|
// Nach Datum sortieren (neueste zuerst)
|
||||||
|
usort($comments, fn($a, $b) => strcmp($b['created_at'], $a['created_at']));
|
||||||
|
|
||||||
|
json_response(['comments' => array_values($comments)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'comment' && $method === 'POST') {
|
||||||
|
// Kommentar erstellen (öffentlich)
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (empty($input['album_id']) || empty($input['author']) || empty($input['text'])) {
|
||||||
|
json_response(['error' => 'Album-ID, Name und Text sind Pflichtfelder'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Honeypot-Check (Spam-Schutz)
|
||||||
|
if (!empty($input['website'])) {
|
||||||
|
json_response(['success' => true]); // Fake-Erfolg für Bots
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate-Limiting: Max 5 Kommentare pro Minute pro IP
|
||||||
|
$ip = $_SERVER['REMOTE_ADDR'];
|
||||||
|
$rate_file = DATA_PATH . 'rate_' . md5($ip) . '.json';
|
||||||
|
$rate_data = read_json($rate_file);
|
||||||
|
$now = time();
|
||||||
|
$rate_data['times'] = array_filter($rate_data['times'] ?? [], fn($t) => $t > $now - 60);
|
||||||
|
|
||||||
|
if (count($rate_data['times']) >= 5) {
|
||||||
|
json_response(['error' => 'Zu viele Kommentare. Bitte warte eine Minute.'], 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rate_data['times'][] = $now;
|
||||||
|
write_json($rate_file, $rate_data);
|
||||||
|
|
||||||
|
$comment = [
|
||||||
|
'id' => generate_uuid(),
|
||||||
|
'album_id' => $input['album_id'],
|
||||||
|
'author' => trim($input['author']),
|
||||||
|
'text' => trim($input['text']),
|
||||||
|
'created_at' => date('c')
|
||||||
|
];
|
||||||
|
|
||||||
|
$data = read_json(COMMENTS_FILE);
|
||||||
|
$data['comments'][] = $comment;
|
||||||
|
write_json(COMMENTS_FILE, $data);
|
||||||
|
|
||||||
|
json_response(['success' => true, 'comment' => $comment]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'comment' && $method === 'DELETE') {
|
||||||
|
// Kommentar löschen (Admin)
|
||||||
|
require_admin();
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!csrf_validate($input['csrf'] ?? '')) {
|
||||||
|
json_response(['error' => 'Ungültiges CSRF-Token'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $input['id'] ?? '';
|
||||||
|
|
||||||
|
$data = read_json(COMMENTS_FILE);
|
||||||
|
$data['comments'] = array_filter($data['comments'], fn($c) => $c['id'] !== $id);
|
||||||
|
$data['comments'] = array_values($data['comments']);
|
||||||
|
write_json(COMMENTS_FILE, $data);
|
||||||
|
|
||||||
|
json_response(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === TAGS ===
|
||||||
|
|
||||||
|
if ($action === 'tags' && $method === 'GET') {
|
||||||
|
// Alle verwendeten Tags abrufen (für Vorschläge)
|
||||||
|
$data = read_json(ALBUMS_FILE);
|
||||||
|
$tags = [];
|
||||||
|
|
||||||
|
foreach ($data['albums'] ?? [] as $album) {
|
||||||
|
foreach ($album['tags'] ?? [] as $tag) {
|
||||||
|
$tags[$tag] = ($tags[$tag] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
arsort($tags);
|
||||||
|
json_response(['tags' => array_keys($tags)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === JAHRE/MONATE ===
|
||||||
|
|
||||||
|
if ($action === 'dates' && $method === 'GET') {
|
||||||
|
// Verfügbare Jahre und Monate
|
||||||
|
$data = read_json(ALBUMS_FILE);
|
||||||
|
$years = [];
|
||||||
|
|
||||||
|
foreach ($data['albums'] ?? [] as $album) {
|
||||||
|
$year = substr($album['date'], 0, 4);
|
||||||
|
$month = substr($album['date'], 5, 2);
|
||||||
|
|
||||||
|
if (!isset($years[$year])) {
|
||||||
|
$years[$year] = [];
|
||||||
|
}
|
||||||
|
if (!in_array($month, $years[$year])) {
|
||||||
|
$years[$year][] = $month;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sortieren
|
||||||
|
krsort($years);
|
||||||
|
foreach ($years as &$months) {
|
||||||
|
sort($months);
|
||||||
|
}
|
||||||
|
|
||||||
|
json_response(['dates' => $years]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === AUTH ===
|
||||||
|
|
||||||
|
if ($action === 'login' && $method === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$password = $input['password'] ?? '';
|
||||||
|
|
||||||
|
if (password_verify($password, ADMIN_PASSWORD_HASH)) {
|
||||||
|
$_SESSION['admin_logged_in'] = true;
|
||||||
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
json_response(['success' => true, 'csrf' => $_SESSION['csrf_token']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verzögerung gegen Brute-Force
|
||||||
|
sleep(1);
|
||||||
|
json_response(['error' => 'Falsches Passwort'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'logout' && $method === 'POST') {
|
||||||
|
session_destroy();
|
||||||
|
json_response(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'check_auth' && $method === 'GET') {
|
||||||
|
json_response([
|
||||||
|
'authenticated' => !empty($_SESSION['admin_logged_in']),
|
||||||
|
'csrf' => $_SESSION['csrf_token'] ?? ''
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === THUMBNAIL UPLOAD ===
|
||||||
|
|
||||||
|
if ($action === 'upload_thumbnail' && $method === 'POST') {
|
||||||
|
require_admin();
|
||||||
|
|
||||||
|
if (empty($_POST['csrf']) || !csrf_validate($_POST['csrf'])) {
|
||||||
|
json_response(['error' => 'Ungültiges CSRF-Token'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($_FILES['thumbnail']) || $_FILES['thumbnail']['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
json_response(['error' => 'Kein Bild hochgeladen'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $_FILES['thumbnail'];
|
||||||
|
$allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||||
|
|
||||||
|
if (!in_array($file['type'], $allowed)) {
|
||||||
|
json_response(['error' => 'Nur JPG, PNG, GIF und WebP erlaubt'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($file['size'] > 5 * 1024 * 1024) {
|
||||||
|
json_response(['error' => 'Maximale Dateigrösse: 5MB'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
|
||||||
|
$filename = generate_uuid() . '.' . $ext;
|
||||||
|
$path = THUMBNAIL_PATH . $filename;
|
||||||
|
|
||||||
|
if (!move_uploaded_file($file['tmp_name'], $path)) {
|
||||||
|
json_response(['error' => 'Upload fehlgeschlagen'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
json_response(['success' => true, 'path' => THUMBNAIL_URL . $filename]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unbekannte Aktion
|
||||||
|
json_response(['error' => 'Unbekannte Aktion'], 404);
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* FamilyAlbums - Konfiguration
|
||||||
|
*
|
||||||
|
* WICHTIG: Nach erster Installation Passwort ändern!
|
||||||
|
* Neuen Hash generieren: php -r "echo password_hash('deinPasswort', PASSWORD_DEFAULT);"
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Standard-Passwort: "familie2024" - BITTE ÄNDERN!
|
||||||
|
define('ADMIN_PASSWORD_HASH', '$2y$10$YxQx8B7GkDqNmPrC4VzKH.qN4tQ8WvX5kF7mZ3hJ9aE1bC2dR6uYO');
|
||||||
|
|
||||||
|
define('SITE_TITLE', 'Familien-Fotoalben');
|
||||||
|
define('DATA_PATH', __DIR__ . '/data/');
|
||||||
|
define('THUMBNAIL_PATH', __DIR__ . '/thumbnails/');
|
||||||
|
define('THUMBNAIL_URL', 'thumbnails/');
|
||||||
|
|
||||||
|
define('ALBUMS_FILE', DATA_PATH . 'albums.json');
|
||||||
|
define('COMMENTS_FILE', DATA_PATH . 'comments.json');
|
||||||
|
|
||||||
|
// Session-Einstellungen
|
||||||
|
define('SESSION_LIFETIME', 3600); // 1 Stunde
|
||||||
|
|
||||||
|
// Zeitzone
|
||||||
|
date_default_timezone_set('Europe/Zurich');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON-Datei lesen
|
||||||
|
*/
|
||||||
|
function read_json(string $file): array {
|
||||||
|
if (!file_exists($file)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$content = file_get_contents($file);
|
||||||
|
return json_decode($content, true) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON-Datei schreiben
|
||||||
|
*/
|
||||||
|
function write_json(string $file, array $data): bool {
|
||||||
|
$dir = dirname($file);
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
mkdir($dir, 0770, true);
|
||||||
|
}
|
||||||
|
return file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UUID generieren
|
||||||
|
*/
|
||||||
|
function generate_uuid(): string {
|
||||||
|
return sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||||
|
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
||||||
|
mt_rand(0, 0xffff),
|
||||||
|
mt_rand(0, 0x0fff) | 0x4000,
|
||||||
|
mt_rand(0, 0x3fff) | 0x8000,
|
||||||
|
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* XSS-sichere Ausgabe
|
||||||
|
*/
|
||||||
|
function e(string $str): string {
|
||||||
|
return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSRF-Token generieren
|
||||||
|
*/
|
||||||
|
function csrf_token(): string {
|
||||||
|
if (empty($_SESSION['csrf_token'])) {
|
||||||
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
return $_SESSION['csrf_token'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSRF-Token validieren
|
||||||
|
*/
|
||||||
|
function csrf_validate(string $token): bool {
|
||||||
|
return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialisiere Daten-Dateien falls nicht vorhanden
|
||||||
|
if (!file_exists(ALBUMS_FILE)) {
|
||||||
|
write_json(ALBUMS_FILE, ['albums' => []]);
|
||||||
|
}
|
||||||
|
if (!file_exists(COMMENTS_FILE)) {
|
||||||
|
write_json(COMMENTS_FILE, ['comments' => []]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Deny access to all files in this directory
|
||||||
|
<IfModule mod_authz_core.c>
|
||||||
|
Require all denied
|
||||||
|
</IfModule>
|
||||||
|
<IfModule !mod_authz_core.c>
|
||||||
|
Order deny,allow
|
||||||
|
Deny from all
|
||||||
|
</IfModule>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"albums": [
|
||||||
|
{
|
||||||
|
"id": "demo-001",
|
||||||
|
"title": "Weihnachten 2024",
|
||||||
|
"url": "https://nextcloud.example.com/apps/photos/public/demo",
|
||||||
|
"date": "2024-12-25",
|
||||||
|
"tags": ["weihnachten", "familie", "2024"],
|
||||||
|
"description": "Bescherung und Festessen bei der Familie",
|
||||||
|
"thumbnail": "",
|
||||||
|
"created_at": "2024-12-26T10:00:00+01:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"comments": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* FamilyAlbums - Öffentliche Ansicht
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
|
||||||
|
$pageTitle = SITE_TITLE;
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><?= e($pageTitle) ?></title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||||
|
<style>
|
||||||
|
.album-card:hover { transform: translateY(-4px); }
|
||||||
|
.tag { transition: all 0.2s; }
|
||||||
|
.tag:hover { transform: scale(1.05); }
|
||||||
|
.modal { transition: opacity 0.3s; }
|
||||||
|
.modal.hidden { opacity: 0; pointer-events: none; }
|
||||||
|
.gradient-placeholder {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 min-h-screen">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="bg-gradient-to-r from-blue-600 to-purple-600 text-white shadow-lg">
|
||||||
|
<div class="container mx-auto px-4 py-6">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<h1 class="text-2xl md:text-3xl font-bold">
|
||||||
|
<i class="fas fa-images mr-2"></i><?= e($pageTitle) ?>
|
||||||
|
</h1>
|
||||||
|
<a href="admin.php" class="text-white/80 hover:text-white text-sm">
|
||||||
|
<i class="fas fa-lock mr-1"></i>Admin
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Filter-Bereich -->
|
||||||
|
<div class="bg-white shadow-md sticky top-0 z-10">
|
||||||
|
<div class="container mx-auto px-4 py-4">
|
||||||
|
<div class="flex flex-col md:flex-row gap-4">
|
||||||
|
<!-- Suche -->
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="relative">
|
||||||
|
<input type="text" id="search" placeholder="Album suchen..."
|
||||||
|
class="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<i class="fas fa-search absolute left-3 top-3 text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Jahr -->
|
||||||
|
<select id="filter-year" class="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500">
|
||||||
|
<option value="">Alle Jahre</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Monat -->
|
||||||
|
<select id="filter-month" class="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500" disabled>
|
||||||
|
<option value="">Alle Monate</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Sortierung -->
|
||||||
|
<select id="sort" class="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500">
|
||||||
|
<option value="newest">Neueste zuerst</option>
|
||||||
|
<option value="oldest">Älteste zuerst</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Album-Grid -->
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<div id="albums-container" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
|
<!-- Alben werden per JS geladen -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="no-results" class="hidden text-center py-12 text-gray-500">
|
||||||
|
<i class="fas fa-search text-4xl mb-4"></i>
|
||||||
|
<p class="text-xl">Keine Alben gefunden</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="loading" class="text-center py-12">
|
||||||
|
<i class="fas fa-spinner fa-spin text-4xl text-blue-500"></i>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Album-Detail Modal -->
|
||||||
|
<div id="album-modal" class="modal hidden fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex justify-between items-start mb-4">
|
||||||
|
<h2 id="modal-title" class="text-2xl font-bold text-gray-800"></h2>
|
||||||
|
<button onclick="closeModal()" class="text-gray-400 hover:text-gray-600">
|
||||||
|
<i class="fas fa-times text-xl"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modal-thumbnail" class="mb-4 rounded-lg overflow-hidden"></div>
|
||||||
|
|
||||||
|
<p id="modal-date" class="text-gray-500 mb-2"></p>
|
||||||
|
<p id="modal-description" class="text-gray-700 mb-4"></p>
|
||||||
|
|
||||||
|
<div id="modal-tags" class="flex flex-wrap gap-2 mb-6"></div>
|
||||||
|
|
||||||
|
<a id="modal-link" href="#" target="_blank"
|
||||||
|
class="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition mb-6">
|
||||||
|
<i class="fas fa-external-link-alt mr-2"></i>Album öffnen
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Kommentare -->
|
||||||
|
<div class="border-t pt-6">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">
|
||||||
|
<i class="fas fa-comments mr-2"></i>Kommentare
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div id="comments-list" class="space-y-4 mb-6"></div>
|
||||||
|
|
||||||
|
<!-- Kommentar-Formular -->
|
||||||
|
<form id="comment-form" class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<input type="hidden" id="comment-album-id">
|
||||||
|
<!-- Honeypot -->
|
||||||
|
<input type="text" name="website" id="comment-website" class="hidden" tabindex="-1" autocomplete="off">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="text" id="comment-author" placeholder="Dein Name" required
|
||||||
|
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<textarea id="comment-text" placeholder="Dein Kommentar..." required rows="3"
|
||||||
|
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition">
|
||||||
|
<i class="fas fa-paper-plane mr-2"></i>Absenden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="bg-gray-800 text-white py-6 mt-12">
|
||||||
|
<div class="container mx-auto px-4 text-center">
|
||||||
|
<p>© <?= date('Y') ?> <?= e($pageTitle) ?></p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// === State ===
|
||||||
|
let allDates = {};
|
||||||
|
let currentAlbumId = null;
|
||||||
|
let debounceTimer = null;
|
||||||
|
|
||||||
|
// === Monatsnamen ===
|
||||||
|
const monthNames = {
|
||||||
|
'01': 'Januar', '02': 'Februar', '03': 'März', '04': 'April',
|
||||||
|
'05': 'Mai', '06': 'Juni', '07': 'Juli', '08': 'August',
|
||||||
|
'09': 'September', '10': 'Oktober', '11': 'November', '12': 'Dezember'
|
||||||
|
};
|
||||||
|
|
||||||
|
// === Helpers ===
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
const [year, month, day] = dateStr.split('-');
|
||||||
|
return `${parseInt(day)}. ${monthNames[month]} ${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(isoStr) {
|
||||||
|
const date = new Date(isoStr);
|
||||||
|
return date.toLocaleDateString('de-CH', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === API Calls ===
|
||||||
|
async function fetchAlbums() {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
const year = document.getElementById('filter-year').value;
|
||||||
|
const month = document.getElementById('filter-month').value;
|
||||||
|
const search = document.getElementById('search').value;
|
||||||
|
const sort = document.getElementById('sort').value;
|
||||||
|
|
||||||
|
if (year) params.append('year', year);
|
||||||
|
if (month) params.append('month', month);
|
||||||
|
if (search) params.append('search', search);
|
||||||
|
params.append('sort', sort);
|
||||||
|
|
||||||
|
const response = await fetch(`api.php?action=albums&${params}`);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDates() {
|
||||||
|
const response = await fetch('api.php?action=dates');
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchComments(albumId) {
|
||||||
|
const response = await fetch(`api.php?action=comments&album_id=${encodeURIComponent(albumId)}`);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postComment(albumId, author, text, website) {
|
||||||
|
const response = await fetch('api.php?action=comment', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ album_id: albumId, author, text, website })
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Rendering ===
|
||||||
|
function renderAlbums(albums) {
|
||||||
|
const container = document.getElementById('albums-container');
|
||||||
|
const noResults = document.getElementById('no-results');
|
||||||
|
const loading = document.getElementById('loading');
|
||||||
|
|
||||||
|
loading.classList.add('hidden');
|
||||||
|
|
||||||
|
if (albums.length === 0) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
noResults.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
noResults.classList.add('hidden');
|
||||||
|
|
||||||
|
container.innerHTML = albums.map(album => `
|
||||||
|
<div class="album-card bg-white rounded-xl shadow-md overflow-hidden cursor-pointer transition-all duration-300 hover:shadow-xl"
|
||||||
|
data-album='${JSON.stringify(album).replace(/'/g, "'")}'
|
||||||
|
onclick="openModalFromCard(this)">
|
||||||
|
<div class="aspect-video gradient-placeholder flex items-center justify-center">
|
||||||
|
${album.thumbnail
|
||||||
|
? `<img src="${escapeHtml(album.thumbnail)}" alt="${escapeHtml(album.title)}" class="w-full h-full object-cover" onerror="this.parentElement.innerHTML='<i class=\\'fas fa-images text-4xl text-white/50\\'></i>'">`
|
||||||
|
: `<i class="fas fa-images text-4xl text-white/50"></i>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<h3 class="font-semibold text-lg text-gray-800 mb-1 line-clamp-2">${escapeHtml(album.title)}</h3>
|
||||||
|
<p class="text-gray-500 text-sm mb-3">
|
||||||
|
<i class="fas fa-calendar mr-1"></i>${formatDate(album.date)}
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
${album.tags.slice(0, 3).map(tag => `
|
||||||
|
<span class="tag bg-blue-100 text-blue-700 text-xs px-2 py-1 rounded-full">${escapeHtml(tag)}</span>
|
||||||
|
`).join('')}
|
||||||
|
${album.tags.length > 3 ? `<span class="text-gray-400 text-xs">+${album.tags.length - 3}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDateFilters(dates) {
|
||||||
|
allDates = dates;
|
||||||
|
const yearSelect = document.getElementById('filter-year');
|
||||||
|
|
||||||
|
yearSelect.innerHTML = '<option value="">Alle Jahre</option>' +
|
||||||
|
Object.keys(dates).map(year => `<option value="${year}">${year}</option>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMonthFilter() {
|
||||||
|
const year = document.getElementById('filter-year').value;
|
||||||
|
const monthSelect = document.getElementById('filter-month');
|
||||||
|
|
||||||
|
if (!year || !allDates[year]) {
|
||||||
|
monthSelect.innerHTML = '<option value="">Alle Monate</option>';
|
||||||
|
monthSelect.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
monthSelect.disabled = false;
|
||||||
|
monthSelect.innerHTML = '<option value="">Alle Monate</option>' +
|
||||||
|
allDates[year].map(month => `<option value="${month}">${monthNames[month]}</option>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderComments(comments) {
|
||||||
|
const container = document.getElementById('comments-list');
|
||||||
|
|
||||||
|
if (comments.length === 0) {
|
||||||
|
container.innerHTML = '<p class="text-gray-500 text-center italic">Noch keine Kommentare. Sei der Erste!</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = comments.map(comment => `
|
||||||
|
<div class="bg-white p-3 rounded-lg border">
|
||||||
|
<div class="flex justify-between items-start mb-1">
|
||||||
|
<span class="font-semibold text-gray-800">${escapeHtml(comment.author)}</span>
|
||||||
|
<span class="text-gray-400 text-xs">${formatDateTime(comment.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-700">${escapeHtml(comment.text)}</p>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Modal ===
|
||||||
|
function openModalFromCard(element) {
|
||||||
|
const album = JSON.parse(element.dataset.album);
|
||||||
|
openModal(album.id, album);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal(id, album) {
|
||||||
|
currentAlbumId = id;
|
||||||
|
|
||||||
|
document.getElementById('modal-title').textContent = album.title;
|
||||||
|
document.getElementById('modal-date').innerHTML = `<i class="fas fa-calendar mr-1"></i>${formatDate(album.date)}`;
|
||||||
|
document.getElementById('modal-description').textContent = album.description || 'Keine Beschreibung';
|
||||||
|
document.getElementById('modal-link').href = album.url;
|
||||||
|
document.getElementById('comment-album-id').value = id;
|
||||||
|
|
||||||
|
// Thumbnail
|
||||||
|
const thumbnailContainer = document.getElementById('modal-thumbnail');
|
||||||
|
if (album.thumbnail) {
|
||||||
|
thumbnailContainer.innerHTML = `<img src="${escapeHtml(album.thumbnail)}" alt="${escapeHtml(album.title)}" class="w-full max-h-64 object-cover">`;
|
||||||
|
} else {
|
||||||
|
thumbnailContainer.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
document.getElementById('modal-tags').innerHTML = album.tags.map(tag =>
|
||||||
|
`<span class="bg-blue-100 text-blue-700 text-sm px-3 py-1 rounded-full">${escapeHtml(tag)}</span>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
// Modal anzeigen
|
||||||
|
document.getElementById('album-modal').classList.remove('hidden');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
// Kommentare laden
|
||||||
|
loadComments(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('album-modal').classList.add('hidden');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
currentAlbumId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadComments(albumId) {
|
||||||
|
document.getElementById('comments-list').innerHTML = '<p class="text-center"><i class="fas fa-spinner fa-spin"></i></p>';
|
||||||
|
const data = await fetchComments(albumId);
|
||||||
|
renderComments(data.comments || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Event Listeners ===
|
||||||
|
document.getElementById('search').addEventListener('input', () => {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(async () => {
|
||||||
|
const data = await fetchAlbums();
|
||||||
|
renderAlbums(data.albums || []);
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('filter-year').addEventListener('change', async () => {
|
||||||
|
updateMonthFilter();
|
||||||
|
document.getElementById('filter-month').value = '';
|
||||||
|
const data = await fetchAlbums();
|
||||||
|
renderAlbums(data.albums || []);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('filter-month').addEventListener('change', async () => {
|
||||||
|
const data = await fetchAlbums();
|
||||||
|
renderAlbums(data.albums || []);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('sort').addEventListener('change', async () => {
|
||||||
|
const data = await fetchAlbums();
|
||||||
|
renderAlbums(data.albums || []);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('comment-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const albumId = document.getElementById('comment-album-id').value;
|
||||||
|
const author = document.getElementById('comment-author').value.trim();
|
||||||
|
const text = document.getElementById('comment-text').value.trim();
|
||||||
|
const website = document.getElementById('comment-website').value;
|
||||||
|
|
||||||
|
if (!author || !text) return;
|
||||||
|
|
||||||
|
const btn = e.target.querySelector('button[type="submit"]');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Senden...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await postComment(albumId, author, text, website);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
alert(result.error);
|
||||||
|
} else {
|
||||||
|
document.getElementById('comment-text').value = '';
|
||||||
|
await loadComments(albumId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Fehler beim Senden des Kommentars');
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-paper-plane mr-2"></i>Absenden';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal schliessen bei Klick ausserhalb
|
||||||
|
document.getElementById('album-modal').addEventListener('click', (e) => {
|
||||||
|
if (e.target.id === 'album-modal') {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal schliessen mit Escape
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Init ===
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
const [albumsData, datesData] = await Promise.all([
|
||||||
|
fetchAlbums(),
|
||||||
|
fetchDates()
|
||||||
|
]);
|
||||||
|
|
||||||
|
renderAlbums(albumsData.albums || []);
|
||||||
|
renderDateFilters(datesData.dates || {});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fehler beim Laden:', err);
|
||||||
|
document.getElementById('loading').innerHTML =
|
||||||
|
'<p class="text-red-500"><i class="fas fa-exclamation-triangle mr-2"></i>Fehler beim Laden der Alben</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user