Compare commits

...

2 Commits

Author SHA1 Message Date
16c4d58def small tweaks 2025-08-18 22:03:09 +02:00
fb182a1aec updated db schema 2025-08-18 21:05:59 +02:00
8 changed files with 269 additions and 136 deletions

View File

@@ -194,21 +194,32 @@ body {
white-space: nowrap; white-space: nowrap;
} }
.btn-pill i {
.btn-pill i { font-size: 1rem; line-height: 1; margin: 0 .5rem; } font-size: 1rem;
line-height: 1;
margin: 0 0.5rem;
}
.btn-pill span { .btn-pill span {
width: 0; opacity: 0; overflow: hidden; white-space: nowrap; margin-left: 0; width: 0;
transition: width .18s ease, opacity .18s ease, margin-left .18s ease; opacity: 0;
overflow: hidden;
white-space: nowrap;
margin-left: 0;
transition: width 0.18s ease, opacity 0.18s ease, margin-left 0.18s ease;
} }
/* Hover: Button wächst, Text klappt auf */ /* Hover: Button wächst, Text klappt auf */
.btn-pill:hover { .btn-pill:hover {
width: auto; width: auto;
flex: 0 1 16rem; flex: 0 1 16rem;
padding: .5rem .8rem; padding: 0.5rem 0.8rem;
/* Keine Ausrichtung hier setzen die Utility-Klasse bleibt wirksam */ /* Keine Ausrichtung hier setzen die Utility-Klasse bleibt wirksam */
} }
.btn-pill:hover span { width: auto; opacity: 1; margin-left: .35rem; } .btn-pill:hover span {
width: auto;
opacity: 1;
margin-left: 0.35rem;
}
/* Fokus sichtbar (zugänglich) */ /* Fokus sichtbar (zugänglich) */
.btn-pill:focus-visible { .btn-pill:focus-visible {
@@ -216,6 +227,14 @@ body {
outline-offset: 2px; outline-offset: 2px;
} }
/* ================= Footer ================== */
.icon-sm {
width: 24px; /* oder 24px, wenn du es etwas größer magst */
height: auto;
vertical-align: middle;
}
/* ============ Responsive Tweaks ============ */ /* ============ Responsive Tweaks ============ */
@media (max-width: 575.98px) { @media (max-width: 575.98px) {
.card-body { .card-body {

BIN
img/heart-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

BIN
img/paw-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
img/shakaru-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -270,12 +270,22 @@ function wishlistMainBuilder(int $ListID, string $sortby = 'priority'): void
// 3) Wünsche laden // 3) Wünsche laden
$sql = " $sql = "
SELECT w.ID, w.image, w.title, w.link, w.price, w.description, SELECT
w.date, w.qty, w.ID,
(SELECT COUNT(*) FROM wishes_reservations r WHERE r.wish_id = w.ID) AS reserved_count 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 FROM wishes w
LEFT JOIN v_wish_reserved_counts rc
ON rc.wish_id = w.ID
WHERE w.wishlist = ? WHERE w.wishlist = ?
ORDER BY {$orderSql}"; ORDER BY {$orderSql}";
$stmt = $conn->prepare($sql); $stmt = $conn->prepare($sql);
$stmt->bind_param('i', $ListID); $stmt->bind_param('i', $ListID);
$stmt->execute(); $stmt->execute();
@@ -311,7 +321,7 @@ HTML;
$row['link'] !== null ? (string) $row['link'] : null, $row['link'] !== null ? (string) $row['link'] : null,
(int) $row['price'], (int) $row['price'],
$row['description'] !== null ? (string) $row['description'] : null, $row['description'] !== null ? (string) $row['description'] : null,
(int) $row['reserved_count'], // echte Anzahl Reservierungen (int) $row['reserved_count'],
$row['date'] !== null ? (string) $row['date'] : null, $row['date'] !== null ? (string) $row['date'] : null,
isset($row['qty']) ? (int) $row['qty'] : 1 isset($row['qty']) ? (int) $row['qty'] : 1
); );

125
index.php
View File

@@ -77,13 +77,24 @@ function require_csrf(): void
} }
} }
ensure_csrf_token(); ensure_csrf_token();
// ===== URL-Param: Liste per UUID (oder Alt-ID -> Redirect) ===== // ===== URL-Param: Liste per UUID (oder Alt-ID -> Redirect) =====
$ListID = -1; $ListID = -1;
$ListUUID = ''; $ListUUID = '';
if (isset($_GET['list'])) { $showCreateEmptyState = false;
if (!isset($_GET['list'])) {
// Kein Parameter => nur Empty-State ohne Message
$showCreateEmptyState = true;
} else {
$raw = trim((string) $_GET['list']); $raw = trim((string) $_GET['list']);
if (preg_match('/^[0-9a-fA-F-]{32,36}$/', $raw)) {
if ($raw === '') {
// Leerer Parameter => nur Empty-State ohne Message
$showCreateEmptyState = true;
} elseif (preg_match('/^[0-9a-fA-F-]{32,36}$/', $raw)) {
// UUID übergeben
$c = db(); $c = db();
$s = $c->prepare('SELECT ID, uuid FROM lists WHERE uuid=?'); $s = $c->prepare('SELECT ID, uuid FROM lists WHERE uuid=?');
$s->bind_param('s', $raw); $s->bind_param('s', $raw);
@@ -92,27 +103,40 @@ if (isset($_GET['list'])) {
if ($r && ($row = $r->fetch_assoc())) { if ($r && ($row = $r->fetch_assoc())) {
$ListID = (int) $row['ID']; $ListID = (int) $row['ID'];
$ListUUID = (string) $row['uuid']; $ListUUID = (string) $row['uuid'];
} else {
// UUID-Format ok, aber nicht vorhanden => Message + Empty-State
$message = ['msg' => 'Diese Liste gibt es nicht. Lege eine neue an oder prüfe den Link.', 'type' => 'warning'];
$showCreateEmptyState = true;
} }
$s->close(); $s->close();
$c->close(); $c->close();
} else {
$ListID = (int) $raw; } elseif (preg_match('/^\d+$/', $raw)) {
if ($ListID >= 0) { // Numerische ID übergeben -> auf UUID umleiten, wenn vorhanden
$id = (int) $raw;
$c = db(); $c = db();
$s = $c->prepare('SELECT uuid FROM lists WHERE ID=?'); $s = $c->prepare('SELECT uuid FROM lists WHERE ID=?');
$s->bind_param('i', $ListID); $s->bind_param('i', $id);
$s->execute(); $s->execute();
$r = $s->get_result(); $r = $s->get_result();
if ($r && ($row = $r->fetch_assoc())) { if ($r && ($row = $r->fetch_assoc())) {
$ListUUID = (string) $row['uuid']; $uuid = (string) $row['uuid'];
$scheme = $secure ? 'https' : 'http'; $scheme = $secure ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? ''; $host = $_SERVER['HTTP_HOST'] ?? '';
header('Location: ' . $scheme . '://' . $host . '/?list=' . urlencode($ListUUID), true, 301); header('Location: ' . $scheme . '://' . $host . '/?list=' . urlencode($uuid), true, 301);
exit; exit;
} else {
// ID-Format ok, aber nicht vorhanden => Message + Empty-State
$message = ['msg' => 'Diese Liste gibt es nicht. Lege eine neue an oder prüfe den Link.', 'type' => 'warning'];
$showCreateEmptyState = true;
} }
$s->close(); $s->close();
$c->close(); $c->close();
}
} else {
// Weder gültige UUID noch Zahl => Message + Empty-State
$message = ['msg' => 'Diese Liste gibt es nicht. Lege eine neue an oder prüfe den Link.', 'type' => 'warning'];
$showCreateEmptyState = true;
} }
} }
@@ -252,7 +276,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="css/bootstrap.min.css"> <link rel="stylesheet" href="css/bootstrap.min.css">
<link rel="stylesheet" href="css/tweaks.css"> <link rel="stylesheet" href="css/tweaks.css">
<link rel="stylesheet" href="css/fontawesome.min.css"/> <link rel="stylesheet" href="css/fontawesome.min.css" />
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="img/favicon-32x32.png"> <link rel="icon" type="image/png" sizes="32x32" href="img/favicon-32x32.png">
@@ -277,6 +301,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
</a> </a>
<div class="nav navbar-nav navbar-right"> <div class="nav navbar-nav navbar-right">
<div class="d-grid gap-2 d-flex"> <div class="d-grid gap-2 d-flex">
<button type="button" class="btn btn-outline-success my-2 my-sm-0" data-bs-toggle="modal"
data-bs-target="#createListModal">Neue Liste</button>
<?php if ($showCreateEmptyState == false): ?>
<?php if ($loggedin): ?> <?php if ($loggedin): ?>
<button type="button" class="btn btn-outline-secondary my-2 my-sm-0" data-bs-toggle="modal" <button type="button" class="btn btn-outline-secondary my-2 my-sm-0" data-bs-toggle="modal"
data-bs-target="#itemModal" data-mode="add" data-listuuid="<?= e($ListUUID) ?>" data-bs-target="#itemModal" data-mode="add" data-listuuid="<?= e($ListUUID) ?>"
@@ -300,6 +327,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
<option <?= $sortby === 'random' ? 'selected' : '' ?> value="random">Zufall</option> <option <?= $sortby === 'random' ? 'selected' : '' ?> value="random">Zufall</option>
</select> </select>
</form> </form>
<?php endif; ?>
</div> </div>
</div> </div>
</div> </div>
@@ -314,13 +342,46 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($showCreateEmptyState)): ?>
<!-- Empty State -->
<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">Willkommen bei Simple Wishlist</h1>
<p class="lead text-muted">
Es wurde keine gültige Liste ausgewählt. Du kannst jetzt eine neue Liste anlegen.
</p>
<p>
<button class="btn btn-success btn-lg" data-bs-toggle="modal" data-bs-target="#createListModal">
Neue Liste anlegen
</button>
</p>
</div>
</div>
</section>
<?php else: ?>
<?php wishlistMainBuilder($ListID, $sortby); ?> <?php wishlistMainBuilder($ListID, $sortby); ?>
<?php endif; ?>
</main> </main>
<footer class="text-muted py-5"> <footer class="mt-5 text-center text-muted small">
<div class="container"> <div class="container">
<p class="float-end mb-1"><a href="#">Back to top</a></p> <hr class="mb-3 opacity-25">
<p class="mb-1">Simple Wishlist &copy; by Marcel Peterkau</p> <p class="mb-1">
Made with
<img src="img/heart-icon.png" alt="Shakaru" class="icon-sm mx-1">
and
<img src="img/paw-icon.png" alt="Paw" class="icon-sm mx-1">
by
<strong>
<img src="img/shakaru-icon.png" alt="Shakaru" class="icon-sm mx-1">
Shakaru
</strong>
</p>
<p class="mb-0">
&copy; <?= date('Y') ?> Simple Wishlist · Marcel Peterkau
· <a href="#" class="text-decoration-none">Back to top ↑</a>
</p>
</div> </div>
</footer> </footer>
@@ -478,6 +539,42 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
</div> </div>
</div> </div>
<!-- Create List Modal -->
<div class="modal fade" id="createListModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Neue Liste anlegen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Schließen"></button>
</div>
<form action="" method="POST">
<div class="modal-body">
<div class="mb-3">
<label for="listName" class="form-label">Titel der Liste</label>
<input type="text" class="form-control" id="listName" name="listName" required maxlength="255">
</div>
<div class="mb-3">
<label for="listDescription" class="form-label">Beschreibung (optional)</label>
<textarea class="form-control" id="listDescription" name="listDescription" rows="3"></textarea>
</div>
<div class="mb-3">
<label for="listPassword" class="form-label">Admin-Passwort (zum Bearbeiten)</label>
<input type="password" class="form-control" id="listPassword" name="listPassword" required>
<div class="form-text">
Dieses Passwort brauchst du später für Login/Bearbeitung der Liste.
</div>
</div>
</div>
<div class="modal-footer">
<input type="hidden" name="csrf" value="<?= e($_SESSION['csrf']) ?>">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" class="btn btn-primary" name="listadd">Anlegen</button>
</div>
</form>
</div>
</div>
</div>
<script src="js/bootstrap.bundle.min.js"></script> <script src="js/bootstrap.bundle.min.js"></script>
<script src="js/jquery.min.js"></script> <script src="js/jquery.min.js"></script>
<script src="js/wishlist.js"></script> <script src="js/wishlist.js"></script>

