Initial public commit (clean history)

This commit is contained in:
2025-08-19 12:27:53 +02:00
commit 6629350c75
46 changed files with 10880 additions and 0 deletions

116
include/delete_unused.php Normal file
View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
/**
* delete_unused.php
*
* Löscht Bilddateien aus $imagedir, die in der DB nicht referenziert sind.
* Sicherheit:
* - CLI-only (kein Webzugriff)
* - Pfad-Härtung via realpath()
* - Dry-Run optional (default)
*
* Aufruf:
* php delete_unused.php # Dry-Run (zeigt nur an)
* php delete_unused.php --apply # wirklich löschen
*/
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit('Forbidden');
}
require_once __DIR__ . '/../config/config.php';
function out(string $msg): void
{
fwrite(STDOUT, $msg . PHP_EOL);
}
function err(string $msg): void
{
fwrite(STDERR, "ERROR: " . $msg . PHP_EOL);
exit(1);
}
// Flags
$apply = in_array('--apply', $argv, true);
// DB verbinden
$conn = @new mysqli($GLOBALS['servername'], $GLOBALS['username'], $GLOBALS['password'], $GLOBALS['db']);
if ($conn->connect_error)
err('DB-Verbindung fehlgeschlagen');
// Bildverzeichnis prüfen/härten
$baseDir = realpath(__DIR__ . '/..');
if ($baseDir === false)
err('BaseDir nicht gefunden');
$imgDirCfg = rtrim((string) $GLOBALS['imagedir'], '/');
$imgDir = realpath(__DIR__ . '/../' . $imgDirCfg);
if ($imgDir === false)
err('imagedir nicht gefunden: ' . $imgDirCfg);
// Verhindere, dass außerhalb des Projekts gelöscht wird
if (strpos($imgDir, $baseDir) !== 0)
err('imagedir liegt außerhalb des Projekts: ' . $imgDir);
// Aus DB: genutzte Dateien (NULL/"" filtern)
$used = [];
$sql = 'SELECT image FROM wishes WHERE image IS NOT NULL AND image <> ""';
$res = $conn->query($sql);
if ($res === false)
err('Query fehlgeschlagen');
while ($row = $res->fetch_assoc()) {
$used[] = (string) $row['image'];
}
$res->free();
$conn->close();
// In ein Set packen
$usedSet = array_flip($used);
$deleted = 0;
$kept = 0;
$skipped = 0;
$it = new DirectoryIterator($imgDir);
foreach ($it as $fileinfo) {
if ($fileinfo->isDot())
continue;
$path = $fileinfo->getPathname();
if ($fileinfo->isDir()) {
$skipped++;
out("[skip-dir] " . $path);
continue;
}
$name = $fileinfo->getFilename();
if (isset($usedSet[$name])) {
$kept++;
out("[keep] " . $name);
continue;
}
// Nicht referenziert -> löschen (oder dry-run)
if ($apply) {
if (@unlink($path)) {
$deleted++;
out("[delete] " . $name);
} else {
err('Konnte Datei nicht löschen: ' . $path);
}
} else {
out("[dry-run] " . $name . " (würde gelöscht)");
}
}
out("");
out("Summary:");
out(" kept: {$kept}");
out(" skipped: {$skipped}");
out(" deleted: {$deleted}" . ($apply ? "" : " (dry-run)"));
exit(0);

342
include/listgenerator.php Normal file
View File

