Initial public commit (clean history)
							
								
								
									
										7
									
								
								.env
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,7 @@ | |||||||
|  | # Kopieren zu .env und anpassen | ||||||
|  | SFTP_HOST=www374.your-server.de | ||||||
|  | SFTP_USER=peterksd | ||||||
|  | SSH_KEY=/home/souko/.ssh/id_ed25519 | ||||||
|  | TARGET_DIR=/public_html/wishlist.hiabuto.de | ||||||
|  | SOURCE_DIR=./ | ||||||
|  | DRY_RUN=1 | ||||||
							
								
								
									
										7
									
								
								.env.example
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,7 @@ | |||||||
|  | # Kopieren zu .env und anpassen | ||||||
|  | SFTP_HOST=change.me | ||||||
|  | SFTP_USER=change_me | ||||||
|  | SSH_KEY=/home/you/.ssh/id_ed25519 | ||||||
|  | TARGET_DIR=/public_html/wishlist.tld | ||||||
|  | SOURCE_DIR=./ | ||||||
|  | DRY_RUN=1 | ||||||
							
								
								
									
										13
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,13 @@ | |||||||
|  | # Wishlist secrets & local artifacts | ||||||
|  | config/config.php | ||||||
|  | data/ | ||||||
|  | *.log | ||||||
|  | *.tmp | ||||||
|  | .cache/ | ||||||
|  | .env | ||||||
|  | *.pem | ||||||
|  | *.key | ||||||
|  | id_* | ||||||
|  | .ssh/ | ||||||
|  | data/ | ||||||
|  | node_modules/ | ||||||
							
								
								
									
										45
									
								
								config/config.sample.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,45 @@ | |||||||
|  | <?php | ||||||
|  | /** | ||||||
|  |  * Sample-Konfiguration für Simple Wishlist | ||||||
|  |  * Kopiere diese Datei nach config.php und trage echte Werte ein. | ||||||
|  |  * | ||||||
|  |  * Sicherheitshinweise: | ||||||
|  |  * - Lege die echte config.php NICHT ins Git-Repo (siehe .gitignore). | ||||||
|  |  * - Setze Dateirechte restriktiv (z. B. 640) und Eigentümer auf den Webserver-User. | ||||||
|  |  * - $image_host_whitelist: Wenn gesetzt, dürfen Bilder NUR von diesen Hosts gepullt werden. | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | $servername = 'localhost'; | ||||||
|  | $username   = 'wishlist'; | ||||||
|  | $password   = 'yourcooldbpasshere'; | ||||||
|  | $db         = 'wishlist'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Pfad zum Bilder-Verzeichnis relativ zum Webroot oder absolut. | ||||||
|  |  * Standard (relativ): 'data/images' | ||||||
|  |  * Beispiel (absolut): '/var/www/wishlist/data/images' | ||||||
|  |  */ | ||||||
|  | $imagedir = 'data/images'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Pfad zum Bilder-Verzeichnis für Platzhalter-Bilder zum Webroot oder absolut. | ||||||
|  |  * Standard (relativ): 'img/placeholders' | ||||||
|  |  * Beispiel (absolut): '/var/www/wishlist/img/placeholders' | ||||||
|  |  */ | ||||||
|  | $placeholders_imagedir = 'img/placeholders'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * (Optional) Whitelist für Bild-Downloads (SSRF-Schutz). | ||||||
|  |  * - Leere Liste => kein Host explizit erlaubt, aber add_item.php blockt private/Loopback/Link-Local IPs. | ||||||
|  |  * - Wenn du hier Einträge setzt, dürfen NUR diese Hosts (und deren Subdomains) genutzt werden. | ||||||
|  |  *   Beispiel: ['i.imgur.com', 'images.example.com'] | ||||||
|  |  */ | ||||||
|  | $image_host_whitelist = []; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * (Optional) Produktions-Flags – falls du sie in Templates brauchst. | ||||||
|  |  * Die App selbst setzt error_reporting in index/add_item; hier nur als Doku. | ||||||
|  |  */ | ||||||
|  | // $app_env = 'production'; // 'development' | 'production' | ||||||
|  |  | ||||||
|  | ?> | ||||||
							
								
								
									
										7
									
								
								css/bootstrap.min.css
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										1
									
								
								css/bootstrap.min.css.map
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										7
									
								
								css/bootstrap.rtl.min.css
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										1
									
								
								css/bootstrap.rtl.min.css.map
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										8441
									
								
								css/custom.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										9
									
								
								css/fontawesome.min.css
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										250
									
								
								css/tweaks.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,250 @@ | |||||||