View File

@@ -1,91 +1,98 @@
-- phpMyAdmin SQL Dump -- ============================================================
-- version 5.2.0 -- Simple Wishlist Minimal-Schema
-- https://www.phpmyadmin.net/ -- Tested on MariaDB 10.x / MySQL 8.x
-- -- ============================================================
-- Host: localhost
-- Erstellungszeit: 04. Okt 2022 um 22:36
-- Server-Version: 10.5.15-MariaDB-0+deb11u1
-- PHP-Version: 8.0.22
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"; /* Optional: eigene DB anlegen/verwenden
START TRANSACTION; CREATE DATABASE IF NOT EXISTS `wishlist`
SET time_zone = "+00:00"; DEFAULT CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE `wishlist`;
*/
-- ------------------------------------------------------------
-- Tabellen löschen (idempotent für wiederholte Importe)
-- ------------------------------------------------------------
DROP TABLE IF EXISTS `wishes_reservations`;
DROP TABLE IF EXISTS `wishes`;
DROP TABLE IF EXISTS `lists`;
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; -- ------------------------------------------------------------
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; -- LISTEN (Metadaten & Admin-Passwort)
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; -- ------------------------------------------------------------
/*!40101 SET NAMES utf8mb4 */;
--
-- Datenbank: `wishlist`
--
-- --------------------------------------------------------
--
-- Tabellenstruktur für Tabelle `lists`
--
CREATE TABLE `lists` ( CREATE TABLE `lists` (
`ID` int(11) NOT NULL, `ID` INT(11) NOT NULL AUTO_INCREMENT,
`title` varchar(255) NOT NULL, `uuid` CHAR(36) NOT NULL, -- öffentliche, stabile ID (Links)
`description` text NOT NULL, `title` VARCHAR(255) NOT NULL,
`edit_pw` varchar(64) NOT NULL `description` TEXT NOT NULL,
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; `edit_pw` VARCHAR(255) NOT NULL, -- Passwort-Hash (password_hash)
PRIMARY KEY (`ID`),
-- -------------------------------------------------------- UNIQUE KEY `uq_lists_uuid` (`uuid`)
) ENGINE=InnoDB
-- DEFAULT CHARSET = utf8mb4
-- Tabellenstruktur für Tabelle `wishes` COLLATE = utf8mb4_unicode_ci;
--
-- ------------------------------------------------------------
-- WÜNSCHE (einer Liste zugeordnet)
-- ------------------------------------------------------------
CREATE TABLE `wishes` ( CREATE TABLE `wishes` (
`ID` int(11) NOT NULL, `ID` INT(11) NOT NULL AUTO_INCREMENT,
`wishlist` int(11) NOT NULL DEFAULT 0, `wishlist` INT(11) NOT NULL, -- FK -> lists.ID
`title` varchar(128) NOT NULL, `title` VARCHAR(128) NOT NULL,
`description` text NOT NULL, `description` TEXT NOT NULL,
`link` text NOT NULL, `link` TEXT NOT NULL, -- optionaler Anbieter-Link (leer erlaubt)
`image` text NOT NULL, `image` TEXT DEFAULT NULL, -- Dateiname (lokal) oder NULL
`price` int(11) NOT NULL, `price` INT(11) NOT NULL DEFAULT 0, -- Preis in Cent
`reserved` tinyint(1) NOT NULL DEFAULT 0, `date` DATE NOT NULL DEFAULT (CURRENT_DATE),
`pass_hash` varchar(64) NOT NULL DEFAULT '', `priority` INT(11) NOT NULL DEFAULT 0, -- Sortierung (höher = weiter oben)
`date` date NOT NULL DEFAULT current_timestamp() `qty` INT(11) NOT NULL DEFAULT 1, -- Anzahl benötigter Exemplare
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- PRIMARY KEY (`ID`),
-- Indizes der exportierten Tabellen KEY `idx_wishes_wishlist` (`wishlist`),
-- CONSTRAINT `fk_wishes_list`
FOREIGN KEY (`wishlist`) REFERENCES `lists` (`ID`)
ON DELETE CASCADE
) ENGINE=InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_unicode_ci;
-- -- ------------------------------------------------------------
-- Indizes für die Tabelle `lists` -- RESERVIERUNGEN (pro Wunsch mehrere Einträge, je Reservierung einer)
-- -- ------------------------------------------------------------
ALTER TABLE `lists` CREATE TABLE `wishes_reservations` (
ADD PRIMARY KEY (`ID`); `id` INT(11) NOT NULL AUTO_INCREMENT,
`wish_id` INT(11) NOT NULL, -- FK -> wishes.ID
`pass_hash` VARCHAR(255) NOT NULL, -- Passwort-Hash (password_hash)
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_wr_wish` (`wish_id`),
CONSTRAINT `fk_wr_wish`
FOREIGN KEY (`wish_id`) REFERENCES `wishes` (`ID`)
ON DELETE CASCADE
) ENGINE=InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_unicode_ci;
-- -- ------------------------------------------------------------
-- Indizes für die Tabelle `wishes` -- OPTIONALE HILFS-VIEW: reserved_count je Wunsch
-- -- Erleichtert das Rendern (Badge/Buttons) ohne Subquery im Code.
ALTER TABLE `wishes` -- Nutzung: SELECT w.*, rc.reserved_count FROM wishes w
ADD PRIMARY KEY (`ID`); -- LEFT JOIN v_wish_reserved_counts rc ON rc.wish_id = w.ID
-- ------------------------------------------------------------
CREATE OR REPLACE VIEW `v_wish_reserved_counts` AS
SELECT
wr.wish_id,
COUNT(*) AS reserved_count
FROM wishes_reservations wr
GROUP BY wr.wish_id;
-- -- ------------------------------------------------------------
-- AUTO_INCREMENT für exportierte Tabellen -- EMPFOHLENE INDIZES (optional je nach Abfragen)
-- -- ------------------------------------------------------------
-- Wenn häufig nach (wishlist, priority DESC) sortiert wird:
CREATE INDEX `idx_wishes_wishlist_priority` ON `wishes` (`wishlist`, `priority`);
-- -- Wenn oft nach Datum sortiert/gefiltert wird:
-- AUTO_INCREMENT für Tabelle `lists` CREATE INDEX `idx_wishes_wishlist_date` ON `wishes` (`wishlist`, `date`);
--
ALTER TABLE `lists`
MODIFY `ID` int(11) NOT NULL AUTO_INCREMENT;
-- -- Fertig.
-- AUTO_INCREMENT für Tabelle `wishes`
--
ALTER TABLE `wishes`
MODIFY `ID` int(11) NOT NULL AUTO_INCREMENT;
COMMIT;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;