@@ -0,0 +1,342 @@
<?php
declare(strict_types=1);
// Konfiguration einbinden (stellt $servername, $username, $password, $db, $imagedir bereit)
require_once __DIR__ . '/../config/config.php';
/**
* HTML-Escape Helper (fallback, falls global e() fehlt)
*/
if (!function_exists('e')) {
function e(string $s): string
{
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
}
/** Attribut-Helper (für data-*, value, title, …) */
if (!function_exists('attr')) {
function attr(string $s): string
{
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
}
/** Pfad zum Platzhalterbild ermitteln */
/** Pfad zum Platzhalterbild ermitteln */
if (!function_exists('lg_default_image')) {
function lg_default_image(): string
{
$dir = $_SERVER['DOCUMENT_ROOT'] . '/' . ($GLOBALS['placeholders_imagedir'] ?? 'img/placeholders');
// Alle PNG/JPG Dateien aus dem Ordner holen
$files = glob($dir . '/*.{png,jpg,jpeg,gif,webp}', GLOB_BRACE);
if ($files && count($files) > 0) {
// Einen zufälligen auswählen
$randomFile = $files[array_rand($files)];
// In relative URL umwandeln
$relativePath = str_replace($_SERVER['DOCUMENT_ROOT'], '', $randomFile);
return $relativePath;
}
// Fallback: statisches no-image
return '/img/no-image-katie-1.png';
}
}
/**
* DB-Connector (keine Fehlerdetails nach außen)
*/
function lg_db(): mysqli
{
global $servername, $username, $password, $db;
$conn = new mysqli($servername, $username, $password, $db);
if ($conn->connect_error) {
http_response_code(500);
exit('Interner Fehler (DB)');
}
$conn->set_charset('utf8mb4');
return $conn;
}
/**
* Einzelne Karten-Kachel rendern
*/
function generateListItem(
int $ListItemID,
?string $ItemImage,
string $ItemTitle,
?string $ItemLink,
int $ItemPriceCents,
?string $ItemComment,
int $ItemReserved,
?string $ItemDate,
int $ItemQty,
): void {
global $loggedin, $imagedir;
// Preis formatieren (de_DE)
$priceText = '';
if ($ItemPriceCents > 0) {
if (class_exists('NumberFormatter')) {
$fmt = new NumberFormatter('de_DE', NumberFormatter::CURRENCY);
$priceText = $fmt->formatCurrency($ItemPriceCents / 100, 'EUR');
} else {
$priceText = number_format($ItemPriceCents / 100, 2, ',', '.') . ' €';
}
}
// Kommentar (2 Zeilen max., CSS macht den Clamp)
$commentPlain = trim((string) $ItemComment);
$commentHtml = ($commentPlain === '') ? '&nbsp;' : e($commentPlain);
// Bild (lokal oder Placeholder)
$srcPath = '';
if (!empty($ItemImage)) {
$local = rtrim($imagedir, '/') . '/' . $ItemImage;
$srcPath = @is_file($local) ? $local : lg_default_image();
} else {
$srcPath = lg_default_image();
}
$imageTag = '<div class="ratio ratio-16x9"><img src="' . e($srcPath) . '" class="card-img-top object-fit-contain" alt="Bild zum Wunsch"></div>';
// Titelzeile: Qty-Pill (nur wenn >1) + einzeiliger Titel
$qtyPill = ($ItemQty > 1) ? '<span class="qty-pill me-2">×' . (int) $ItemQty . '</span>' : '';
$titleHtml = '<div class="d-flex align-items-center card-title-row">'
. $qtyPill
. '<h5 class="card-title flex-grow-1 mb-0">' . e($ItemTitle) . '</h5>'
. '</div>';
// Status-Badge links
$statusLeft = '';
if ($ItemReserved >= $ItemQty) {
$statusLeft = '<span class="badge bg-danger">alle reserviert</span>';
} elseif ($ItemReserved > 0) {
$statusLeft = '<span class="badge bg-warning text-dark">'
. (int) $ItemReserved . ' / ' . (int) $ItemQty . ' reserviert</span>';
}
// Datum (lokal lesbar)
$dateHtml = '';
if (!empty($ItemDate)) {
$ts = strtotime($ItemDate);
$dateHtml = $ts ? date('d.m.Y', $ts) : e($ItemDate);
}
// User-Buttons (Reserve / Cancel / Vendor)
// --- unten: Aktionsleiste (Icons, wachsen bei Hover) ---
// (Reserve nur wenn möglich; Cancel immer; Vendor nur wenn Link)
$userBtns = [];
if ($ItemReserved < $ItemQty) {
$userBtns[] = sprintf(
'<button class="btn btn-primary btn-pill justify-content-end"
aria-label="Reservieren"
data-bs-toggle="modal" data-bs-target="#reservationModal"
data-wishid="%d" data-reserved="0">
<i class="fa-solid fa-lock"></i>
<span>Reservieren</span>
</button>',
$ListItemID
);
}
if ($ItemReserved > 0) {
$userBtns[] = sprintf(
'<button class="btn btn-outline-primary btn-pill justify-content-center"
aria-label="Reservierung aufheben"
data-bs-toggle="modal" data-bs-target="#reservationModal"
data-wishid="%d" data-reserved="1">
<i class="fa-solid fa-unlock"></i>
<span>Reservierung aufheben</span>
</button>',
$ListItemID
);
}
if (!empty($ItemLink)) {
$safeLink = e($ItemLink);
$userBtns[] =
'<a class="btn btn-outline-dark btn-pill justify-content-start"
aria-label="Zum Anbieter"
href="' . $safeLink . '" target="_blank" rel="noopener">
<i class="fa-solid fa-up-right-from-square">
</i><span>Zum Anbieter</span>
</a>';
}
$userBtnRow = implode("\n", $userBtns);
// ... in deinem Echo-Heredoc:
// <div class="btn-group btn-group-pills mt-3" role="group" aria-label="Aktionen">
// {$userBtnRow}
// </div>
// Admin-Icons (nur eingeloggt)
$adminFloating = '';
if (!empty($loggedin)) {
$adminFloating = '
<div class="admin-actions btn-group btn-group-sm" role="group" aria-label="Admin-Action">
<button class="btn btn-outline-secondary btn-icon" title="nach oben"
data-bs-toggle="modal" data-bs-target="#pushprioModal" data-wishid="' . (int) $ListItemID . '">
<i class="fa-solid fa-arrow-up"></i>
</button>
<button class="btn btn-outline-warning btn-icon" title="edit"
data-bs-toggle="modal" data-bs-target="#itemModal"
data-mode="edit"
data-wishid="' . (int) $ListItemID . '"
data-title="' . attr($ItemTitle) . '"
data-description="' . attr($commentPlain) . '"
data-price="' . attr(($ItemPriceCents > 0) ? number_format($ItemPriceCents / 100, 2, ',', '.') : '') . '"
data-qty="' . (int) $ItemQty . '"
data-link="' . attr((string) $ItemLink) . '">
<i class="fa-solid fa-pen"></i>
</button>
<button class="btn btn-outline-danger btn-icon" title="löschen"
data-bs-toggle="modal" data-bs-target="#deleteModal" data-wishid="' . (int) $ListItemID . '">
<i class="fa-solid fa-trash"></i>
</button>
</div>';
}
// Preis-Badge rechts
$priceHtml = $priceText !== '' ? '<span class="badge text-bg-light fs-6 fw-semibold">' . e($priceText) . '</span>' : '';
echo <<<HTML
<div class="col">
<div class="card wl-card shadow-sm h-100 position-relative">
{$adminFloating}
{$imageTag}
<div class="card-body d-flex flex-column">
{$titleHtml}
<p class="card-text">{$commentHtml}</p>
<div class="d-flex justify-content-between align-items-center mt-1">
<div>{$statusLeft}</div>
<div class="status-right text-end">
<div>{$priceHtml}</div>
<div class="text-muted small">{$dateHtml}</div>
</div>
</div>
<div class="btn-group btn-group-pills mt-3" role="group" aria-label="Aktionen">
{$userBtnRow}
</div>
</div>
</div>
</div>
HTML;
}
/**
* Haupt-Builder für die Listenansicht
* $ListID: aktive Liste (int, intern)
* $sortby: einer aus der Whitelist unten
*/
function wishlistMainBuilder(int $ListID, string $sortby = 'priority'): void
{
global $loggedin;
$conn = lg_db();
// 1) Listen-Metadaten holen
$stmt = $conn->prepare('SELECT title, description FROM lists WHERE ID = ?');
$stmt->bind_param('i', $ListID);
$stmt->execute();
$res = $stmt->get_result();
$listTitle = 'Unbekannte Liste';
$listDesc = '';
if ($res && $row = $res->fetch_assoc()) {
$listTitle = e((string) $row['title']);
$listDesc = e((string) $row['description']);
}
$stmt->close();
// 2) ORDER BY Whitelist
$orderWhitelist = [
'priority' => 'priority DESC',
'price_asc' => 'price ASC',
'price_desc' => 'price DESC',
'date_desc' => 'date DESC',
'date_asc' => 'date ASC',
'random' => 'RAND()',
];
$orderSql = $orderWhitelist[$sortby] ?? $orderWhitelist['priority'];
// 3) Wünsche laden
$sql = "
SELECT
w.ID,
w.image,
w.title,
w.link,
w.price,
w.description,
w.date,
w.qty,
COALESCE(rc.reserved_count, 0) AS reserved_count
FROM wishes w
LEFT JOIN v_wish_reserved_counts rc
ON rc.wish_id = w.ID
WHERE w.wishlist = ?
ORDER BY {$orderSql}";
$stmt = $conn->prepare($sql);
$stmt->bind_param('i', $ListID);
$stmt->execute();
$items = $stmt->get_result();
// 4) Header-Text (vorher berechnen, kein Ternary im Heredoc)
$loginMsg = $loggedin
? 'Eingeloggt: Du kannst Einträge bearbeiten, löschen und priorisieren.'
: 'Tipp: Du kannst Einträge reservieren. Nur mit deinem Reservierungs-Passwort lässt sich die Reservierung wieder lösen.';
echo <<<HTML
<section class="py-5 text-center container">
<div class="row py-lg-4">
<div class="col-lg-8 col-md-10 mx-auto">
<h1 class="fw-light">{$listTitle}</h1>
<p class="lead text-muted">{$listDesc}</p>
<p class="text-muted">{$loginMsg}</p>
</div>
</div>
</section>
<div class="album py-3 bg-light">
<div class="container">
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 g-3">
HTML;
if ($items !== false && $items->num_rows > 0) {
while ($row = $items->fetch_assoc()) {
generateListItem(
(int) $row['ID'],
$row['image'] !== null ? (string) $row['image'] : null,
(string) $row['title'],
$row['link'] !== null ? (string) $row['link'] : null,
(int) $row['price'],
$row['description'] !== null ? (string) $row['description'] : null,
(int) $row['reserved_count'],
$row['date'] !== null ? (string) $row['date'] : null,
isset($row['qty']) ? (int) $row['qty'] : 1
);
}
} else {
echo '<div class="col"><div class="alert alert-info w-100">Keine Einträge vorhanden.</div></div>';
}
echo <<<HTML
</div>
</div>
</div>
HTML;
$stmt->close();
$conn->close();
}