|  | /* ============ Base / Vars ============ */ | ||||||
|  | @font-face { | ||||||
|  |   font-family: "Comfortaa"; | ||||||
|  |   font-style: normal; | ||||||
|  |   font-weight: 300; | ||||||
|  |   src: url(../webfonts/comfortaa.woff2) format("woff2"); | ||||||
|  |   unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, | ||||||
|  |     U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, | ||||||
|  |     U+FEFF, U+FFFD; | ||||||
|  | } | ||||||
|  | :root { | ||||||
|  |   --wl-card-radius: 0.75rem; | ||||||
|  | } | ||||||
|  | body { | ||||||
|  |   font-family: system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", | ||||||
|  |     Arial, "Comfortaa", sans-serif; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Utility */ | ||||||
|  | .object-fit-cover { | ||||||
|  |   object-fit: cover; | ||||||
|  | } | ||||||
|  | .object-fit-contain { | ||||||
|  |   object-fit: contain; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* ============ Cards ============ */ | ||||||
|  | .card { | ||||||
|  |   border-radius: var(--wl-card-radius); | ||||||
|  |   transition: box-shadow 0.2s ease, transform 0.2s ease; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .card .ratio { | ||||||
|  |   --bs-aspect-ratio: 56.25%; /* 16:9 Bildfläche */ | ||||||
|  |   border-top-left-radius: var(--wl-card-radius); | ||||||
|  |   border-top-right-radius: var(--wl-card-radius); | ||||||
|  |   background: #fff; | ||||||
|  | } | ||||||
|  | .card-img-top { | ||||||
|  |   width: 100%; | ||||||
|  |   height: 100%; | ||||||
|  |   padding: 0; | ||||||
|  |   display: block; | ||||||
|  | } | ||||||
|  | .card-body { | ||||||
|  |   display: flex; | ||||||
|  |   flex-direction: column; | ||||||
|  |   gap: 0.35rem; | ||||||
|  |   min-height: 9rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Titelzeile */ | ||||||
|  | .card-title-row { | ||||||
|  |   gap: 0.5rem; | ||||||
|  | } | ||||||
|  | .card-title-row .card-title, | ||||||
|  | .card-title { | ||||||
|  |   margin: 0 0 0.25rem 0; | ||||||
|  |   font-weight: 700; | ||||||
|  |   line-height: 1.15; | ||||||
|  |   white-space: nowrap; | ||||||
|  |   overflow: hidden; | ||||||
|  |   text-overflow: ellipsis; | ||||||
|  | } | ||||||
|  | .qty-pill { | ||||||
|  |   display: inline-block; | ||||||
|  |   padding: 0.15rem 0.5rem; | ||||||
|  |   font-size: 0.8rem; | ||||||
|  |   line-height: 1; | ||||||
|  |   border-radius: 999px; | ||||||
|  |   background: var(--bs-gray-200); | ||||||
|  |   color: var(--bs-gray-800); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Beschreibung (2 Zeilen Ellipsis) */ | ||||||
|  | .card-text { | ||||||
|  |   color: #333; | ||||||
|  |   display: -webkit-box; | ||||||
|  |   -webkit-line-clamp: 2; | ||||||
|  |   -webkit-box-orient: vertical; | ||||||
|  |   overflow: hidden; | ||||||
|  |   min-height: 2.6em; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Status rechts */ | ||||||
|  | .status-right .fs-6 { | ||||||
|  |   line-height: 1.2; | ||||||
|  | } | ||||||
|  | .card .text-end small, | ||||||
|  | .card .text-end .badge { | ||||||
|  |   white-space: nowrap; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Header Controls */ | ||||||
|  | .navbar-brand svg { | ||||||
|  |   opacity: 0.9; | ||||||
|  | } | ||||||
|  | .navbar .form-control#sortby { | ||||||
|  |   min-width: 10.5rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Demo-/Bootstrap-Beispielkram killen */ | ||||||
|  | .bd-placeholder-img, | ||||||
|  | .bd-placeholder-img-lg, | ||||||
|  | .b-example-divider, | ||||||
|  | .b-example-vr, | ||||||
|  | .nav-scroller, | ||||||
|  | .nav-scroller .nav { | ||||||
|  |   display: none !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* ============ Admin-Icons (schwebend) ============ */ | ||||||
|  | /* schwebende Admin-Icons sichtbar machen */ | ||||||
|  | .wl-card { | ||||||
|  |   position: relative; | ||||||
|  | } /* hast du schon */ | ||||||
|  | .wl-card .admin-actions { | ||||||
|  |   position: absolute; | ||||||
|  |   top: 0.5rem; | ||||||
|  |   right: 0.5rem; | ||||||
|  |   background: rgba(255, 255, 255, 0.85); | ||||||
|  |   backdrop-filter: blur(2px); | ||||||
|  |   border-radius: 999px; | ||||||
|  |   padding: 0.125rem; | ||||||
|  |   z-index: 10; | ||||||
|  |   opacity: 0; | ||||||
|  |   transform: translateY(-4px); | ||||||
|  |   transition: opacity 0.18s ease, transform 0.18s ease; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* beim Hovern (oder Tastaturfokus) einblenden */ | ||||||
|  | .wl-card:hover .admin-actions { | ||||||
|  |   opacity: 1; | ||||||
|  |   transform: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* --- Admin-Icon oben rechts bleibt kompakt --- */ | ||||||
|  | .wl-card .btn-icon { | ||||||
|  |   box-sizing: border-box; | ||||||
|  |   width: 34px; | ||||||
|  |   height: 34px; | ||||||
|  |   padding: 0; | ||||||
|  |   display: inline-flex; | ||||||
|  |   align-items: center; | ||||||
|  |   justify-content: center; | ||||||
|  |   border-radius: 999px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* ============ Untere Aktionsleiste ============ */ | ||||||
|  | .btn-group-pills { | ||||||
|  |   display: flex; | ||||||
|  |   justify-content: center; | ||||||
|  |   gap: 0.45rem; | ||||||
|  |   flex-wrap: wrap; | ||||||
|  |   align-items: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Idle: klein und fix 40px */ | ||||||
|  | .btn-group-pills > .btn.btn-pill { | ||||||
|  |   flex: 0 0 40px !important; | ||||||
|  |   min-width: 40px; /* falls Bootstrap-Mindestbreiten reinfunken */ | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Hover/Fokus/Aktiv: darf wachsen */ | ||||||
|  | .btn-group-pills > .btn.btn-pill:hover, | ||||||
|  | .btn-group-pills > .btn.btn-pill:focus, | ||||||
|  | .btn-group-pills > .btn.btn-pill:active { | ||||||
|  |   flex: 0 1 16rem !important; /* überschreibt die Idle-Regel */ | ||||||
|  |   width: auto !important; /* falls irgendwo width:40px gewinnt */ | ||||||
|  |   padding: 0.5rem 0.8rem; /* „aufklappen“ */ | ||||||
|  |   justify-content: flex-start; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Text mit aufklappen */ | ||||||
|  | .btn-pill:hover span, | ||||||
|  | .btn-pill:focus span, | ||||||
|  | .btn-pill:active span { | ||||||
|  |   width: auto; | ||||||
|  |   opacity: 1; | ||||||
|  |   margin-left: 0.35rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* kompakter Grundzustand: NUR Icon, super schmal */ | ||||||
|  | .btn-pill { | ||||||
|  |   box-sizing: border-box; /* Breite inkl. Padding & Border */ | ||||||
|  |   display: inline-flex; | ||||||
|  |   align-items: center; | ||||||
|  |   width: 40px; | ||||||
|  |   height: 40px; | ||||||
|  |   flex-basis: 40px; | ||||||
|  |   padding: 0; | ||||||
|  |   border-radius: 999px; | ||||||
|  |   overflow: hidden; | ||||||
|  |   white-space: nowrap; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .btn-pill i { | ||||||
|  |   font-size: 1rem; | ||||||
|  |   line-height: 1; | ||||||
|  |   margin: 0 0.5rem; | ||||||
|  | } | ||||||
|  | .btn-pill span { | ||||||
|  |   width: 0; | ||||||
|  |   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 */ | ||||||
|  | .btn-pill:hover { | ||||||
|  |   width: auto; | ||||||
|  |   flex: 0 1 16rem; | ||||||
|  |   padding: 0.5rem 0.8rem; | ||||||
|  |   /* Keine Ausrichtung hier setzen – die Utility-Klasse bleibt wirksam */ | ||||||
|  | } | ||||||
|  | .btn-pill:hover span { | ||||||
|  |   width: auto; | ||||||
|  |   opacity: 1; | ||||||
|  |   margin-left: 0.35rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Fokus sichtbar (zugänglich) */ | ||||||
|  | .btn-pill:focus-visible { | ||||||
|  |   outline: 2px solid rgba(13, 110, 253, 0.5); | ||||||
|  |   outline-offset: 2px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* ================= Footer ================== */ | ||||||
|  |  | ||||||
|  | .icon-sm { | ||||||
|  |   width: 24px; /* oder 24px, wenn du es etwas größer magst */ | ||||||
|  |   height: auto; | ||||||
|  |   vertical-align: middle; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* ============ Responsive Tweaks ============ */ | ||||||
|  | @media (max-width: 575.98px) { | ||||||
|  |   .card-body { | ||||||
|  |     min-height: unset; | ||||||
|  |   } | ||||||
|  |   .btn-group-pills { | ||||||
|  |     gap: 0.35rem; | ||||||
|  |   } | ||||||
|  |   .btn-pill { | ||||||
|  |     width: 38px; | ||||||
|  |     flex-basis: 38px; | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										153
									
								
								deploy.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						| @@ -0,0 +1,153 @@ | |||||||
|  | #!/usr/bin/env bash | ||||||
|  | set -euo pipefail | ||||||
|  |  | ||||||
|  | # ========================= | ||||||
|  | #  Wishlist Deploy (SFTP) | ||||||
|  | #  - Git-safe Defaults | ||||||
|  | #  - .env Support | ||||||
|  | # ========================= | ||||||
|  |  | ||||||
|  | RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' | ||||||
|  |  | ||||||
|  | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||||||
|  |  | ||||||
|  | # --- Optional: .env im Skriptordner laden (wenn vorhanden) --- | ||||||
|  | if [[ -f "${SCRIPT_DIR}/.env" ]]; then | ||||||
|  |   # Nur schlichte KEY=VALUE Zeilen ohne Export/Spaces | ||||||
|  |   # shellcheck disable=SC2046 | ||||||
|  |   set -a | ||||||
|  |   source "${SCRIPT_DIR}/.env" | ||||||
|  |   set +a | ||||||
|  | fi | ||||||
|  |  | ||||||
|  | # --- Pfade/Quellen --- | ||||||
|  | SOURCE_DIR="${SOURCE_DIR:-"$SCRIPT_DIR/"}" | ||||||
|  | TARGET_DIR="${TARGET_DIR:-"/public_html/CHANGE_ME_path"}" | ||||||
|  |  | ||||||
|  | # --- Verbindungsdaten: absichtlich ungültige Defaults (müssen überschrieben werden) --- | ||||||
|  | SFTP_HOST="${SFTP_HOST:-CHANGE_ME_HOST}" | ||||||
|  | SFTP_USER="${SFTP_USER:-CHANGE_ME_USER}" | ||||||
|  | SSH_KEY="${SSH_KEY:-$HOME/.ssh/CHANGE_ME_key}" | ||||||
|  |  | ||||||
|  | # Flags | ||||||
|  | DRY_RUN="${DRY_RUN:-0}" | ||||||
|  | DEBUG="${DEBUG:-0}" | ||||||
|  |  | ||||||
|  | # Glob-Excludes (nur Globs, lftp-kompatibel) | ||||||
|  | EXCLUDE_ARGS=( | ||||||
|  |   --exclude-glob ".git" | ||||||
|  |   --exclude-glob ".git/*" | ||||||
|  |   --exclude-glob "*/.git" | ||||||
|  |   --exclude-glob "*/.git/*" | ||||||
|  |   --exclude-glob "**/.git" | ||||||
|  |   --exclude-glob "**/.git/*" | ||||||
|  |   --exclude-glob ".git*" | ||||||
|  |   --exclude-glob "*/.git*" | ||||||
|  |   --exclude-glob "**/.git*" | ||||||
|  |   --exclude-glob ".gitattributes" | ||||||
|  |   --exclude-glob ".gitignore" | ||||||
|  |   --exclude-glob ".github*" | ||||||
|  |  | ||||||
|  |   --exclude-glob ".env*" | ||||||
|  |   --exclude-glob "deploy*.sh" | ||||||
|  |   --exclude-glob "README*" | ||||||
|  |   --exclude-glob "*.md" | ||||||
|  |   --exclude-glob "*.sql" | ||||||
|  |  | ||||||
|  |   --exclude-glob "node_modules" | ||||||
|  |   --exclude-glob "node_modules/**" | ||||||
|  |   --exclude-glob "vendor/*/.git*" | ||||||
|  |  | ||||||
|  |   --exclude-glob "config/config.php" | ||||||
|  |  | ||||||
|  |   --exclude-glob "data" | ||||||
|  |   --exclude-glob "data/*" | ||||||
|  |   --exclude-glob "data/**" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | need() { command -v "$1" >/dev/null 2>&1 || { echo -e "${RED}Error: '$1' ist nicht installiert.${NC}"; exit 1; }; } | ||||||
|  |  | ||||||
|  | build_connect_program() { | ||||||
|  |   printf "ssh -i %q -o IdentitiesOnly=yes -o PreferredAuthentications=publickey -o PasswordAuthentication=no -o KbdInteractiveAuthentication=no -o NumberOfPasswordPrompts=0 -o BatchMode=yes -o ConnectTimeout=15 -o LogLevel=ERROR" "$SSH_KEY" | ||||||
|  | } | ||||||
|  |  | ||||||
|  | abort_defaults_present() { | ||||||
|  |   local bad=0 | ||||||
|  |   if [[ "$SFTP_HOST" == *CHANGE_ME* ]]; then echo -e "${RED}Unsafe default: SFTP_HOST=${SFTP_HOST}${NC}"; bad=1; fi | ||||||
|  |   if [[ "$SFTP_USER" == *CHANGE_ME* ]]; then echo -e "${RED}Unsafe default: SFTP_USER=${SFTP_USER}${NC}"; bad=1; fi | ||||||
|  |   if [[ "$SSH_KEY"    == *CHANGE_ME* ]]; then echo -e "${RED}Unsafe default: SSH_KEY=${SSH_KEY}${NC}";    bad=1; fi | ||||||
|  |   if [[ "$TARGET_DIR" == *CHANGE_ME* ]]; then echo -e "${RED}Unsafe default: TARGET_DIR=${TARGET_DIR}${NC}"; bad=1; fi | ||||||
|  |  | ||||||
|  |   if (( bad == 1 )); then | ||||||
|  |     cat <<EOF >&2 | ||||||
|  | ${YELLOW}Hinweis:${NC} Setze die Variablen per Umgebung oder .env: | ||||||
|  |   SFTP_HOST=example.org | ||||||
|  |   SFTP_USER=example | ||||||
|  |   SSH_KEY=/home/user/.ssh/id_ed25519 | ||||||
|  |   TARGET_DIR=/public_html/wishlist.hiabuto.de | ||||||
|  | Abbruch, weil noch CHANGE_ME-Defaults aktiv sind. | ||||||
|  | EOF | ||||||
|  |     exit 42 | ||||||
|  |   fi | ||||||
|  | } | ||||||
|  |  | ||||||
|  | echo -e "${GREEN}Starting wishlist deployment...${NC}" | ||||||
|  | echo -e "${GREEN}SFTP Upload -> ${SFTP_USER}@${SFTP_HOST}${NC}" | ||||||
|  | echo -e "${GREEN}Target Dir  -> ${TARGET_DIR}${NC}" | ||||||
|  | (( DRY_RUN == 1 )) && echo -e "${YELLOW}Mode        -> DRY-RUN${NC}" | ||||||
|  |  | ||||||
|  | need lftp | ||||||
|  | abort_defaults_present | ||||||
|  |  | ||||||
|  | [ -d "$SOURCE_DIR" ] || { echo -e "${RED}Error: SOURCE_DIR existiert nicht: ${SOURCE_DIR}${NC}"; exit 1; } | ||||||
|  | [ -r "$SSH_KEY" ]     || { echo -e "${RED}Error: SSH-Key nicht gefunden/lesbar: ${SSH_KEY}${NC}"; exit 1; } | ||||||
|  |  | ||||||
|  | case "$TARGET_DIR" in | ||||||
|  |   /public_html/*) : ;; | ||||||
|  |   *) echo -e "${RED}TARGET_DIR muss unter /public_html/ liegen (aktuell: ${TARGET_DIR})${NC}"; exit 1;; | ||||||
|  | esac | ||||||
|  |  | ||||||
|  | echo -e "${YELLOW}>> Prüfe SFTP-Verbindung (key-only)...${NC}" | ||||||
|  | if ! lftp </dev/null -e " | ||||||
|  |   set cmd:interactive false; | ||||||
|  |   set cmd:fail-exit yes; | ||||||
|  |   set net:max-retries 1; | ||||||
|  |   set net:timeout 15; | ||||||
|  |   set sftp:auto-confirm yes; | ||||||
|  |   set sftp:connect-program '$(build_connect_program)'; | ||||||
|  |   open -u ${SFTP_USER}, sftp://${SFTP_HOST}; | ||||||
|  |   cls -1 '${TARGET_DIR}'; | ||||||
|  |   bye | ||||||
|  | " >/dev/null 2>&1; then | ||||||
|  |   echo -e "${RED}SFTP-Test fehlgeschlagen.${NC}" | ||||||
|  |   exit 255 | ||||||
|  | fi | ||||||
|  |  | ||||||
|  | echo -e "${YELLOW}>> Upload per SFTP (mirror -R)...${NC}" | ||||||
|  | MIRROR_OPTS=( -R --delete --verbose --parallel=4 ) | ||||||
|  | (( DRY_RUN == 1 )) && MIRROR_OPTS+=( --dry-run ) | ||||||
|  |  | ||||||
|  | (( DEBUG == 1 )) && { | ||||||
|  |   echo "mirror opts: ${MIRROR_OPTS[*]}" | ||||||
|  |   echo "exclude    : ${EXCLUDE_ARGS[*]}" | ||||||
|  | } | ||||||
|  |  | ||||||
|  | lftp -e " | ||||||
|  |   set cmd:interactive false; | ||||||
|  |   set cmd:fail-exit yes; | ||||||
|  |   set net:max-retries 2; | ||||||
|  |   set net:timeout 20; | ||||||
|  |   set sftp:auto-confirm yes; | ||||||
|  |   set sftp:connect-program '$(build_connect_program)'; | ||||||
|  |   open -u ${SFTP_USER}, sftp://${SFTP_HOST}; | ||||||
|  |   mirror ${MIRROR_OPTS[*]} ${EXCLUDE_ARGS[*]} '${SOURCE_DIR%/}/' '${TARGET_DIR%/}/'; | ||||||
|  |   bye | ||||||
|  | " | ||||||
|  |  | ||||||
|  | if (( DRY_RUN == 1 )); then | ||||||
|  |   echo -e "${GREEN}DRY-RUN erfolgreich (keine Dateien verändert).${NC}" | ||||||
|  | else | ||||||
|  |   echo -e "${GREEN}SFTP-Upload erfolgreich.${NC}" | ||||||
|  | fi | ||||||
|  |  | ||||||
|  | echo -e "${GREEN}Deployment completed.${NC}" | ||||||
							
								
								
									
										
											BIN
										
									
								
								img/android-chrome-192x192.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 2.0 KiB | 
							
								
								
									
										
											BIN
										
									
								
								img/android-chrome-512x512.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 5.6 KiB | 
							
								
								
									
										
											BIN
										
									
								
								img/apple-touch-icon.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 2.4 KiB | 
							
								
								
									
										9
									
								
								img/browserconfig.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,9 @@ | |||||||
|  | <?xml version="1.0" encoding="utf-8"?> | ||||||
|  | <browserconfig> | ||||||
|  |     <msapplication> | ||||||
|  |         <tile> | ||||||
|  |             <square150x150logo src="img/mstile-150x150.png"/> | ||||||
|  |             <TileColor>#da532c</TileColor> | ||||||
|  |         </tile> | ||||||
|  |     </msapplication> | ||||||
|  | </browserconfig> | ||||||
							
								
								
									
										
											BIN
										
									
								
								img/favicon-16x16.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 511 B | 
							
								
								
									
										
											BIN
										
									
								
								img/favicon-32x32.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 607 B | 
							
								
								
									
										
											BIN
										
									
								
								img/favicon.ico
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 15 KiB | 
							
								
								
									
										3
									
								
								img/gift.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,3 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-gift" viewBox="0 0 16 16"> | ||||||
|  |   <path d="M3 2.5a2.5 2.5 0 0 1 5 0 2.5 2.5 0 0 1 5 0v.006c0 .07 0 .27-.038.494H15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1v7.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 1 14.5V7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h2.038A2.968 2.968 0 0 1 3 2.506V2.5zm1.068.5H7v-.5a1.5 1.5 0 1 0-3 0c0 .085.002.274.045.43a.522.522 0 0 0 .023.07zM9 3h2.932a.56.56 0 0 0 .023-.07c.043-.156.045-.345.045-.43a1.5 1.5 0 0 0-3 0V3zM1 4v2h6V4H1zm8 0v2h6V4H9zm5 3H9v8h4.5a.5.5 0 0 0 .5-.5V7zm-7 8V7H2v7.5a.5.5 0 0 0 .5.5H7z"/> | ||||||
|  | </svg> | ||||||
| After Width: | Height: | Size: 613 B | 
							
								
								
									
										
											BIN
										
									
								
								img/heart-icon.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 48 KiB | 
							
								
								
									
										
											BIN
										
									
								
								img/mstile-144x144.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.6 KiB | 
							
								
								
									
										
											BIN
										
									
								
								img/mstile-150x150.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 2.0 KiB | 
							
								
								
									
										
											BIN
										
									
								
								img/mstile-310x150.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 2.2 KiB | 
							
								
								
									
										
											BIN
										
									
								
								img/mstile-310x310.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 4.1 KiB | 
							
								
								
									
										
											BIN
										
									
								
								img/mstile-70x70.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.5 KiB | 
							
								
								
									
										
											BIN
										
									
								
								img/paw-icon.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 25 KiB | 
							
								
								
									
										
											BIN
										
									
								
								img/placeholders/no-image-katie-1.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 2.5 MiB | 
							
								
								
									
										
											BIN
										
									
								
								img/placeholders/no-image-katie-2.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 2.5 MiB | 
							
								
								
									
										39
									
								
								img/safari-pinned-tab.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,39 @@ | |||||||
|  | <?xml version="1.0" standalone="no"?> | ||||||
|  | <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" | ||||||
|  |  "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd"> | ||||||
|  | <svg version="1.0" xmlns="http://www.w3.org/2000/svg" | ||||||
|  |  width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000" | ||||||
|  |  preserveAspectRatio="xMidYMid meet"> | ||||||
|  | <metadata> | ||||||
|  | Created by potrace 1.14, written by Peter Selinger 2001-2017 | ||||||
|  | </metadata> | ||||||
|  | <g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)" | ||||||
|  | fill="#000000" stroke="none"> | ||||||
|  | <path d="M2225 6985 c-102 -20 -110 -22 -178 -46 -378 -133 -657 -466 -720 | ||||||
|  | -861 -14 -83 -15 -304 -3 -360 6 -27 5 -28 -36 -29 -24 0 -234 -1 -467 -1 | ||||||
|  | -460 0 -499 -4 -594 -55 -76 -42 -145 -117 -186 -203 l-36 -75 -2 -520 c-2 | ||||||
|  | -574 -1 -581 60 -684 64 -107 180 -185 306 -206 l66 -10 1 -1655 c1 -910 4 | ||||||
|  | -1678 7 -1706 34 -272 244 -501 514 -559 103 -22 4983 -22 5088 0 236 49 430 | ||||||
|  | 235 495 475 l23 85 0 1678 c0 1194 4 1680 11 1682 6 1 40 8 75 15 161 30 294 | ||||||
|  | 155 336 315 23 89 22 999 -1 1092 -39 156 -151 270 -308 313 -53 15 -120 17 | ||||||
|  | -529 18 -258 0 -470 1 -472 3 -2 2 1 36 5 74 8 69 5 244 -5 317 -9 65 -55 208 | ||||||
|  | -90 285 -98 213 -276 399 -485 507 -81 42 -186 83 -239 92 -13 3 -53 11 -89 | ||||||
|  | 18 -107 23 -326 14 -447 -18 -113 -30 -292 -113 -375 -176 -125 -93 -235 -219 | ||||||
|  | -313 -357 -62 -110 -123 -294 -126 -380 -2 -46 -17 -21 -29 46 -32 190 -139 | ||||||
|  | 406 -270 546 -159 172 -347 278 -597 336 -75 17 -308 20 -390 4z m347 -445 | ||||||
|  | c244 -56 454 -294 484 -546 10 -87 10 -295 0 -301 -15 -9 -1267 -6 -1276 3 | ||||||
|  | -17 17 -23 70 -24 199 0 97 5 145 18 190 76 253 306 448 557 471 35 4 65 7 66 | ||||||
|  | 8 5 4 124 -12 175 -24z m2235 -16 c102 -33 167 -75 253 -163 79 -80 134 -170 | ||||||
|  | 165 -271 31 -99 26 -362 -7 -395 -9 -9 -1267 -10 -1275 -1 -9 8 -7 233 2 304 | ||||||
|  | 20 162 120 331 257 435 87 67 245 122 368 129 65 4 154 -10 237 -38z m-1297 | ||||||
|  | -508 c0 -3 -4 -8 -10 -11 -5 -3 -10 -1 -10 4 0 6 5 11 10 11 6 0 10 -2 10 -4z | ||||||
|  | m-449 -773 c0 -5 1 -200 2 -436 1 -278 -1 -427 -8 -428 -5 -1 -596 -2 -1312 | ||||||
|  | -3 l-1303 -1 -1 25 c-1 31 -1 844 0 848 2 6 2621 1 2622 -5z m3500 -5 c1 -16 | ||||||
|  | 1 -839 0 -853 -1 -12 -2605 -15 -2617 -3 -5 6 -10 775 -5 856 1 9 270 12 1311 | ||||||
|  | 12 1041 0 1310 -3 1311 -12z m-3500 -1337 c4 -179 1 -3450 -4 -3457 -3 -5 | ||||||
|  | -430 -9 -1004 -8 -982 1 -999 1 -1044 21 -53 24 -82 52 -112 108 l-21 40 -1 | ||||||
|  | 1666 0 1666 1092 0 1093 0 1 -36z m3064 -1631 l-1 -1665 -21 -40 c-30 -56 -59 | ||||||
|  | -84 -112 -108 -45 -20 -62 -20 -1039 -21 -547 -1 -999 2 -1004 6 -8 4 -11 537 | ||||||
|  | -10 1740 0 953 1 1738 1 1744 1 8 311 11 1094 10 l1092 -1 0 -1665z"/> | ||||||
|  | </g> | ||||||
|  | </svg> | ||||||
| After Width: | Height: | Size: 2.4 KiB | 
							
								
								
									
										
											BIN
										
									
								
								img/shakaru-icon.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 27 KiB | 
							
								
								
									
										19
									
								
								img/site.webmanifest
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,19 @@ | |||||||
|  | { | ||||||
|  |     "name": "", | ||||||
|  |     "short_name": "", | ||||||
|  |     "icons": [ | ||||||
|  |         { | ||||||
|  |             "src": "android-chrome-192x192.png", | ||||||
|  |             "sizes": "192x192", | ||||||
|  |             "type": "image/png" | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             "src": "android-chrome-512x512.png", | ||||||
|  |             "sizes": "512x512", | ||||||
|  |             "type": "image/png" | ||||||
|  |         } | ||||||
|  |     ], | ||||||
|  |     "theme_color": "#ffffff", | ||||||
|  |     "background_color": "#ffffff", | ||||||
|  |     "display": "standalone" | ||||||
|  | } | ||||||
							
								
								
									
										116
									
								
								include/delete_unused.php
									
									
									
									
									
										Normal 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
									
								
							
							
						
						| @@ -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 === '') ? ' ' : 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(); | ||||||
|  | } | ||||||
							
								
								
									
										583
									
								
								index.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,583 @@ | |||||||
|  | <?php | ||||||
|  | declare(strict_types=1); | ||||||
|  |  | ||||||
|  | // ===== Session Setup (einheitlich in allen Entry Points) ===== | ||||||
|  | $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'); | ||||||
|  | session_set_cookie_params([ | ||||||
|  |   'lifetime' => 0, | ||||||
|  |   'path' => '/', | ||||||
|  |   'secure' => $secure, | ||||||
|  |   'httponly' => true, | ||||||
|  |   'samesite' => 'Lax', | ||||||
|  | ]); | ||||||
|  | session_name('WLISTSESSID'); | ||||||
|  | session_start(); | ||||||
|  |  | ||||||
|  | require_once __DIR__ . '/config/config.php'; | ||||||
|  | require_once __DIR__ . '/include/listgenerator.php'; | ||||||
|  |  | ||||||
|  | // ===== Debug Toggle ===== | ||||||
|  | if (!empty($app_debug)) { | ||||||
|  |   ini_set('display_errors', '1'); | ||||||
|  |   ini_set('display_startup_errors', '1'); | ||||||
|  |   ini_set('log_errors', '1'); | ||||||
|  |   if (!is_dir(__DIR__ . '/logs')) { | ||||||
|  |     @mkdir(__DIR__ . '/logs', 0750, true); | ||||||
|  |   } | ||||||
|  |   ini_set('error_log', __DIR__ . '/logs/php-error.log'); | ||||||
|  |   error_reporting(E_ALL); | ||||||
|  | } else { | ||||||
|  |   ini_set('display_errors', '0'); | ||||||
|  |   ini_set('display_startup_errors', '0'); | ||||||
|  |   ini_set('log_errors', '1'); | ||||||
|  |   if (!is_dir(__DIR__ . '/logs')) { | ||||||
|  |     @mkdir(__DIR__ . '/logs', 0750, true); | ||||||
|  |   } | ||||||
|  |   ini_set('error_log', __DIR__ . '/logs/php-error.log'); | ||||||
|  |   error_reporting(E_ALL); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | $message = null; | ||||||
|  |  | ||||||
|  | if (!empty($_SESSION['flash']) && is_array($_SESSION['flash'])) { | ||||||
|  |   $message = $_SESSION['flash']; // ['msg'=>..., 'type'=> success|warning|danger] | ||||||
|  |   unset($_SESSION['flash']); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // ===== Helpers ===== | ||||||
|  | function e(string $s): string | ||||||
|  | { | ||||||
|  |   return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); | ||||||
|  | } | ||||||
|  | function db(): mysqli | ||||||
|  | { | ||||||
|  |   global $servername, $username, $password, $db; | ||||||
|  |   $m = new mysqli($servername, $username, $password, $db); | ||||||
|  |   if ($m->connect_error) { | ||||||
|  |     http_response_code(500); | ||||||
|  |     exit('Interner Fehler (DB)'); | ||||||
|  |   } | ||||||
|  |   $m->set_charset('utf8mb4'); | ||||||
|  |   return $m; | ||||||
|  | } | ||||||
|  | function ensure_csrf_token(): void | ||||||
|  | { | ||||||
|  |   if (empty($_SESSION['csrf'])) { | ||||||
|  |     $_SESSION['csrf'] = bin2hex(random_bytes(32)); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | function require_csrf(): void | ||||||
|  | { | ||||||
|  |   if ($_SERVER['REQUEST_METHOD'] === 'POST') { | ||||||
|  |     $ok = isset($_POST['csrf']) && hash_equals($_SESSION['csrf'] ?? '', (string) $_POST['csrf']); | ||||||
|  |     if (!$ok) { | ||||||
|  |       http_response_code(403); | ||||||
|  |       exit('Ungültiges CSRF-Token'); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | ensure_csrf_token(); | ||||||
|  | // ===== URL-Param: Liste per UUID (oder Alt-ID -> Redirect) ===== | ||||||
|  | $ListID = -1; | ||||||
|  | $ListUUID = ''; | ||||||
|  | $showCreateEmptyState = false; | ||||||
|  |  | ||||||
|  | if (!isset($_GET['list'])) { | ||||||
|  |   // Kein Parameter => nur Empty-State ohne Message | ||||||
|  |   $showCreateEmptyState = true; | ||||||
|  |  | ||||||
|  | } else { | ||||||
|  |   $raw = trim((string) $_GET['list']); | ||||||
|  |  | ||||||
|  |   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(); | ||||||
|  |     $s = $c->prepare('SELECT ID, uuid FROM lists WHERE uuid=?'); | ||||||
|  |     $s->bind_param('s', $raw); | ||||||
|  |     $s->execute(); | ||||||
|  |     $r = $s->get_result(); | ||||||
|  |     if ($r && ($row = $r->fetch_assoc())) { | ||||||
|  |       $ListID = (int) $row['ID']; | ||||||
|  |       $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(); | ||||||
|  |     $c->close(); | ||||||
|  |  | ||||||
|  |   } elseif (preg_match('/^\d+$/', $raw)) { | ||||||
|  |     // Numerische ID übergeben -> auf UUID umleiten, wenn vorhanden | ||||||
|  |     $id = (int) $raw; | ||||||
|  |     $c = db(); | ||||||
|  |     $s = $c->prepare('SELECT uuid FROM lists WHERE ID=?'); | ||||||
|  |     $s->bind_param('i', $id); | ||||||
|  |     $s->execute(); | ||||||
|  |     $r = $s->get_result(); | ||||||
|  |     if ($r && ($row = $r->fetch_assoc())) { | ||||||
|  |       $uuid = (string) $row['uuid']; | ||||||
|  |       $scheme = $secure ? 'https' : 'http'; | ||||||
|  |       $host = $_SERVER['HTTP_HOST'] ?? ''; | ||||||
|  |       header('Location: ' . $scheme . '://' . $host . '/?list=' . urlencode($uuid), true, 301); | ||||||
|  |       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(); | ||||||
|  |     $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; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // ===== Sortierung (Whitelist) ===== | ||||||
|  | $sortby = 'priority'; | ||||||
|  | if (isset($_POST['sortby'])) | ||||||
|  |   $sortby = (string) $_POST['sortby']; | ||||||
|  | elseif (isset($_POST['sortby_transfer'])) | ||||||
|  |   $sortby = (string) $_POST['sortby_transfer']; | ||||||
|  | $allowedOrder = ['priority' => 'priority DESC', 'price_asc' => 'price ASC', 'price_desc' => 'price DESC', 'date_desc' => 'date DESC', 'date_asc' => 'date ASC', 'random' => 'RAND()']; | ||||||
|  | if (!array_key_exists($sortby, $allowedOrder)) | ||||||
|  |   $sortby = 'priority'; | ||||||
|  |  | ||||||
|  | // ===== Login-Status ===== | ||||||
|  | $loggedin = (isset($_SESSION['listid']) && $ListID === (int) $_SESSION['listid']); | ||||||
|  | $GLOBALS['loggedin'] = $loggedin; // für listgenerator.php | ||||||
|  |  | ||||||
|  | // ===== POST-Actions (mit CSRF) ===== | ||||||
|  | if ($_SERVER['REQUEST_METHOD'] === 'POST') { | ||||||
|  |   require_csrf(); | ||||||
|  |  | ||||||
|  |   // LOGIN | ||||||
|  |   if (isset($_POST['login'])) { | ||||||
|  |     $ListPassword = (string) ($_POST['ListPassword'] ?? ''); | ||||||
|  |     $ListIdFromForm = (int) ($_POST['ListID'] ?? -1); | ||||||
|  |     $c = db(); | ||||||
|  |     $s = $c->prepare('SELECT edit_pw, uuid FROM lists WHERE ID=?'); | ||||||
|  |     $s->bind_param('i', $ListIdFromForm); | ||||||
|  |     $s->execute(); | ||||||
|  |     $r = $s->get_result(); | ||||||
|  |     if ($r && ($row = $r->fetch_assoc())) { | ||||||
|  |       if (password_verify($ListPassword, (string) $row['edit_pw'])) { | ||||||
|  |         $_SESSION['listid'] = $ListIdFromForm; | ||||||
|  |         $loggedin = true; | ||||||
|  |         $ListUUID = (string) $row['uuid']; | ||||||
|  |         $message = ['msg' => 'Login erfolgreich', 'type' => 'success']; | ||||||
|  |       } else | ||||||
|  |         $message = ['msg' => 'Falsches Passwort', 'type' => 'warning']; | ||||||
|  |     } else | ||||||
|  |       $message = ['msg' => 'Liste nicht gefunden', 'type' => 'warning']; | ||||||
|  |     $s->close(); | ||||||
|  |     $c->close(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // LISTE ANLEGEN | ||||||
|  |   if (isset($_POST['listadd'])) { | ||||||
|  |     $listName = (string) ($_POST['listName'] ?? ''); | ||||||
|  |     $listPasswordRaw = (string) ($_POST['listPassword'] ?? ''); | ||||||
|  |     $listDescription = (string) ($_POST['listDescription'] ?? ''); | ||||||
|  |     $listPassword = password_hash($listPasswordRaw, PASSWORD_DEFAULT); | ||||||
|  |  | ||||||
|  |     $c = db(); | ||||||
|  |     $s = $c->prepare('INSERT INTO lists (uuid, title, description, edit_pw) VALUES (UUID(), ?, ?, ?)'); | ||||||
|  |     $s->bind_param('sss', $listName, $listDescription, $listPassword); | ||||||
|  |     if ($s->execute()) { | ||||||
|  |       $last_id = $c->insert_id; | ||||||
|  |       $g = $c->prepare('SELECT uuid FROM lists WHERE ID=?'); | ||||||
|  |       $g->bind_param('i', $last_id); | ||||||
|  |       $g->execute(); | ||||||
|  |       $gr = $g->get_result(); | ||||||
|  |       $uuid = ($gr && ($row = $gr->fetch_assoc())) ? (string) $row['uuid'] : ''; | ||||||
|  |       $g->close(); | ||||||
|  |       $_SESSION['listid'] = $last_id; | ||||||
|  |       $loggedin = true; | ||||||
|  |       $scheme = $secure ? 'https' : 'http'; | ||||||
|  |       $host = $_SERVER['HTTP_HOST'] ?? ''; | ||||||
|  |       header('Location: ' . $scheme . '://' . $host . '/?list=' . urlencode($uuid)); | ||||||
|  |       exit; | ||||||
|  |     } else { | ||||||
|  |       $message = ['msg' => 'Unerwarteter Fehler beim Anlegen', 'type' => 'danger']; | ||||||
|  |     } | ||||||
|  |     $s->close(); | ||||||
|  |     $c->close(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // LOGOUT | ||||||
|  |   if (isset($_POST['logout'])) { | ||||||
|  |     session_destroy(); | ||||||
|  |     $loggedin = false; | ||||||
|  |     $message = ['msg' => 'Logout erfolgreich', 'type' => 'success']; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // PRIORITÄT PUSHEN | ||||||
|  |   if (isset($_POST['pushprio'])) { | ||||||
|  |     $wishId = (int) ($_POST['WhishID'] ?? -1); | ||||||
|  |     $c = db(); | ||||||
|  |     $s = $c->prepare('SELECT COALESCE(MAX(priority),0) AS maxprio FROM wishes WHERE wishlist=?'); | ||||||
|  |     $s->bind_param('i', $ListID); | ||||||
|  |     $s->execute(); | ||||||
|  |     $r = $s->get_result(); | ||||||
|  |     $next = 1; | ||||||
|  |     if ($r && ($row = $r->fetch_assoc())) | ||||||
|  |       $next = ((int) $row['maxprio']) + 1; | ||||||
|  |     $s->close(); | ||||||
|  |     $u = $c->prepare('UPDATE wishes SET priority=? WHERE ID=?'); | ||||||
|  |     $u->bind_param('ii', $next, $wishId); | ||||||
|  |     $message = $u->execute() ? ['msg' => 'Wunschpriorität aktualisiert', 'type' => 'success'] : ['msg' => 'Uups, irgendwas ist schief gegangen!', 'type' => 'danger']; | ||||||
|  |     $u->close(); | ||||||
|  |     $c->close(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // LÖSCHEN (nur eingeloggt) | ||||||
|  |   if (isset($_POST['delete']) && $loggedin === true) { | ||||||
|  |     $WhishID = (int) ($_POST['WhishID'] ?? -1); | ||||||
|  |     $WhishTitle = ''; | ||||||
|  |     $c = db(); | ||||||
|  |     $s = $c->prepare('SELECT image, title FROM wishes WHERE ID=?'); | ||||||
|  |     $s->bind_param('i', $WhishID); | ||||||
|  |     $s->execute(); | ||||||
|  |     $r = $s->get_result(); | ||||||
|  |     if ($r && ($row = $r->fetch_assoc())) { | ||||||
|  |       $WhishTitle = (string) $row['title']; | ||||||
|  |       $imageFile = (string) $row['image']; | ||||||
|  |       if (!empty($imageFile)) { | ||||||
|  |         global $imagedir; | ||||||
|  |         $full = rtrim($imagedir, '/') . '/' . $imageFile; | ||||||
|  |         if (is_file($full)) | ||||||
|  |           @unlink($full); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     $s->close(); | ||||||
|  |     $d = $c->prepare('DELETE FROM wishes WHERE ID=?'); | ||||||
|  |     $d->bind_param('i', $WhishID); | ||||||
|  |     $message = $d->execute() ? ['msg' => 'Wunsch <b>"' . e($WhishTitle) . '"</b> gelöscht', 'type' => 'success'] : ['msg' => 'Uups, irgendwas ist schief gegangen!', 'type' => 'danger']; | ||||||
|  |     $d->close(); | ||||||
|  |     $c->close(); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | ?> | ||||||
|  | <!DOCTYPE html> | ||||||
|  | <html lang="de"> | ||||||
|  |  | ||||||
|  | <head> | ||||||
|  |   <meta charset="utf-8" /> | ||||||
|  |   <title>Simple Wishlist</title> | ||||||
|  |   <meta name="viewport" content="width=device-width, initial-scale=1"> | ||||||
|  |   <link rel="stylesheet" href="css/bootstrap.min.css"> | ||||||
|  |   <link rel="stylesheet" href="css/tweaks.css"> | ||||||
|  |   <link rel="stylesheet" href="css/fontawesome.min.css" /> | ||||||
|  |  | ||||||
|  |   <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="16x16" href="img/favicon-16x16.png"> | ||||||
|  |   <link rel="mask-icon" href="img/safari-pinned-tab.svg" color="#5bbad5"> | ||||||
|  |   <link rel="shortcut icon" href="img/favicon.ico"> | ||||||
|  |   <meta name="theme-color" content="#ffffff"> | ||||||
|  | </head> | ||||||
|  |  | ||||||
|  | <body> | ||||||
|  |  | ||||||
|  |   <header> | ||||||
|  |     <div class="navbar navbar-dark bg-dark shadow-sm"> | ||||||
|  |       <div class="container"> | ||||||
|  |         <a href="#" class="navbar-brand d-flex align-items-center"> | ||||||
|  |           <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-2 bi bi-gift" | ||||||
|  |             viewBox="0 0 16 16"> | ||||||
|  |             <path | ||||||
|  |               d="M3 2.5a2.5 2.5 0 0 1 5 0 2.5 2.5 0 0 1 5 0v.006c0 .07 0 .27-.038.494H15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1v7.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 1 14.5V7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h2.038A2.968 2.968 0 0 1 3 2.506V2.5zm1.068.5H7v-.5a1.5 1.5 0 1 0-3 0c0 .085.002.274.045.43a.522.522 0 0 0 .023.07zM9 3h2.932a.56.56 0 0 0 .023-.07c.043-.156.045-.345.045-.43a1.5 1.5 0 0 0-3 0V3zM1 4v2h6V4H1zm8 0v2h6V4H9zm5 3H9v8h4.5a.5.5 0 0 0 .5-.5V7zm-7 8V7H2v7.5a.5.5 0 0 0 .5.5H7z" /> | ||||||
|  |           </svg> | ||||||
|  |           <strong>Simple Wishlist</strong> | ||||||
|  |         </a> | ||||||
|  |         <div class="nav navbar-nav navbar-right"> | ||||||
|  |           <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): ?> | ||||||
|  |                 <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-sort="<?= e($sortby) ?>">Add Item</button> | ||||||
|  |                 <form class="form-inline" action="" method="POST"> | ||||||
|  |                   <input type="hidden" name="csrf" value="<?= e($_SESSION['csrf']) ?>"> | ||||||
|  |                   <button type="submit" class="btn btn-outline-secondary my-2 my-sm-0" name="logout">Logout</button> | ||||||
|  |                 </form> | ||||||
|  |               <?php else: ?> | ||||||
|  |                 <button type="button" class="btn btn-outline-secondary my-2 my-sm-0" data-bs-toggle="modal" | ||||||
|  |                   data-bs-target="#loginModal">Login</button> | ||||||
|  |               <?php endif; ?> | ||||||
|  |               <form class="form-inline" action="" method="POST"> | ||||||
|  |                 <input type="hidden" name="csrf" value="<?= e($_SESSION['csrf']) ?>"> | ||||||
|  |                 <select class="form-control" name="sortby" id="sortby"> | ||||||
|  |                   <option <?= $sortby === 'priority' ? 'selected' : '' ?> value="priority">Priorität</option> | ||||||
|  |                   <option <?= $sortby === 'price_asc' ? 'selected' : '' ?> value="price_asc">Preis aufsteigend</option> | ||||||
|  |                   <option <?= $sortby === 'price_desc' ? 'selected' : '' ?> value="price_desc">Preis absteigend</option> | ||||||
|  |                   <option <?= $sortby === 'date_desc' ? 'selected' : '' ?> value="date_desc">Datum, neu → alt</option> | ||||||
|  |                   <option <?= $sortby === 'date_asc' ? 'selected' : '' ?> value="date_asc">Datum, alt → neu</option> | ||||||
|  |                   <option <?= $sortby === 'random' ? 'selected' : '' ?> value="random">Zufall</option> | ||||||
|  |                 </select> | ||||||
|  |               </form> | ||||||
|  |             <?php endif; ?> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </header> | ||||||
|  |  | ||||||
|  |   <main class="pb-4"> | ||||||
|  |     <?php if (isset($message)): ?> | ||||||
|  |       <div class="alert alert-<?= e($message['type']) ?> alert-dismissible fade show m-3" role="alert"> | ||||||
|  |         <?= $message['msg'] ?> | ||||||
|  |         <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> | ||||||
|  |       </div> | ||||||
|  |     <?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 endif; ?> | ||||||
|  |   </main> | ||||||
|  |  | ||||||
|  |   <footer class="mt-5 text-center text-muted small"> | ||||||
|  |     <div class="container"> | ||||||
|  |       <hr class="mb-3 opacity-25"> | ||||||
|  |       <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"> | ||||||
|  |         © <?= date('Y') ?> Simple Wishlist · Marcel Peterkau | ||||||
|  |         · <a href="#" class="text-decoration-none">Back to top ↑</a> | ||||||
|  |       </p> | ||||||
|  |     </div> | ||||||
|  |   </footer> | ||||||
|  |  | ||||||
|  |   <?php if (!$loggedin): ?> | ||||||
|  |     <!-- Login Modal --> | ||||||
|  |     <div class="modal fade" id="loginModal" tabindex="-1" aria-hidden="true"> | ||||||
|  |       <div class="modal-dialog"> | ||||||
|  |         <div class="modal-content"> | ||||||
|  |           <div class="modal-header"> | ||||||
|  |             <h5 class="modal-title">Login</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button> | ||||||
|  |           </div> | ||||||
|  |           <form action="" method="POST"> | ||||||
|  |             <div class="modal-body"> | ||||||
|  |               <label for="ListPassword" class="form-label">Passwort</label> | ||||||
|  |               <input type="password" class="form-control" id="ListPassword" name="ListPassword" required> | ||||||
|  |               <input type="hidden" id="ListID" name="ListID" value="<?= (int) $ListID ?>"> | ||||||
|  |             </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" name="login" class="btn btn-primary">Login</button> | ||||||
|  |             </div> | ||||||
|  |           </form> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   <?php endif; ?> | ||||||
|  |  | ||||||
|  |   <!-- Add/Edit Modal (ein Modal für beides) --> | ||||||
|  |   <div class="modal fade" id="itemModal" tabindex="-1" aria-hidden="true"> | ||||||
|  |     <div class="modal-dialog"> | ||||||
|  |       <div class="modal-content"> | ||||||
|  |         <div class="modal-header"> | ||||||
|  |           <h5 class="modal-title" id="itemModalTitle">Wunsch hinzufügen</h5><button type="button" class="btn-close" | ||||||
|  |             data-bs-dismiss="modal"></button> | ||||||
|  |         </div> | ||||||
|  |         <form action="item.php" method="POST" id="itemForm"> | ||||||
|  |           <div class="modal-body"> | ||||||
|  |             <label class="form-label">Titel</label> | ||||||
|  |             <input class="form-control" name="ItemTitle" id="ItemTitle" required> | ||||||
|  |  | ||||||
|  |             <label class="form-label mt-2">Beschreibung</label> | ||||||
|  |             <textarea class="form-control" name="ItemDescription" id="ItemDescription" rows="3"></textarea> | ||||||
|  |  | ||||||
|  |             <label class="form-label mt-2">Preis</label> | ||||||
|  |             <div class="input-group"> | ||||||
|  |               <input class="form-control" name="ItemPrice" id="ItemPrice" placeholder="0,00"> | ||||||
|  |               <span class="input-group-text">€</span> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <label class="form-label mt-2">Anzahl</label> | ||||||
|  |             <input class="form-control" name="ItemQty" id="ItemQty" type="number" min="1" step="1" value="1" required> | ||||||
|  |  | ||||||
|  |             <label class="form-label mt-2">Link zum Angebot</label> | ||||||
|  |             <input class="form-control" name="ItemLink" id="ItemLink" type="url" pattern="https?://.+"> | ||||||
|  |  | ||||||
|  |             <label class="form-label mt-2">Bild (URL)</label> | ||||||
|  |             <input class="form-control" name="ItemImage" id="ItemImage" type="url" pattern="https?://.+"> | ||||||
|  |  | ||||||
|  |             <div class="form-check mt-2" id="RemoveImageWrap" style="display:none;"> | ||||||
|  |               <input class="form-check-input" type="checkbox" value="1" id="RemoveImage" name="RemoveImage"> | ||||||
|  |               <label class="form-check-label" for="RemoveImage">Aktuelles Bild entfernen</label> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |           <div class="modal-footer"> | ||||||
|  |             <input type="hidden" name="action" id="ItemAction" value="add"> | ||||||
|  |             <input type="hidden" name="WhishID" id="WhishID" value="-1"> | ||||||
|  |             <input type="hidden" name="ItemListUUID" value="<?= e($ListUUID) ?>"> | ||||||
|  |             <input type="hidden" name="sortby_transfer" value="<?= e($sortby) ?>"> | ||||||
|  |             <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" id="ItemSubmitBtn">Speichern</button> | ||||||
|  |           </div> | ||||||
|  |         </form> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  |  | ||||||
|  |   <!-- Delete Modal --> | ||||||
|  |   <div class="modal fade" id="deleteModal" tabindex="-1" aria-hidden="true"> | ||||||
|  |     <div class="modal-dialog"> | ||||||
|  |       <div class="modal-content"> | ||||||
|  |         <div class="modal-header"> | ||||||
|  |           <h5 class="modal-title">Wunsch löschen</h5><button type="button" class="btn-close" | ||||||
|  |             data-bs-dismiss="modal"></button> | ||||||
|  |         </div> | ||||||
|  |         <div class="modal-body"> | ||||||
|  |           <h5 id="del-whish-title">WunschTitel</h5> | ||||||
|  |           <p>Soll dieser Wunsch wirklich gelöscht werden?</p> | ||||||
|  |         </div> | ||||||
|  |         <div class="modal-footer"> | ||||||
|  |           <form action="" method="POST"> | ||||||
|  |             <input type="hidden" name="WhishID" id="DelWhishID" value="-1"> | ||||||
|  |             <input type="hidden" name="sortby_transfer" value="<?= e($sortby) ?>"> | ||||||
|  |             <input type="hidden" name="csrf" value="<?= e($_SESSION['csrf']) ?>"> | ||||||
|  |             <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Nein</button> | ||||||
|  |             <button type="submit" name="delete" class="btn btn-danger">Löschen</button> | ||||||
|  |           </form> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  |  | ||||||
|  |   <!-- Push Priority Modal --> | ||||||
|  |   <div class="modal fade" id="pushprioModal" tabindex="-1" aria-hidden="true"> | ||||||
|  |     <div class="modal-dialog"> | ||||||
|  |       <div class="modal-content"> | ||||||
|  |         <div class="modal-header"> | ||||||
|  |           <h5 class="modal-title">Wunschpriorität</h5><button type="button" class="btn-close" | ||||||
|  |             data-bs-dismiss="modal"></button> | ||||||
|  |         </div> | ||||||
|  |         <div class="modal-body"> | ||||||
|  |           <h5 id="prio-whish-title">WunschTitel</h5> | ||||||
|  |           <p>Priorität ganz nach oben setzen?</p> | ||||||
|  |         </div> | ||||||
|  |         <div class="modal-footer"> | ||||||
|  |           <form action="" method="POST"> | ||||||
|  |             <input type="hidden" name="WhishID" id="PrioWhishID" value="-1"> | ||||||
|  |             <input type="hidden" name="sortby_transfer" value="<?= e($sortby) ?>"> | ||||||
|  |             <input type="hidden" name="csrf" value="<?= e($_SESSION['csrf']) ?>"> | ||||||
|  |             <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Nein</button> | ||||||
|  |             <button type="submit" name="pushprio" class="btn btn-primary">Ja</button> | ||||||
|  |           </form> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  |  | ||||||
|  |   <!-- Reservation Modal (öffentlich) --> | ||||||
|  |   <div class="modal fade" id="reservationModal" tabindex="-1" aria-hidden="true"> | ||||||
|  |     <div class="modal-dialog"> | ||||||
|  |       <div class="modal-content"> | ||||||
|  |         <div class="modal-header"> | ||||||
|  |           <h5 class="modal-title" id="reservationModalLabel">Wunsch reservieren</h5><button type="button" | ||||||
|  |             class="btn-close" data-bs-dismiss="modal"></button> | ||||||
|  |         </div> | ||||||
|  |         <div class="modal-body"> | ||||||
|  |           <p id="ReservationInfoText">Bitte vergeben Sie ein Passwort, um diesen Wunsch zu reservieren. Nur mit diesem | ||||||
|  |             Passwort (oder durch den Listeneigentümer) kann die Reservierung wieder aufgehoben werden.</p> | ||||||
|  |           <form action="reservations.php" method="POST" id="reservationForm"> | ||||||
|  |             <label for="WishPassword" class="form-label">Passwort</label> | ||||||
|  |             <input type="password" class="form-control" id="WishPassword" name="WishPassword" required> | ||||||
|  |             <input type="hidden" name="wishid" id="modal-wishid" value=""> | ||||||
|  |             <input type="hidden" name="reservedstat" id="modal-reservedstat" value=""> | ||||||
|  |             <input type="hidden" name="sortby_transfer" value="<?= e($sortby) ?>"> | ||||||
|  |             <input type="hidden" name="csrf" value="<?= e($_SESSION['csrf']) ?>"> | ||||||
|  |           </form> | ||||||
|  |         </div> | ||||||
|  |         <div class="modal-footer"> | ||||||
|  |           <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button> | ||||||
|  |           <button type="submit" class="btn btn-primary" form="reservationForm" | ||||||
|  |             id="reservation-submit">Reservieren</button> | ||||||
|  |         </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/jquery.min.js"></script> | ||||||
|  |   <script src="js/wishlist.js"></script> | ||||||
|  | </body> | ||||||
|  |  | ||||||
|  | </html> | ||||||
							
								
								
									
										426
									
								
								item.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,426 @@ | |||||||
|  | <?php | ||||||
|  | declare(strict_types=1); | ||||||
|  |  | ||||||
|  | /* ========= Session & Bootstrap ========= */ | ||||||
|  | $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'); | ||||||
|  | session_set_cookie_params([ | ||||||
|  |   'lifetime' => 0, | ||||||
|  |   'path' => '/', | ||||||
|  |   'secure' => $secure, | ||||||
|  |   'httponly' => true, | ||||||
|  |   'samesite' => 'Lax', | ||||||
|  | ]); | ||||||
|  | session_name('WLISTSESSID'); | ||||||
|  | session_start(); | ||||||
|  | umask(0022); // sorgt für 0644/0755 als Default für neu erstellte Dateien/Ordner | ||||||
|  |  | ||||||
|  | require_once __DIR__ . '/config/config.php'; | ||||||
|  |  | ||||||
|  | // ===== Debug Toggle ===== | ||||||
|  | if (!empty($app_debug)) { | ||||||
|  |   ini_set('display_errors', '1'); | ||||||
|  |   ini_set('display_startup_errors', '1'); | ||||||
|  |   ini_set('log_errors', '1'); | ||||||
|  |   if (!is_dir(__DIR__ . '/logs')) { | ||||||
|  |     @mkdir(__DIR__ . '/logs', 0750, true); | ||||||
|  |   } | ||||||
|  |   ini_set('error_log', __DIR__ . '/logs/php-error.log'); | ||||||
|  |   error_reporting(E_ALL); | ||||||
|  | } else { | ||||||
|  |   ini_set('display_errors', '0'); | ||||||
|  |   ini_set('display_startup_errors', '0'); | ||||||
|  |   ini_set('log_errors', '1'); | ||||||
|  |   if (!is_dir(__DIR__ . '/logs')) { | ||||||
|  |     @mkdir(__DIR__ . '/logs', 0750, true); | ||||||
|  |   } | ||||||
|  |   ini_set('error_log', __DIR__ . '/logs/php-error.log'); | ||||||
|  |   error_reporting(E_ALL); | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | /* ============= Helpers ============= */ | ||||||
|  |  | ||||||
|  | function fail(string $msg = 'Unerwarteter Fehler', int $code = 400): void | ||||||
|  | { | ||||||
|  |   http_response_code($code); | ||||||
|  |   exit($msg); | ||||||
|  | } | ||||||
|  | function require_csrf(): void | ||||||
|  | { | ||||||
|  |   $t = (string) ($_POST['csrf'] ?? ''); | ||||||
|  |   if (empty($_SESSION['csrf']) || !hash_equals($_SESSION['csrf'], $t)) | ||||||
|  |     fail('Ungültiges CSRF-Token', 403); | ||||||
|  | } | ||||||
|  | function require_logged_in(): int | ||||||
|  | { | ||||||
|  |   if (!isset($_SESSION['listid'])) | ||||||
|  |     fail('Nicht eingeloggt', 403); | ||||||
|  |   return (int) $_SESSION['listid']; | ||||||
|  | } | ||||||
|  | function db(): mysqli | ||||||
|  | { | ||||||
|  |   global $servername, $username, $password, $db; | ||||||
|  |   $conn = new mysqli($servername, $username, $password, $db); | ||||||
|  |   if ($conn->connect_error) | ||||||
|  |     fail('Interner Fehler (DB)', 500); | ||||||
|  |   $conn->set_charset('utf8mb4'); | ||||||
|  |   return $conn; | ||||||
|  | } | ||||||
|  | function parse_price_to_cents(string $in): int | ||||||
|  | { | ||||||
|  |   $clean = preg_replace('/[^\d,\.]/', '', $in ?? ''); | ||||||
|  |   $norm = str_replace('.', '', $clean); | ||||||
|  |   $norm = str_replace(',', '.', $norm); | ||||||
|  |   if ($norm === '' || !is_numeric($norm)) | ||||||
|  |     return 0; | ||||||
|  |   $cents = (int) round((float) $norm * 100); | ||||||
|  |   if ($cents < 0) | ||||||
|  |     $cents = 0; | ||||||
|  |   if ($cents > 100000000) | ||||||
|  |     $cents = 100000000; // 1 Mio € | ||||||
|  |   return $cents; | ||||||
|  | } | ||||||
|  | function is_valid_http_url(string $url): bool | ||||||
|  | { | ||||||
|  |   if (!filter_var($url, FILTER_VALIDATE_URL)) | ||||||
|  |     return false; | ||||||
|  |   $p = parse_url($url); | ||||||
|  |   if (!$p || empty($p['scheme']) || empty($p['host'])) | ||||||
|  |     return false; | ||||||
|  |   $s = strtolower($p['scheme']); | ||||||
|  |   return $s === 'http' || $s === 'https'; | ||||||
|  | } | ||||||
|  | function ip_in_cidr(string $ip, string $cidr): bool | ||||||
|  | { | ||||||
|  |   if (strpos($cidr, ':') !== false) { | ||||||
|  |     [$subnet, $mask] = array_pad(explode('/', $cidr, 2), 2, null); | ||||||
|  |     $mask = (int) $mask; | ||||||
|  |     $binIp = inet_pton($ip); | ||||||
|  |     $binSubnet = inet_pton($subnet); | ||||||
|  |     if ($binIp === false || $binSubnet === false) | ||||||
|  |       return false; | ||||||
|  |     $bytes = intdiv($mask, 8); | ||||||
|  |     $bits = $mask % 8; | ||||||
|  |     if ($bytes && substr($binIp, 0, $bytes) !== substr($binSubnet, 0, $bytes)) | ||||||
|  |       return false; | ||||||
|  |     if ($bits) { | ||||||
|  |       $b1 = ord($binIp[$bytes]) & (0xFF << (8 - $bits)); | ||||||
|  |       $b2 = ord($binSubnet[$bytes]) & (0xFF << (8 - $bits)); | ||||||
|  |       return $b1 === $b2; | ||||||
|  |     } | ||||||
|  |     return true; | ||||||
|  |   } else { | ||||||
|  |     [$subnet, $mask] = array_pad(explode('/', $cidr, 2), 2, null); | ||||||
|  |     $mask = (int) $mask; | ||||||
|  |     $ipL = ip2long($ip); | ||||||
|  |     $subL = ip2long($subnet); | ||||||
|  |     if ($ipL === false || $subL === false) | ||||||
|  |       return false; | ||||||
|  |     $maskL = -1 << (32 - $mask); | ||||||
|  |     return (($ipL & $maskL) === ($subL & $maskL)); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | function is_private_ip(string $ip): bool | ||||||
|  | { | ||||||
|  |   if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { | ||||||
|  |     foreach (['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '127.0.0.0/8', '169.254.0.0/16'] as $c) | ||||||
|  |       if (ip_in_cidr($ip, $c)) | ||||||
|  |         return true; | ||||||
|  |   } elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { | ||||||
|  |     foreach (['::1/128', 'fc00::/7', 'fe80::/10'] as $c) | ||||||
|  |       if (ip_in_cidr($ip, $c)) | ||||||
|  |         return true; | ||||||
|  |   } | ||||||
|  |   return false; | ||||||
|  | } | ||||||
|  | function validate_remote_host_not_private(string $url): void | ||||||
|  | { | ||||||
|  |   $p = parse_url($url); | ||||||
|  |   if (!$p || empty($p['host'])) | ||||||
|  |     fail('Ungültige URL', 400); | ||||||
|  |   $host = $p['host']; | ||||||
|  |   global $image_host_whitelist; | ||||||
|  |   if (isset($image_host_whitelist) && is_array($image_host_whitelist) && count($image_host_whitelist) > 0) { | ||||||
|  |     $ok = false; | ||||||
|  |     foreach ($image_host_whitelist as $allowed) { | ||||||
|  |       if (strcasecmp($host, $allowed) === 0) { | ||||||
|  |         $ok = true; | ||||||
|  |         break; | ||||||
|  |       } | ||||||
|  |       if (preg_match('/\.' . preg_quote($allowed, '/') . '$/i', $host)) { | ||||||
|  |         $ok = true; | ||||||
|  |         break; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     if (!$ok) | ||||||
|  |       fail('Host nicht erlaubt', 400); | ||||||
|  |   } | ||||||
|  |   $recs = dns_get_record($host, DNS_A + DNS_AAAA); | ||||||
|  |   if (!$recs || !count($recs)) | ||||||
|  |     fail('Host nicht auflösbar', 400); | ||||||
|  |   foreach ($recs as $r) { | ||||||
|  |     $ip = $r['type'] === 'A' ? ($r['ip'] ?? null) : ($r['ipv6'] ?? null); | ||||||
|  |     if (!$ip) | ||||||
|  |       continue; | ||||||
|  |     if (is_private_ip($ip)) | ||||||
|  |       fail('Zieladresse unzulässig', 400); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | function download_remote_image_limited(string $url, int $maxBytes = 5_000_000, int $timeout = 8): string | ||||||
|  | { | ||||||
|  |   $tmp = tempnam(sys_get_temp_dir(), 'wlimg_'); | ||||||
|  |   if ($tmp === false) | ||||||
|  |     fail('Temp-Datei Fehler', 500); | ||||||
|  |   $fh = fopen($tmp, 'wb'); | ||||||
|  |   if ($fh === false) { | ||||||
|  |     @unlink($tmp); | ||||||
|  |     fail('Temp-Datei Fehler', 500); | ||||||
|  |   } | ||||||
|  |   $ch = curl_init($url); | ||||||
|  |   if ($ch === false) { | ||||||
|  |     fclose($fh); | ||||||
|  |     @unlink($tmp); | ||||||
|  |     fail('Download Fehler', 500); | ||||||
|  |   } | ||||||
|  |   $received = 0; | ||||||
|  |   curl_setopt_array($ch, [ | ||||||
|  |     CURLOPT_FOLLOWLOCATION => true, | ||||||
|  |     CURLOPT_MAXREDIRS => 3, | ||||||
|  |     CURLOPT_CONNECTTIMEOUT => 3, | ||||||
|  |     CURLOPT_TIMEOUT => $timeout, | ||||||
|  |     CURLOPT_USERAGENT => 'wishlist/1.0', | ||||||
|  |     CURLOPT_SSL_VERIFYPEER => true, | ||||||
|  |     CURLOPT_SSL_VERIFYHOST => 2, | ||||||
|  |     CURLOPT_WRITEFUNCTION => function ($ch, $data) use (&$received, $maxBytes, $fh) { | ||||||
|  |       $len = strlen($data); | ||||||
|  |       $received += $len; | ||||||
|  |       if ($received > $maxBytes) | ||||||
|  |         return 0; | ||||||
|  |       return fwrite($fh, $data); | ||||||
|  |     } | ||||||
|  |   ]); | ||||||
|  |   $ok = curl_exec($ch); | ||||||
|  |   $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); | ||||||
|  |   curl_close($ch); | ||||||
|  |   fclose($fh); | ||||||
|  |   if (!$ok || $code < 200 || $code >= 300) { | ||||||
|  |     @unlink($tmp); | ||||||
|  |     fail('Bild-Download fehlgeschlagen', 400); | ||||||
|  |   } | ||||||
|  |   return $tmp; | ||||||
|  | } | ||||||
|  | function safe_image_filename_from_url(string $url): string | ||||||
|  | { | ||||||
|  |   $stripped = strtok($url, '?#'); | ||||||
|  |   $ext = strtolower(pathinfo((string) $stripped, PATHINFO_EXTENSION)); | ||||||
|  |   if (!preg_match('/^[a-z0-9]{1,5}$/i', $ext)) | ||||||
|  |     $ext = 'jpg'; | ||||||
|  |   return bin2hex(random_bytes(10)) . '.' . $ext; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* ============= Controller ============= */ | ||||||
|  |  | ||||||
|  | require_csrf(); | ||||||
|  | $sessionListId = require_logged_in(); | ||||||
|  |  | ||||||
|  | $action = strtolower(trim((string) ($_POST['action'] ?? ''))); | ||||||
|  | $ItemTitle = trim((string) ($_POST['ItemTitle'] ?? '')); | ||||||
|  | $ItemDescription = trim((string) ($_POST['ItemDescription'] ?? '')); | ||||||
|  | $ItemPrice = parse_price_to_cents((string) ($_POST['ItemPrice'] ?? '')); | ||||||
|  | $ItemQty = isset($_POST['ItemQty']) ? max(1, (int) $_POST['ItemQty']) : 1; | ||||||
|  | $ItemLink = trim((string) ($_POST['ItemLink'] ?? '')); | ||||||
|  | $ItemImageUrl = trim((string) ($_POST['ItemImage'] ?? ''));   // optional URL zum Pull | ||||||
|  | $ListUUID = trim((string) ($_POST['ItemListUUID'] ?? '')); | ||||||
|  | $sortbyTransfer = (string) ($_POST['sortby_transfer'] ?? 'priority'); | ||||||
|  |  | ||||||
|  | $removeImage = isset($_POST['RemoveImage']) && ($_POST['RemoveImage'] === '1'); | ||||||
|  | $WhishID = isset($_POST['WhishID']) ? (int) $_POST['WhishID'] : 0; // bei edit | ||||||
|  |  | ||||||
|  | if (!preg_match('/^[0-9a-fA-F-]{32,36}$/', $ListUUID)) | ||||||
|  |   fail('Liste nicht autorisiert', 403); | ||||||
|  |  | ||||||
|  | $conn = db(); | ||||||
|  |  | ||||||
|  | /* UUID -> int ID */ | ||||||
|  | $stmt = $conn->prepare('SELECT ID FROM lists WHERE uuid = ?'); | ||||||
|  | $stmt->bind_param('s', $ListUUID); | ||||||
|  | $stmt->execute(); | ||||||
|  | $res = $stmt->get_result(); | ||||||
|  | if (!$res || !($row = $res->fetch_assoc())) { | ||||||
|  |   $stmt->close(); | ||||||
|  |   $conn->close(); | ||||||
|  |   fail('Liste nicht autorisiert', 403); | ||||||
|  | } | ||||||
|  | $ListID = (int) $row['ID']; | ||||||
|  | $stmt->close(); | ||||||
|  |  | ||||||
|  | /* Session muss zu dieser Liste gehören */ | ||||||
|  | if ($ListID !== $sessionListId) { | ||||||
|  |   $conn->close(); | ||||||
|  |   fail('Liste nicht autorisiert', 403); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Validierungen (add & edit) */ | ||||||
|  | if ($ItemTitle === '') | ||||||
|  |   fail('Titel fehlt', 400); | ||||||
|  | if ($ItemLink !== '' && !is_valid_http_url($ItemLink)) | ||||||
|  |   fail('Ungültiger Angebotslink', 400); | ||||||
|  |  | ||||||
|  | /* Optional: Bild von externer URL holen */ | ||||||
|  | $imageLocalLink = null; | ||||||
|  | if (!$removeImage && $ItemImageUrl !== '') { | ||||||
|  |   if (!is_valid_http_url($ItemImageUrl)) { | ||||||
|  |     $conn->close(); | ||||||
|  |     fail('Ungültiger Bildlink', 400); | ||||||
|  |   } | ||||||
|  |   validate_remote_host_not_private($ItemImageUrl); | ||||||
|  |   $tmp = download_remote_image_limited($ItemImageUrl, 5_000_000, 8); | ||||||
|  |   $info = @getimagesize($tmp); | ||||||
|  |   if ($info === false || empty($info['mime']) || stripos($info['mime'], 'image/') !== 0) { | ||||||
|  |     @unlink($tmp); | ||||||
|  |     $conn->close(); | ||||||
|  |     fail('Link ist kein gültiges Bild', 400); | ||||||
|  |   } | ||||||
|  |   global $imagedir; | ||||||
|  |   if (!is_dir($imagedir)) { | ||||||
|  |     @mkdir($imagedir, 0755, true); | ||||||
|  |   } | ||||||
|  |   $filename = safe_image_filename_from_url($ItemImageUrl); | ||||||
|  |   $target = rtrim($imagedir, '/') . '/' . $filename; | ||||||
|  |  | ||||||
|  |   if (!@rename($tmp, $target)) { | ||||||
|  |     // Fallback falls rename scheitert | ||||||
|  |     if (!@copy($tmp, $target)) { | ||||||
|  |       @unlink($tmp); | ||||||
|  |       $conn->close(); | ||||||
|  |       fail('Bildspeicherung fehlgeschlagen', 500); | ||||||
|  |     } | ||||||
|  |     @unlink($tmp); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // HIER: Permissions fixen | ||||||
|  |   @chmod($target, 0644); | ||||||
|  |  | ||||||
|  |   $imageLocalLink = $filename; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* ====== ADD ====== */ | ||||||
|  | if ($action === 'add') { | ||||||
|  |   // nächste Priority ermitteln | ||||||
|  |   $next = 1; | ||||||
|  |   $s = $conn->prepare('SELECT COALESCE(MAX(priority),0) AS maxp FROM wishes WHERE wishlist = ?'); | ||||||
|  |   $s->bind_param('i', $ListID); | ||||||
|  |   $s->execute(); | ||||||
|  |   $r = $s->get_result(); | ||||||
|  |   if ($r && $m = $r->fetch_assoc()) | ||||||
|  |     $next = ((int) $m['maxp']) + 1; | ||||||
|  |   $s->close(); | ||||||
|  |  | ||||||
|  |   $stmt = $conn->prepare(' | ||||||
|  |   INSERT INTO wishes (title, description, link, image, price, wishlist, priority, qty) | ||||||
|  |   VALUES (?, ?, ?, ?, ?, ?, ?, ?) | ||||||
|  | '); | ||||||
|  |   $link = $ItemLink !== '' ? $ItemLink : null; | ||||||
|  |   $image = $imageLocalLink !== null ? $imageLocalLink : null; | ||||||
|  |   $stmt->bind_param( | ||||||
|  |     'ssssiiii', | ||||||
|  |     $ItemTitle, | ||||||
|  |     $ItemDescription, | ||||||
|  |     $link, | ||||||
|  |     $image, | ||||||
|  |     $ItemPrice, | ||||||
|  |     $ListID, | ||||||
|  |     $next, | ||||||
|  |     $ItemQty | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   if (!$stmt->execute()) { | ||||||
|  |     $stmt->close(); | ||||||
|  |     $conn->close(); | ||||||
|  |     fail('Speichern fehlgeschlagen', 500); | ||||||
|  |   } | ||||||
|  |   $stmt->close(); | ||||||
|  | } | ||||||
|  | /* ====== EDIT ====== */ elseif ($action === 'edit') { | ||||||
|  |   if ($WhishID <= 0) { | ||||||
|  |     $conn->close(); | ||||||
|  |     fail('Ungültige Item-ID', 400); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Besitz prüfen & altes Bild holen | ||||||
|  |   $stmt = $conn->prepare('SELECT image FROM wishes WHERE ID = ? AND wishlist = ?'); | ||||||
|  |   $stmt->bind_param('ii', $WhishID, $ListID); | ||||||
|  |   $stmt->execute(); | ||||||
|  |   $res = $stmt->get_result(); | ||||||
|  |   if (!$res || !($row = $res->fetch_assoc())) { | ||||||
|  |     $stmt->close(); | ||||||
|  |     $conn->close(); | ||||||
|  |     fail('Item nicht gefunden/autorisiert', 404); | ||||||
|  |   } | ||||||
|  |   $oldImage = (string) $row['image']; | ||||||
|  |   $stmt->close(); | ||||||
|  |  | ||||||
|  |   // Bild-Entscheidung: entfernen, ersetzen oder behalten | ||||||
|  |   $newImage = $oldImage; | ||||||
|  |   if ($removeImage) { | ||||||
|  |     if (!empty($oldImage)) { | ||||||
|  |       global $imagedir; | ||||||
|  |       $full = rtrim($imagedir, '/') . '/' . $oldImage; | ||||||
|  |       if (is_file($full)) | ||||||
|  |         @unlink($full); | ||||||
|  |     } | ||||||
|  |     $newImage = ''; | ||||||
|  |   } elseif ($imageLocalLink !== null) { | ||||||
|  |     if (!empty($oldImage)) { | ||||||
|  |       global $imagedir; | ||||||
|  |       $full = rtrim($imagedir, '/') . '/' . $oldImage; | ||||||
|  |       if (is_file($full)) | ||||||
|  |         @unlink($full); | ||||||
|  |     } | ||||||
|  |     $newImage = $imageLocalLink; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   $link = $ItemLink !== '' ? $ItemLink : null; | ||||||
|  |   $imgNullable = ($newImage !== '') ? $newImage : null; | ||||||
|  |  | ||||||
|  |   $stmt = $conn->prepare( | ||||||
|  |     'UPDATE wishes SET title=?, description=?, link=?, image=?, price=?, qty=? WHERE ID=? AND wishlist=?' | ||||||
|  |   ); | ||||||
|  |   $stmt->bind_param( | ||||||
|  |     'sssssiii', | ||||||
|  |     $ItemTitle, | ||||||
|  |     $ItemDescription, | ||||||
|  |     $link, | ||||||
|  |     $imgNullable, | ||||||
|  |     $ItemPrice, | ||||||
|  |     $ItemQty, | ||||||
|  |     $WhishID, | ||||||
|  |     $ListID | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   if (!$stmt->execute()) { | ||||||
|  |     $stmt->close(); | ||||||
|  |     $conn->close(); | ||||||
|  |     fail('Update fehlgeschlagen', 500); | ||||||
|  |   } | ||||||
|  |   $stmt->close(); | ||||||
|  | } | ||||||
|  | /* ====== Unbekannt ====== */ else { | ||||||
|  |   $conn->close(); | ||||||
|  |   fail('Unbekannte Aktion', 400); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | $conn->close(); | ||||||
|  |  | ||||||
|  | /* Redirect zurück (sicher, mit UUID) */ | ||||||
|  | $redirect = (string) ($_SERVER['HTTP_REFERER'] ?? ''); | ||||||
|  | if ($redirect === '' || stripos($redirect, 'http') !== 0) { | ||||||
|  |   $host = $_SERVER['HTTP_HOST'] ?? ''; | ||||||
|  |   $scheme = $secure ? 'https' : 'http'; | ||||||
|  |   $qs = '?list=' . urlencode($ListUUID); | ||||||
|  |   if ($sortbyTransfer !== '') | ||||||
|  |     $qs .= '&sort=' . urlencode($sortbyTransfer); | ||||||
|  |   $redirect = $scheme . '://' . $host . '/' . $qs; | ||||||
|  | } | ||||||
|  | header('Location: ' . $redirect, true, 303); | ||||||
|  | exit; | ||||||
							
								
								
									
										7
									
								
								js/bootstrap.bundle.min.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										1
									
								
								js/bootstrap.bundle.min.js.map
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										2
									
								
								js/jquery.min.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										151
									
								
								js/wishlist.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,151 @@ | |||||||
|  | // js/wishlist.js | ||||||
|  | (function () { | ||||||
|  |   "use strict"; | ||||||
|  |  | ||||||
|  |   // Sort direkt submitten | ||||||
|  |   const sortSel = document.getElementById("sortby"); | ||||||
|  |   if (sortSel && sortSel.form) { | ||||||
|  |     sortSel.addEventListener("change", function () { | ||||||
|  |       this.form.submit(); | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Reservation Modal | ||||||
|  |   const reservationModal = document.getElementById("reservationModal"); | ||||||
|  |   if (reservationModal) { | ||||||
|  |     reservationModal.addEventListener("show.bs.modal", function (ev) { | ||||||
|  |       const btn = ev.relatedTarget; | ||||||
|  |       if (!btn) return; | ||||||
|  |       const wishid = btn.getAttribute("data-wishid"); | ||||||
|  |       const reserved = btn.getAttribute("data-reserved"); | ||||||
|  |  | ||||||
|  |       reservationModal.querySelector("#modal-wishid").value = wishid || ""; | ||||||
|  |       reservationModal.querySelector("#modal-reservedstat").value = | ||||||
|  |         reserved || ""; | ||||||
|  |  | ||||||
|  |       const submitBtn = reservationModal.querySelector("#reservation-submit"); | ||||||
|  |       const titleEl = reservationModal.querySelector("#reservationModalLabel"); | ||||||
|  |       const infoEl = reservationModal.querySelector("#ReservationInfoText"); | ||||||
|  |  | ||||||
|  |       if (reserved === "1") { | ||||||
|  |         submitBtn.textContent = "Reservierung aufheben"; | ||||||
|  |         titleEl.textContent = "Reservierung aufheben"; | ||||||
|  |         if (infoEl) infoEl.style.display = "none"; | ||||||
|  |       } else { | ||||||
|  |         submitBtn.textContent = "Reservieren"; | ||||||
|  |         titleEl.textContent = "Wunsch reservieren"; | ||||||
|  |         if (infoEl) infoEl.style.display = ""; | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Delete Modal | ||||||
|  |   const deleteModal = document.getElementById("deleteModal"); | ||||||
|  |   if (deleteModal) { | ||||||
|  |     deleteModal.addEventListener("show.bs.modal", function (ev) { | ||||||
|  |       const btn = ev.relatedTarget; | ||||||
|  |       if (!btn) return; | ||||||
|  |       const card = btn.closest(".card"); | ||||||
|  |       const title = card | ||||||
|  |         ? card.querySelector(".card-title")?.textContent?.trim() || "" | ||||||
|  |         : ""; | ||||||
|  |       const wishid = btn.getAttribute("data-wishid") || ""; | ||||||
|  |  | ||||||
|  |       // robust: suche entweder #DelWhishID ODER #WhishID | ||||||
|  |       const idInput = deleteModal.querySelector( | ||||||
|  |         '#DelWhishID, #WhishID, input[name="WhishID"]' | ||||||
|  |       ); | ||||||
|  |       const titleEl = deleteModal.querySelector( | ||||||
|  |         "#del-whish-title, #whish-title" | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       if (idInput) idInput.value = wishid; | ||||||
|  |       if (titleEl) titleEl.textContent = title || "Wunsch"; | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Push Prio Modal | ||||||
|  |   const prioModal = document.getElementById("pushprioModal"); | ||||||
|  |   if (prioModal) { | ||||||
|  |     prioModal.addEventListener("show.bs.modal", function (ev) { | ||||||
|  |       const btn = ev.relatedTarget; | ||||||
|  |       if (!btn) return; | ||||||
|  |       const card = btn.closest(".card"); | ||||||
|  |       const title = card | ||||||
|  |         ? card.querySelector(".card-title")?.textContent?.trim() || "" | ||||||
|  |         : ""; | ||||||
|  |       const wishid = btn.getAttribute("data-wishid") || ""; | ||||||
|  |  | ||||||
|  |       const idInput = prioModal.querySelector( | ||||||
|  |         '#PrioWhishID, #WhishID, input[name="WhishID"]' | ||||||
|  |       ); | ||||||
|  |       const titleEl = prioModal.querySelector( | ||||||
|  |         "#prio-whish-title, #whish-title" | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       if (idInput) idInput.value = wishid; | ||||||
|  |       if (titleEl) titleEl.textContent = title || "Wunsch"; | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Add/Edit Item Modal | ||||||
|  |   const itemModal = document.getElementById("itemModal"); | ||||||
|  |   if (itemModal) { | ||||||
|  |     itemModal.addEventListener("show.bs.modal", function (ev) { | ||||||
|  |       const btn = ev.relatedTarget; | ||||||
|  |       if (!btn) return; | ||||||
|  |       const mode = btn.getAttribute("data-mode") || "add"; | ||||||
|  |  | ||||||
|  |       const titleEl = itemModal.querySelector("#itemModalTitle"); | ||||||
|  |       const actionEl = itemModal.querySelector("#ItemAction"); | ||||||
|  |       const submitEl = itemModal.querySelector("#ItemSubmitBtn"); | ||||||
|  |       const removeWrap = itemModal.querySelector("#RemoveImageWrap"); | ||||||
|  |       const removeChk = itemModal.querySelector("#RemoveImage"); | ||||||
|  |  | ||||||
|  |       // Felder | ||||||
|  |       const fTitle = itemModal.querySelector("#ItemTitle"); | ||||||
|  |       const fDesc = itemModal.querySelector("#ItemDescription"); | ||||||
|  |       const fPrice = itemModal.querySelector("#ItemPrice"); | ||||||
|  |       const fLink = itemModal.querySelector("#ItemLink"); | ||||||
|  |       const fImg = itemModal.querySelector("#ItemImage"); | ||||||
|  |       const fId = itemModal.querySelector("#WhishID"); | ||||||
|  |       const fQty = itemModal.querySelector("#ItemQty"); // <-- NEU | ||||||
|  |  | ||||||
|  |       if (mode === "edit") { | ||||||
|  |         titleEl.textContent = "Wunsch bearbeiten"; | ||||||
|  |         actionEl.value = "edit"; | ||||||
|  |         submitEl.textContent = "Speichern"; | ||||||
|  |         if (removeWrap) removeWrap.style.display = ""; | ||||||
|  |         if (removeChk) removeChk.checked = false; | ||||||
|  |  | ||||||
|  |         const wishid = btn.getAttribute("data-wishid") || "-1"; | ||||||
|  |         const dTitle = btn.getAttribute("data-title") || ""; | ||||||
|  |         const dDesc = btn.getAttribute("data-description") || ""; | ||||||
|  |         const dPrice = btn.getAttribute("data-price") || ""; | ||||||
|  |         const dLink = btn.getAttribute("data-link") || ""; | ||||||
|  |         const dQty = btn.getAttribute("data-qty") || "1"; // <-- NEU | ||||||
|  |  | ||||||
|  |         if (fId) fId.value = wishid; | ||||||
|  |         if (fTitle) fTitle.value = dTitle; | ||||||
|  |         if (fDesc) fDesc.value = dDesc; | ||||||
|  |         if (fPrice) fPrice.value = dPrice; | ||||||
|  |         if (fLink) fLink.value = dLink; | ||||||
|  |         if (fImg) fImg.value = ""; | ||||||
|  |         if (fQty) fQty.value = dQty; // <-- NEU | ||||||
|  |       } else { | ||||||
|  |         titleEl.textContent = "Wunsch hinzufügen"; | ||||||
|  |         actionEl.value = "add"; | ||||||
|  |         submitEl.textContent = "Hinzufügen"; | ||||||
|  |         if (fId) fId.value = "-1"; | ||||||
|  |         if (fTitle) fTitle.value = ""; | ||||||
|  |         if (fDesc) fDesc.value = ""; | ||||||
|  |         if (fPrice) fPrice.value = ""; | ||||||
|  |         if (fLink) fLink.value = ""; | ||||||
|  |         if (fImg) fImg.value = ""; | ||||||
|  |         if (fQty) fQty.value = "1"; // <-- NEU | ||||||
|  |         if (removeWrap) removeWrap.style.display = "none"; | ||||||
|  |         if (removeChk) removeChk.checked = false; | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | })(); | ||||||
							
								
								
									
										143
									
								
								reservations.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,143 @@ | |||||||
|  | <?php | ||||||
|  | declare(strict_types=1); | ||||||
|  |  | ||||||
|  | /* ========= Session & Bootstrap ========= */ | ||||||
|  | $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'); | ||||||
|  | session_set_cookie_params([ | ||||||
|  |   'lifetime'=>0,'path'=>'/','secure'=>$secure,'httponly'=>true,'samesite'=>'Lax', | ||||||
|  | ]); | ||||||
|  | session_name('WLISTSESSID'); | ||||||
|  | session_start(); | ||||||
|  | umask(0022); // neue Dateien: 0644, Ordner: 0755 | ||||||
|  |  | ||||||
|  | require_once __DIR__ . '/config/config.php'; | ||||||
|  |  | ||||||
|  | /* ===== Debug Toggle (wie item.php) ===== */ | ||||||
|  | if (!empty($app_debug)) { | ||||||
|  |   ini_set('display_errors','1'); ini_set('display_startup_errors','1'); ini_set('log_errors','1'); | ||||||
|  |   if (!is_dir(__DIR__.'/logs')) { @mkdir(__DIR__.'/logs',0750,true); } | ||||||
|  |   ini_set('error_log', __DIR__.'/logs/php-error.log'); error_reporting(E_ALL); | ||||||
|  | } else { | ||||||
|  |   ini_set('display_errors','0'); ini_set('display_startup_errors','0'); ini_set('log_errors','1'); | ||||||
|  |   if (!is_dir(__DIR__.'/logs')) { @mkdir(__DIR__.'/logs',0750,true); } | ||||||
|  |   ini_set('error_log', __DIR__.'/logs/php-error.log'); error_reporting(E_ALL); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* ============= Helpers ============= */ | ||||||
|  | function fail(string $msg, int $code=400): void { | ||||||
|  |   http_response_code($code); | ||||||
|  |   // kleine Flash-Message für index.php | ||||||
|  |   $_SESSION['flash'] = ['msg'=>$msg,'type'=> ($code>=400?'danger':'success')]; | ||||||
|  |   safe_redirect_back(); | ||||||
|  | } | ||||||
|  | function db(): mysqli { | ||||||
|  |   global $servername, $username, $password, $db; | ||||||
|  |   $conn = new mysqli($servername, $username, $password, $db); | ||||||
|  |   if ($conn->connect_error) fail('Interner Fehler (DB)', 500); | ||||||
|  |   $conn->set_charset('utf8mb4'); | ||||||
|  |   return $conn; | ||||||
|  | } | ||||||
|  | function require_csrf(): void { | ||||||
|  |   $t = (string)($_POST['csrf'] ?? ''); | ||||||
|  |   if (empty($_SESSION['csrf']) || !hash_equals($_SESSION['csrf'], $t)) { | ||||||
|  |     fail('Ungültiges CSRF-Token', 403); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | function e(string $s): string { return htmlspecialchars($s, ENT_QUOTES|ENT_SUBSTITUTE, 'UTF-8'); } | ||||||
|  | function safe_redirect_back(): void { | ||||||
|  |   $ref = (string)($_SERVER['HTTP_REFERER'] ?? ''); | ||||||
|  |   if ($ref === '' || stripos($ref, 'http') !== 0) { | ||||||
|  |     $host = $_SERVER['HTTP_HOST'] ?? ''; | ||||||
|  |     $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; | ||||||
|  |     header('Location: '.$scheme.'://'.$host.'/', true, 303); | ||||||
|  |   } else { | ||||||
|  |     header('Location: '.$ref, true, 303); | ||||||
|  |   } | ||||||
|  |   exit; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* ============= Controller ============= */ | ||||||
|  |  | ||||||
|  | if ($_SERVER['REQUEST_METHOD'] !== 'POST') { | ||||||
|  |   fail('Methode nicht erlaubt', 405); | ||||||
|  | } | ||||||
|  | require_csrf(); | ||||||
|  |  | ||||||
|  | $wishId       = (int)($_POST['wishid'] ?? -1); | ||||||
|  | $pw           = (string)($_POST['WishPassword'] ?? ''); | ||||||
|  | $reservedstat = (int)($_POST['reservedstat'] ?? 0); // 0 = setzen, 1 = aufheben | ||||||
|  |  | ||||||
|  | if ($wishId <= 0) fail('Ungültige Wunsch-ID', 400); | ||||||
|  | if ($pw === '')   fail('Passwort erforderlich', 400); | ||||||
|  |  | ||||||
|  | $conn = db(); | ||||||
|  |  | ||||||
|  | /* --- qty des Wunsch + bestehende Reservierungen zählen --- */ | ||||||
|  | $qty = 1; | ||||||
|  | $stmt = $conn->prepare('SELECT qty FROM wishes WHERE ID = ?'); | ||||||
|  | $stmt->bind_param('i', $wishId); | ||||||
|  | $stmt->execute(); | ||||||
|  | $res = $stmt->get_result(); | ||||||
|  | if (!$res || !($row = $res->fetch_assoc())) { | ||||||
|  |   $stmt->close(); $conn->close(); fail('Wunsch nicht gefunden', 404); | ||||||
|  | } | ||||||
|  | $qty = max(1, (int)$row['qty']); | ||||||
|  | $stmt->close(); | ||||||
|  |  | ||||||
|  | /* Existierende Reservierungen zählen */ | ||||||
|  | $cnt = 0; | ||||||
|  | $stmt = $conn->prepare('SELECT COUNT(*) AS c FROM wishes_reservations WHERE wish_id = ?'); | ||||||
|  | $stmt->bind_param('i',$wishId); | ||||||
|  | $stmt->execute(); | ||||||
|  | $res = $stmt->get_result(); | ||||||
|  | if ($res && ($row = $res->fetch_assoc())) $cnt = (int)$row['c']; | ||||||
|  | $stmt->close(); | ||||||
|  |  | ||||||
|  | /* --- Operationen --- */ | ||||||
|  | if ($reservedstat === 0) { | ||||||
|  |   // setzen | ||||||
|  |   if ($cnt >= $qty) { | ||||||
|  |     $conn->close(); | ||||||
|  |     fail('Für diesen Wunsch sind bereits alle Exemplare reserviert.', 409); | ||||||
|  |   } | ||||||
|  |   $hash = password_hash($pw, PASSWORD_BCRYPT); | ||||||
|  |   $ins = $conn->prepare('INSERT INTO wishes_reservations (wish_id, pass_hash, created_at) VALUES (?, ?, NOW())'); | ||||||
|  |   $ins->bind_param('is', $wishId, $hash); | ||||||
|  |   if (!$ins->execute()) { | ||||||
|  |     $ins->close(); $conn->close(); | ||||||
|  |     fail('Reservierung fehlgeschlagen', 500); | ||||||
|  |   } | ||||||
|  |   $ins->close(); | ||||||
|  |   $conn->close(); | ||||||
|  |   $_SESSION['flash'] = ['msg'=>'Reservierung eingetragen','type'=>'success']; | ||||||
|  |   safe_redirect_back(); | ||||||
|  |  | ||||||
|  | } else { | ||||||
|  |   // aufheben: passenden Hash suchen und genau einen Eintrag löschen | ||||||
|  |   $sel = $conn->prepare('SELECT id, pass_hash FROM wishes_reservations WHERE wish_id = ?'); | ||||||
|  |   $sel->bind_param('i',$wishId); | ||||||
|  |   $sel->execute(); | ||||||
|  |   $res = $sel->get_result(); | ||||||
|  |  | ||||||
|  |   $deleted = false; | ||||||
|  |   while ($row = $res->fetch_assoc()) { | ||||||
|  |     $rid  = (int)$row['id']; | ||||||
|  |     $rhash= (string)$row['pass_hash']; | ||||||
|  |     if (password_verify($pw, $rhash)) { | ||||||
|  |       $del = $conn->prepare('DELETE FROM wishes_reservations WHERE id = ? LIMIT 1'); | ||||||
|  |       $del->bind_param('i', $rid); | ||||||
|  |       $deleted = $del->execute(); | ||||||
|  |       $del->close(); | ||||||
|  |       break; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |   $sel->close(); | ||||||
|  |   $conn->close(); | ||||||
|  |  | ||||||
|  |   if ($deleted) { | ||||||
|  |     $_SESSION['flash'] = ['msg'=>'Reservierung aufgehoben','type'=>'success']; | ||||||
|  |   } else { | ||||||
|  |     $_SESSION['flash'] = ['msg'=>'Kein passender Reservierungseintrag gefunden (Passwort korrekt?)','type'=>'warning']; | ||||||
|  |   } | ||||||
|  |   safe_redirect_back(); | ||||||
|  | } | ||||||
							
								
								
									
										
											BIN
										
									
								
								webfonts/comfortaa.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								webfonts/fa-brands-400.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								webfonts/fa-regular-400.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								webfonts/fa-solid-900.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										98
									
								
								wishlist.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,98 @@ | |||||||
|  | -- ============================================================ | ||||||
|  | -- Simple Wishlist – Minimal-Schema | ||||||
|  | -- Tested on MariaDB 10.x / MySQL 8.x | ||||||
|  | -- ============================================================ | ||||||
|  |  | ||||||
|  | /* Optional: eigene DB anlegen/verwenden | ||||||
|  | CREATE DATABASE IF NOT EXISTS `wishlist` | ||||||
|  |   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`; | ||||||
|  |  | ||||||
|  | -- ------------------------------------------------------------ | ||||||
|  | -- LISTEN (Metadaten & Admin-Passwort) | ||||||
|  | -- ------------------------------------------------------------ | ||||||
|  | CREATE TABLE `lists` ( | ||||||
|  |   `ID`          INT(11)      NOT NULL AUTO_INCREMENT, | ||||||
|  |   `uuid`        CHAR(36)     NOT NULL,               -- öffentliche, stabile ID (Links) | ||||||
|  |   `title`       VARCHAR(255) NOT NULL, | ||||||
|  |   `description` TEXT         NOT NULL, | ||||||
|  |   `edit_pw`     VARCHAR(255) NOT NULL,               -- Passwort-Hash (password_hash) | ||||||
|  |   PRIMARY KEY (`ID`), | ||||||
|  |   UNIQUE KEY `uq_lists_uuid` (`uuid`) | ||||||
|  | ) ENGINE=InnoDB | ||||||
|  |   DEFAULT CHARSET = utf8mb4 | ||||||
|  |   COLLATE = utf8mb4_unicode_ci; | ||||||
|  |  | ||||||
|  | -- ------------------------------------------------------------ | ||||||
|  | -- WÜNSCHE (einer Liste zugeordnet) | ||||||
|  | -- ------------------------------------------------------------ | ||||||
|  | CREATE TABLE `wishes` ( | ||||||
|  |   `ID`        INT(11)      NOT NULL AUTO_INCREMENT, | ||||||
|  |   `wishlist`  INT(11)      NOT NULL,                 -- FK -> lists.ID | ||||||
|  |   `title`     VARCHAR(128) NOT NULL, | ||||||
|  |   `description` TEXT       NOT NULL, | ||||||
|  |   `link`      TEXT         NOT NULL,                 -- optionaler Anbieter-Link (leer erlaubt) | ||||||
|  |   `image`     TEXT         DEFAULT NULL,             -- Dateiname (lokal) oder NULL | ||||||
|  |   `price`     INT(11)      NOT NULL DEFAULT 0,       -- Preis in Cent | ||||||
|  |   `date`      DATE         NOT NULL DEFAULT (CURRENT_DATE), | ||||||
|  |   `priority`  INT(11)      NOT NULL DEFAULT 0,       -- Sortierung (höher = weiter oben) | ||||||
|  |   `qty`       INT(11)      NOT NULL DEFAULT 1,       -- Anzahl benötigter Exemplare | ||||||
|  |  | ||||||
|  |   PRIMARY KEY (`ID`), | ||||||
|  |   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; | ||||||
|  |  | ||||||
|  | -- ------------------------------------------------------------ | ||||||
|  | -- RESERVIERUNGEN (pro Wunsch mehrere Einträge, je Reservierung einer) | ||||||
|  | -- ------------------------------------------------------------ | ||||||
|  | CREATE TABLE `wishes_reservations` ( | ||||||
|  |   `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; | ||||||
|  |  | ||||||
|  | -- ------------------------------------------------------------ | ||||||
|  | -- OPTIONALE HILFS-VIEW: reserved_count je Wunsch | ||||||
|  | -- Erleichtert das Rendern (Badge/Buttons) ohne Subquery im Code. | ||||||
|  | -- Nutzung: SELECT w.*, rc.reserved_count FROM wishes w | ||||||
|  | --          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; | ||||||
|  |  | ||||||
|  | -- ------------------------------------------------------------ | ||||||
|  | -- 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: | ||||||
|  | CREATE INDEX `idx_wishes_wishlist_date` ON `wishes` (`wishlist`, `date`); | ||||||
|  |  | ||||||
|  | -- Fertig.  | ||||||