Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
39c5cc5480
@ -1,9 +0,0 @@
|
|||||||
# AuthType Basic
|
|
||||||
# AuthName "Geschützter Bereich"
|
|
||||||
# AuthUserFile /FSST/Website/.htpasswd
|
|
||||||
# Require valid-user
|
|
||||||
#
|
|
||||||
# # .htpasswd vor Auslieferung schützen
|
|
||||||
# <Files ".htpasswd">
|
|
||||||
# Require all denied
|
|
||||||
# </Files>
|
|
||||||
37
.idea/php.xml
generated
37
.idea/php.xml
generated
@ -1,37 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="MessDetectorOptionsConfiguration">
|
|
||||||
<option name="transferred" value="true" />
|
|
||||||
</component>
|
|
||||||
<component name="PHPCSFixerOptionsConfiguration">
|
|
||||||
<option name="transferred" value="true" />
|
|
||||||
</component>
|
|
||||||
<component name="PHPCodeSnifferOptionsConfiguration">
|
|
||||||
<option name="highlightLevel" value="WARNING" />
|
|
||||||
<option name="transferred" value="true" />
|
|
||||||
</component>
|
|
||||||
<component name="PhpCodeSniffer">
|
|
||||||
<phpcs_settings>
|
|
||||||
<phpcs_by_interpreter asDefaultInterpreter="true" interpreter_id="b74b3486-711a-42ad-bf18-c51cc1addaa5" timeout="30000" />
|
|
||||||
</phpcs_settings>
|
|
||||||
</component>
|
|
||||||
<component name="PhpProjectSharedConfiguration" php_language_level="8.2">
|
|
||||||
<option name="suggestChangeDefaultLanguageLevel" value="false" />
|
|
||||||
</component>
|
|
||||||
<component name="PhpStan">
|
|
||||||
<PhpStan_settings>
|
|
||||||
<phpstan_by_interpreter asDefaultInterpreter="true" interpreter_id="b74b3486-711a-42ad-bf18-c51cc1addaa5" timeout="60000" />
|
|
||||||
</PhpStan_settings>
|
|
||||||
</component>
|
|
||||||
<component name="PhpStanOptionsConfiguration">
|
|
||||||
<option name="transferred" value="true" />
|
|
||||||
</component>
|
|
||||||
<component name="Psalm">
|
|
||||||
<Psalm_settings>
|
|
||||||
<psalm_fixer_by_interpreter asDefaultInterpreter="true" interpreter_id="b74b3486-711a-42ad-bf18-c51cc1addaa5" timeout="60000" />
|
|
||||||
</Psalm_settings>
|
|
||||||
</component>
|
|
||||||
<component name="PsalmOptionsConfiguration">
|
|
||||||
<option name="transferred" value="true" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
100
404.php
100
404.php
@ -1,27 +1,53 @@
|
|||||||
<?php
|
<?php
|
||||||
// Wichtig: echten 404-Status setzen
|
/**
|
||||||
// !!! Funktioniert nur auf geizkragen.fabianschieder.com (Weil im VirtualHost configuriert) !!!
|
* @file 404.php
|
||||||
|
* @brief Fehlerseite für nicht gefundene Dokumente (HTTP 404 Not Found)
|
||||||
|
*
|
||||||
|
* @details Diese Datei wird aufgerufen, wenn ein Benutzer eine URL aufruft,
|
||||||
|
* die auf dem Server nicht existiert. Sie sendet den korrekten HTTP-Statuscode 404 an den Browser,
|
||||||
|
* liest die angeforderte URI aus und bietet dem Benutzer benutzerfreundliche Möglichkeiten,
|
||||||
|
* zurück zur Startseite oder zur vorherigen Seite zu navigieren. Ein ansprechendes Design
|
||||||
|
* mit Glitch-Effekten macht die Fehlerseite visuell interessant.
|
||||||
|
*
|
||||||
|
* @author Fabian Schieder / Geizkragen Team
|
||||||
|
* @version 1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Wichtig: Echten 404-Status setzen, damit Suchmaschinen und Browser erkennen, dass die Seite nicht existiert.
|
||||||
|
// !!! Funktioniert in der Produktion nur zuverlässig (z.B. auf geizkragen.fabianschieder.com),
|
||||||
|
// wenn die Fehlerseite im VirtualHost oder der .htaccess entsprechend für 404-Fehler konfiguriert ist !!!
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
|
|
||||||
// Request-Daten absichern
|
// Request-Daten absichern, um XSS (Cross-Site Scripting) Angriffe bei der Ausgabe zu verhindern.
|
||||||
|
// htmlspecialchars wandelt spezielle Zeichen (<, >, &, ", ') in ungefährliche HTML-Entitäten um.
|
||||||
$requestUri = htmlspecialchars($_SERVER['REQUEST_URI'] ?? '', ENT_QUOTES, 'UTF-8');
|
$requestUri = htmlspecialchars($_SERVER['REQUEST_URI'] ?? '', ENT_QUOTES, 'UTF-8');
|
||||||
$method = htmlspecialchars($_SERVER['REQUEST_METHOD'] ?? '', ENT_QUOTES, 'UTF-8');
|
$method = htmlspecialchars($_SERVER['REQUEST_METHOD'] ?? '', ENT_QUOTES, 'UTF-8');
|
||||||
|
|
||||||
// Optional: einfaches Logging
|
// Optional: Einfaches Logging der 404-Fehler für administrative Zwecke.
|
||||||
|
// Schreibt die IP-Adresse des Clients, die HTTP-Methode und die angeforderte fehlerhafte URI in das PHP Error-Log.
|
||||||
error_log("[404] " . ($_SERVER['REMOTE_ADDR'] ?? '') . " $method $requestUri");
|
error_log("[404] " . ($_SERVER['REMOTE_ADDR'] ?? '') . " $method $requestUri");
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
|
<!-- Metadaten zur Zeichenkodierung und für ein responsives Layout -->
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|
||||||
|
<!-- Einbindung des Favicons -->
|
||||||
<link rel="icon" href="/assets/images/favicon.ico" sizes="any">
|
<link rel="icon" href="/assets/images/favicon.ico" sizes="any">
|
||||||
|
|
||||||
|
<!-- Einbindung des globalen Stylesheets -->
|
||||||
<link rel="stylesheet" href="/style.css">
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
|
||||||
|
<!-- Titel der Seite, der im Browser-Tab angezeigt wird -->
|
||||||
<title>404 – Seite nicht gefunden | Geizkragen</title>
|
<title>404 – Seite nicht gefunden | Geizkragen</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<!-- Hauptinhaltsbereich der Fehlerseite -->
|
||||||
<main>
|
<main>
|
||||||
|
<!-- Container für die Zentrierung und Ausrichtung der Inhalte -->
|
||||||
<div class="container" style="
|
<div class="container" style="
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -31,12 +57,22 @@ error_log("[404] " . ($_SERVER['REMOTE_ADDR'] ?? '') . " $method $requestUri");
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
padding-block: 4rem;
|
padding-block: 4rem;
|
||||||
">
|
">
|
||||||
<!-- 404 Glitch-Zahl -->
|
<!--
|
||||||
|
404 Glitch-Zahl:
|
||||||
|
Ein dekoratives Element, das die Zahl 404 groß und mit einem visuellen Störungs-Effekt (Glitch) darstellt.
|
||||||
|
Das Attribut aria-hidden="true" versteckt es vor Screenreadern, da es rein dekorativ ist.
|
||||||
|
-->
|
||||||
<div class="error-code" aria-hidden="true">404</div>
|
<div class="error-code" aria-hidden="true">404</div>
|
||||||
|
|
||||||
<!-- Card -->
|
<!--
|
||||||
|
Error Card:
|
||||||
|
Eine visuelle Karte, die den Text, das Icon und die Navigations-Aktionen strukturiert zusammenfasst.
|
||||||
|
-->
|
||||||
<div class="error-card">
|
<div class="error-card">
|
||||||
|
|
||||||
|
<!-- Icon-Bereich in der Fehlerkarte -->
|
||||||
<div class="error-card__icon">
|
<div class="error-card__icon">
|
||||||
|
<!-- SVG-Icon: Ein durchgestrichener Kreis zur symbolischen Darstellung eines Fehlers -->
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
stroke-linecap="round" stroke-linejoin="round" width="28" height="28">
|
stroke-linecap="round" stroke-linejoin="round" width="28" height="28">
|
||||||
<circle cx="11" cy="11" r="8"/>
|
<circle cx="11" cy="11" r="8"/>
|
||||||
@ -45,14 +81,20 @@ error_log("[404] " . ($_SERVER['REMOTE_ADDR'] ?? '') . " $method $requestUri");
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Überschrift für die Fehlerkarte -->
|
||||||
<h1 class="error-card__title">Seite nicht gefunden</h1>
|
<h1 class="error-card__title">Seite nicht gefunden</h1>
|
||||||
|
|
||||||
|
<!-- Erklärender Text für den Benutzer -->
|
||||||
<p class="error-card__text">
|
<p class="error-card__text">
|
||||||
Die Seite, die du suchst, existiert leider nicht oder wurde verschoben.
|
Die Seite, die du suchst, existiert leider nicht oder wurde verschoben.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Ausgabe des angeforderten Pfads, der zum Fehler geführt hat -->
|
||||||
<div class="error-card__path"><?php echo $requestUri; ?></div>
|
<div class="error-card__path"><?php echo $requestUri; ?></div>
|
||||||
|
|
||||||
|
<!-- Container für die Navigationsbuttons -->
|
||||||
<div class="error-card__actions">
|
<div class="error-card__actions">
|
||||||
|
<!-- Button: Zurück zur Startseite -->
|
||||||
<a href="/index.php" class="btn btn--primary">
|
<a href="/index.php" class="btn btn--primary">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
stroke-linecap="round" stroke-linejoin="round" width="18" height="18">
|
stroke-linecap="round" stroke-linejoin="round" width="18" height="18">
|
||||||
@ -61,6 +103,8 @@ error_log("[404] " . ($_SERVER['REMOTE_ADDR'] ?? '') . " $method $requestUri");
|
|||||||
</svg>
|
</svg>
|
||||||
Zur Startseite
|
Zur Startseite
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<!-- Button: Zurück zur vorherigen Seite im Browser-Verlauf -->
|
||||||
<button onclick="history.back()" class="btn btn--ghost">
|
<button onclick="history.back()" class="btn btn--ghost">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
stroke-linecap="round" stroke-linejoin="round" width="18" height="18">
|
stroke-linecap="round" stroke-linejoin="round" width="18" height="18">
|
||||||
@ -74,24 +118,36 @@ error_log("[404] " . ($_SERVER['REMOTE_ADDR'] ?? '') . " $method $requestUri");
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Inlined CSS-Bereich für das spezifische Styling der 404-Komponenten -->
|
||||||
<style>
|
<style>
|
||||||
|
/**
|
||||||
|
* @brief Styling für die 404 Glitch-Zahl
|
||||||
|
* @details Definiert eine große, animierte Textdarstellung mit einem Farbgradienten.
|
||||||
|
*/
|
||||||
/* ── 404 Glitch-Zahl ── */
|
/* ── 404 Glitch-Zahl ── */
|
||||||
.error-code {
|
.error-code {
|
||||||
font-size: clamp(7rem, 18vw, 12rem);
|
font-size: clamp(7rem, 18vw, 12rem);
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
letter-spacing: -0.04em;
|
letter-spacing: -0.04em;
|
||||||
|
/* Lineares Farbverlaufs-Hintergrundbild für den Text */
|
||||||
background: linear-gradient(135deg, var(--color-primary), #4f46e5, var(--color-accent));
|
background: linear-gradient(135deg, var(--color-primary), #4f46e5, var(--color-accent));
|
||||||
background-size: 200% 200%;
|
background-size: 200% 200%;
|
||||||
|
/* Wendet den Hintergrund nur auf den Text an (Webkit und Standard) */
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
|
/* Animation zur Verschiebung des Gradienten */
|
||||||
animation: gradientShift 4s ease-in-out infinite;
|
animation: gradientShift 4s ease-in-out infinite;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Pseudo-Elemente für den Glitch-Tearing-Effekt
|
||||||
|
* @details Erstellt Kopien des Textes, die beschnitten und verschoben werden.
|
||||||
|
*/
|
||||||
.error-code::before,
|
.error-code::before,
|
||||||
.error-code::after {
|
.error-code::after {
|
||||||
content: '404';
|
content: '404';
|
||||||
@ -104,16 +160,19 @@ error_log("[404] " . ($_SERVER['REMOTE_ADDR'] ?? '') . " $method $requestUri");
|
|||||||
background-clip: text;
|
background-clip: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Oberer Teil des Glitches */
|
||||||
.error-code::before {
|
.error-code::before {
|
||||||
clip-path: inset(0 0 65% 0);
|
clip-path: inset(0 0 65% 0);
|
||||||
animation: glitch1 3s infinite linear alternate-reverse;
|
animation: glitch1 3s infinite linear alternate-reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Unterer Teil des Glitches */
|
||||||
.error-code::after {
|
.error-code::after {
|
||||||
clip-path: inset(65% 0 0 0);
|
clip-path: inset(65% 0 0 0);
|
||||||
animation: glitch2 3s infinite linear alternate-reverse;
|
animation: glitch2 3s infinite linear alternate-reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Keyframes-Animationen für den wackeligen Glitch-Effekt */
|
||||||
@keyframes glitch1 {
|
@keyframes glitch1 {
|
||||||
0%,92% { transform: translate(0); }
|
0%,92% { transform: translate(0); }
|
||||||
93% { transform: translate(-6px,-2px); }
|
93% { transform: translate(-6px,-2px); }
|
||||||
@ -130,6 +189,9 @@ error_log("[404] " . ($_SERVER['REMOTE_ADDR'] ?? '') . " $method $requestUri");
|
|||||||
94%,100%{ transform: translate(0); }
|
94%,100%{ transform: translate(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Styling für die Error-Karten-Komponente (Glassmorphism-Effekt)
|
||||||
|
*/
|
||||||
/* ── Card ── */
|
/* ── Card ── */
|
||||||
.error-card {
|
.error-card {
|
||||||
background: rgba(31, 41, 55, 0.55);
|
background: rgba(31, 41, 55, 0.55);
|
||||||
@ -144,6 +206,7 @@ error_log("[404] " . ($_SERVER['REMOTE_ADDR'] ?? '') . " $method $requestUri");
|
|||||||
animation: fadeInUp 0.6s ease-out 0.1s both;
|
animation: fadeInUp 0.6s ease-out 0.1s both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Styling für den Kreis mit dem Icon in der Karte */
|
||||||
.error-card__icon {
|
.error-card__icon {
|
||||||
width: 56px;
|
width: 56px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
@ -157,6 +220,7 @@ error_log("[404] " . ($_SERVER['REMOTE_ADDR'] ?? '') . " $method $requestUri");
|
|||||||
animation: pulse-glow 3s ease-in-out infinite;
|
animation: pulse-glow 3s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Typografie für den Karten-Titel */
|
||||||
.error-card__title {
|
.error-card__title {
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@ -164,6 +228,7 @@ error_log("[404] " . ($_SERVER['REMOTE_ADDR'] ?? '') . " $method $requestUri");
|
|||||||
margin-bottom: 0.6rem;
|
margin-bottom: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Typografie für den Info-Text */
|
||||||
.error-card__text {
|
.error-card__text {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
@ -171,6 +236,7 @@ error_log("[404] " . ($_SERVER['REMOTE_ADDR'] ?? '') . " $method $requestUri");
|
|||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Darstellung des Pfad-Textes im Monospace-Look */
|
||||||
.error-card__path {
|
.error-card__path {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background: var(--color-primary-soft);
|
background: var(--color-primary-soft);
|
||||||
@ -186,14 +252,18 @@ error_log("[404] " . ($_SERVER['REMOTE_ADDR'] ?? '') . " $method $requestUri");
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Layout und Styling der Aktions-Buttons
|
||||||
|
*/
|
||||||
/* ── Buttons ── */
|
/* ── Buttons ── */
|
||||||
.error-card__actions {
|
.error-card__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem; /* Abstand zwischen den Buttons */
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap; /* Erlaubt Umbruch bei kleinen Bildschirmen */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Basis-Klasse für Buttons mit Flex-Eigenschaften und Transitionen */
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -209,39 +279,50 @@ error_log("[404] " . ($_SERVER['REMOTE_ADDR'] ?? '') . " $method $requestUri");
|
|||||||
transition: all var(--transition-smooth);
|
transition: all var(--transition-smooth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Haupt-Button (Primäraktion) */
|
||||||
.btn--primary {
|
.btn--primary {
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: 0 4px 14px var(--color-primary-glow);
|
box-shadow: 0 4px 14px var(--color-primary-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hover-Zustand des Haupt-Buttons */
|
||||||
.btn--primary:hover {
|
.btn--primary:hover {
|
||||||
background: var(--color-primary-hover);
|
background: var(--color-primary-hover);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px); /* Hebe-Effekt */
|
||||||
box-shadow: 0 8px 24px var(--color-primary-glow);
|
box-shadow: 0 8px 24px var(--color-primary-glow);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sekundärer Ghost-Button (Transparenter Hintergrund) */
|
||||||
.btn--ghost {
|
.btn--ghost {
|
||||||
background: rgba(255,255,255,0.04);
|
background: rgba(255,255,255,0.04);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hover-Zustand des Ghost-Buttons */
|
||||||
.btn--ghost:hover {
|
.btn--ghost:hover {
|
||||||
background: rgba(255,255,255,0.09);
|
background: rgba(255,255,255,0.09);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Responsive Anpassungen (Media Queries)
|
||||||
|
* @details Passt das Layout auf kleineren Bildschirmen (z.B. Mobilgeräten) an.
|
||||||
|
*/
|
||||||
/* ── Responsive ── */
|
/* ── Responsive ── */
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
|
/* Verringerte Polsterung der Karte für kleine Screens */
|
||||||
.error-card {
|
.error-card {
|
||||||
padding: 1.8rem 1.25rem 1.5rem;
|
padding: 1.8rem 1.25rem 1.5rem;
|
||||||
}
|
}
|
||||||
|
/* Stapeln der Buttons untereinander */
|
||||||
.error-card__actions {
|
.error-card__actions {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
/* Buttons füllen die gesamte Breite aus und zentrieren ihren Inhalt */
|
||||||
.btn {
|
.btn {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -251,3 +332,4 @@ error_log("[404] " . ($_SERVER['REMOTE_ADDR'] ?? '') . " $method $requestUri");
|
|||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
250
README.md
250
README.md
@ -1,250 +0,0 @@
|
|||||||
# Geizkragen – Preisvergleichs- und Produktplattform
|
|
||||||
|
|
||||||
## Kurzüberblick
|
|
||||||
|
|
||||||
Geizkragen ist eine webbasierte Anwendung zur Produktsuche und zum Preisvergleich. Nutzer können Produkte durchsuchen, vergleichen und in einer persönlichen Wunschliste verwalten.
|
|
||||||
Das Projekt dient als technische Demonstration einer klassischen PHP-basierten Webanwendung mit Datenbankanbindung.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Funktionsumfang
|
|
||||||
|
|
||||||
* Produktsuche (inkl. API-Endpunkt)
|
|
||||||
* Vergleich mehrerer Produkte
|
|
||||||
* Wunschliste (benutzergebunden)
|
|
||||||
* Benutzerregistrierung und Login
|
|
||||||
* Benutzerkonto-Verwaltung
|
|
||||||
* Administrationsbereich zur Benutzerverwaltung
|
|
||||||
* Hinzufügen neuer Produkte
|
|
||||||
* Produktdetailseiten
|
|
||||||
* Einfache statistische Auswertungen
|
|
||||||
* Grundlegendes Empfehlungssystem
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Projektstruktur
|
|
||||||
|
|
||||||
```bash
|
|
||||||
geizkragen/
|
|
||||||
│
|
|
||||||
├── index.php
|
|
||||||
├── productpage.php
|
|
||||||
├── compare.php
|
|
||||||
├── wunschliste.php
|
|
||||||
├── login.php
|
|
||||||
├── register.php
|
|
||||||
├── account.php
|
|
||||||
├── admin_users.php
|
|
||||||
│
|
|
||||||
├── api/
|
|
||||||
│ └── search_products.php
|
|
||||||
│
|
|
||||||
├── assets/
|
|
||||||
│ ├── css/
|
|
||||||
│ ├── images/
|
|
||||||
│ └── icons/
|
|
||||||
│
|
|
||||||
├── header.php
|
|
||||||
├── footer.php
|
|
||||||
├── style.css
|
|
||||||
├── stats.json
|
|
||||||
└── .htaccess
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Installation und Setup
|
|
||||||
|
|
||||||
### Voraussetzungen
|
|
||||||
|
|
||||||
* Webserver (z. B. Apache)
|
|
||||||
* PHP (empfohlen ≥ 7.x)
|
|
||||||
* MySQL oder MariaDB
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone <repository-url>
|
|
||||||
cd geizkragen
|
|
||||||
```
|
|
||||||
|
|
||||||
Oder manuell:
|
|
||||||
Projekt in das Webserver-Verzeichnis kopieren, z. B.:
|
|
||||||
|
|
||||||
```
|
|
||||||
C:\xampp\htdocs\geizkragen
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Datenbankeinrichtung
|
|
||||||
|
|
||||||
1. Neue Datenbank erstellen
|
|
||||||
2. Tabellen entsprechend dem Schema anlegen
|
|
||||||
3. Zugangsdaten in den PHP-Dateien konfigurieren
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Start
|
|
||||||
|
|
||||||
Webserver starten und im Browser öffnen:
|
|
||||||
|
|
||||||
```
|
|
||||||
http://localhost/geizkragen
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
### Produktsuche
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/search_products.php?q=Suchbegriff
|
|
||||||
```
|
|
||||||
|
|
||||||
Antwort:
|
|
||||||
|
|
||||||
* JSON mit passenden Produkten
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Technische Architektur
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
* PHP
|
|
||||||
* Serverseitige Verarbeitung
|
|
||||||
* Teilweise API-Struktur
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
* HTML5
|
|
||||||
* CSS
|
|
||||||
* Klassisches serverseitiges Rendering
|
|
||||||
|
|
||||||
### Datenhaltung
|
|
||||||
|
|
||||||
* MySQL / MariaDB
|
|
||||||
* Ergänzend JSON (Statistiken)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sicherheit und Hinweis zur öffentlichen Nutzung
|
|
||||||
|
|
||||||
Dieses Repository ist öffentlich zugänglich.
|
|
||||||
Der aktuelle Stand ist als Lern- bzw. Entwicklungsprojekt zu verstehen und **nicht für den produktiven Einsatz im Internet vorgesehen**.
|
|
||||||
|
|
||||||
Bekannte Einschränkungen:
|
|
||||||
|
|
||||||
* Sicherheitsmechanismen sind nicht vollständig implementiert
|
|
||||||
* Authentifizierung und Session-Handling sollten überprüft werden
|
|
||||||
* Eingabevalidierung und Schutz gegen SQL-Injection sind teilweise unzureichend
|
|
||||||
* Keine Garantie für Datenschutz oder Datensicherheit
|
|
||||||
|
|
||||||
**Empfehlung:**
|
|
||||||
Vor einem produktiven Einsatz müssen grundlegende Sicherheitsmaßnahmen implementiert werden.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Empfohlene Sicherheitsmaßnahmen
|
|
||||||
|
|
||||||
* Verwendung von Prepared Statements (PDO/MySQLi)
|
|
||||||
* Passwort-Hashing mit `password_hash()`
|
|
||||||
* CSRF-Schutz für Formulare
|
|
||||||
* Strikte Validierung und Sanitizing aller Eingaben
|
|
||||||
* Sichere Session-Verwaltung (Session-Regeneration)
|
|
||||||
* Fehlerausgaben im Produktivbetrieb deaktivieren
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Erweiterungsmöglichkeiten
|
|
||||||
|
|
||||||
* Einführung eines MVC-Patterns
|
|
||||||
* Trennung von Backend und Frontend (API-first)
|
|
||||||
* Nutzung eines modernen Frontend-Frameworks
|
|
||||||
* Verbesserung der Benutzeroberfläche (Responsive Design)
|
|
||||||
* Erweiterung des Empfehlungssystems
|
|
||||||
* Automatisierte Tests
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Entwicklungsumgebung
|
|
||||||
|
|
||||||
Empfohlen:
|
|
||||||
|
|
||||||
* PhpStorm oder Visual Studio Code
|
|
||||||
* MySQL Workbench
|
|
||||||
* XAMPP oder vergleichbare lokale Serverumgebung
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Lizenz
|
|
||||||
|
|
||||||
Derzeit ist keine Lizenz definiert.
|
|
||||||
Ohne Lizenz ist die Nutzung, Veränderung und Weitergabe rechtlich eingeschränkt.
|
|
||||||
|
|
||||||
Empfehlung:
|
|
||||||
Eine Open-Source-Lizenz wie MIT oder Apache 2.0 hinzufügen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Autor
|
|
||||||
|
|
||||||
Fabian Schieder
|
|
||||||
Paul Eisenbock
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fachliche Einschätzung
|
|
||||||
|
|
||||||
Das Projekt ist ein solides Beispiel für eine klassische PHP-Webanwendung mit Datenbankanbindung.
|
|
||||||
Für Lernzwecke ist die Struktur sinnvoll, jedoch fehlen klare architektonische Trennungen und sicherheitsrelevante Mechanismen.
|
|
||||||
|
|
||||||
Für eine Weiterentwicklung sind insbesondere folgende Punkte entscheidend:
|
|
||||||
|
|
||||||
* Strukturierung (MVC oder ähnliche Muster)
|
|
||||||
* Absicherung der Datenbankzugriffe
|
|
||||||
* Saubere Trennung von Logik und Darstellung
|
|
||||||
* Skalierbarkeit durch API-basierte Architektur
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Typische Fehlerquellen und Best Practices
|
|
||||||
|
|
||||||
### Häufige Probleme
|
|
||||||
|
|
||||||
* SQL-Injection durch direkte Queries
|
|
||||||
* Fehlendes Passwort-Hashing
|
|
||||||
* Vermischung von PHP und HTML
|
|
||||||
* Unsichere Session-Verwaltung
|
|
||||||
* Fehlende Eingabevalidierung
|
|
||||||
|
|
||||||
### Best Practices
|
|
||||||
|
|
||||||
* Prepared Statements konsequent verwenden
|
|
||||||
* Zentrale Konfigurationsdateien nutzen
|
|
||||||
* Logging und Fehlerhandling implementieren
|
|
||||||
* Code modularisieren
|
|
||||||
* Sicherheitskonzepte früh integrieren
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Typischer Ablauf
|
|
||||||
|
|
||||||
1. Benutzer sucht ein Produkt
|
|
||||||
2. Ergebnisse werden angezeigt
|
|
||||||
3. Produktdetailseite wird geöffnet
|
|
||||||
4. Optional: Vergleich oder Speicherung in Wunschliste
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Zusammenfassung
|
|
||||||
|
|
||||||
* PHP-basierte Preisvergleichsplattform
|
|
||||||
* Klassische serverseitige Architektur
|
|
||||||
* Öffentliche Repository mit Lerncharakter
|
|
||||||
* Sicherheit derzeit nicht ausreichend für Produktion
|
|
||||||
* Gute Basis für Erweiterung und Refactoring
|
|
||||||
117
account.php
117
account.php
@ -1,36 +1,97 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @file account.php
|
||||||
|
* @brief Profil- und Kontoverwaltungsseite des Benutzers.
|
||||||
|
*
|
||||||
|
* @details Diese Datei stellt die Benutzeroberfläche für das Benutzerprofil dar.
|
||||||
|
* Sie überprüft, ob eine aktive Benutzersitzung vorliegt, ruft die Benutzerdaten
|
||||||
|
* (wie Anzeigename, E-Mail und Profilbild) aus der Datenbank ab und zeigt diese an.
|
||||||
|
* Zudem bietet sie Funktionen zum Hochladen eines neuen Profilbildes, Verlinken zu
|
||||||
|
* administrativen oder benutzerspezifischen Aktionen (wie Wunschliste und Produktverwaltung)
|
||||||
|
* sowie die Möglichkeit, sich vom System abzumelden.
|
||||||
|
*
|
||||||
|
* @author Geizkragen-Projekt
|
||||||
|
* @date 2026-04-03
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Einbinden der Bootstrap-Datei.
|
||||||
|
*
|
||||||
|
* Beinhaltet Konfigurationen, Konstanten und grundlegende Funktionen (wie z. B. DB-Verbindung),
|
||||||
|
* die für den gesamten Ablauf erforderlich sind.
|
||||||
|
*/
|
||||||
require_once __DIR__ . '/lib/bootstrap.php';
|
require_once __DIR__ . '/lib/bootstrap.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Überprüfung der Benutzersitzung.
|
||||||
|
*
|
||||||
|
* Wenn die Session-Variable 'user_id' nicht gesetzt ist, wird der Benutzer sofort
|
||||||
|
* auf die Login-Seite weitergeleitet.
|
||||||
|
*/
|
||||||
if (empty($_SESSION['user_id'])) {
|
if (empty($_SESSION['user_id'])) {
|
||||||
header('Location: login.php');
|
header('Location: login.php');
|
||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int $userId Die ID des aktuell angemeldeten Benutzers, sicher als Integer gecastet.
|
||||||
|
*/
|
||||||
$userId = (int)$_SESSION['user_id'];
|
$userId = (int)$_SESSION['user_id'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var mysqli $conn Das aktive Datenbankverbindungsobjekt.
|
||||||
|
*/
|
||||||
$conn = db_connect();
|
$conn = db_connect();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Vorbereiten der SQL-Abfrage zur Ermittlung der Benutzerdaten.
|
||||||
|
*
|
||||||
|
* Holt die userID, den Anzeigenamen, die E-Mail-Adresse und den Pfad zum Profilbild
|
||||||
|
* aus der Tabelle 'users' für den gerade angemeldeten Benutzer.
|
||||||
|
*
|
||||||
|
* @var mysqli_stmt|false $stmt Das vorbereitete Statement.
|
||||||
|
*/
|
||||||
$stmt = $conn->prepare('SELECT userID, displayName, email, profilePicture FROM users WHERE userID = ? LIMIT 1');
|
$stmt = $conn->prepare('SELECT userID, displayName, email, profilePicture FROM users WHERE userID = ? LIMIT 1');
|
||||||
if (!$stmt) {
|
if (!$stmt) {
|
||||||
|
// Bei einem Fehler beim Vorbereiten der Abfrage wird ein HTTP 500 Fehler gesendet und die Ausführung gestoppt.
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
die('Datenbankfehler');
|
die('Datenbankfehler');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Ausführen des Statements mit der Benutzer-ID.
|
||||||
|
*/
|
||||||
$stmt->bind_param('i', $userId);
|
$stmt->bind_param('i', $userId);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var mysqli_result|false $result Das Ergebnis der Datenbankabfrage.
|
||||||
|
*/
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Auswertung des Abfrageergebnisses.
|
||||||
|
*
|
||||||
|
* Wenn ein Datensatz gefunden wurde, wird das assoziative Array in $user gespeichert, ansonsten ist $user null.
|
||||||
|
*
|
||||||
|
* @var array|null $user Enthält die Benutzerdaten oder null, wenn kein Benutzer gefunden wurde.
|
||||||
|
*/
|
||||||
if ($result) {
|
if ($result) {
|
||||||
$user = mysqli_fetch_assoc($result);
|
$user = mysqli_fetch_assoc($result);
|
||||||
} else {
|
} else {
|
||||||
$user = null;
|
$user = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Schließen des Statements und der Datenbankverbindung zur Ressourcenfreigabe.
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
$conn->close();
|
$conn->close();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Validierung der gefundenen Benutzerdaten.
|
||||||
|
*
|
||||||
|
* Falls kein gültiger Benutzer gefunden wurde (z.B. wenn der Benutzer zwischenzeitlich aus der DB gelöscht wurde),
|
||||||
|
* wird die aktuelle Sitzung zerstört und der Nutzer zum Login-Bildschirm umgeleitet.
|
||||||
|
*/
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
session_unset();
|
session_unset();
|
||||||
session_destroy();
|
session_destroy();
|
||||||
@ -38,12 +99,25 @@ if (!$user) {
|
|||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Einbinden des HTML-Headers.
|
||||||
|
*
|
||||||
|
* Lädt den allgemeinen Kopfbereich der Webseite, inklusive CSS-Referenzen und Navigation.
|
||||||
|
*/
|
||||||
include 'header.php';
|
include 'header.php';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
@brief Hauptcontainer für die Account-Ansicht.
|
||||||
|
Definiert die Struktur für Profilanzeige und Einstellungen.
|
||||||
|
-->
|
||||||
<main class="auth" role="main">
|
<main class="auth" role="main">
|
||||||
<section class="account" aria-label="Account Bereich">
|
<section class="account" aria-label="Account Bereich">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
@brief Erfolgs- und Fehlermeldungen für den Profilbild-Upload.
|
||||||
|
Wertet GET-Parameter 'upload' aus, um dem Benutzer visuelles Feedback zu geben.
|
||||||
|
-->
|
||||||
<?php if (isset($_GET['upload']) && $_GET['upload'] === 'ok'): ?>
|
<?php if (isset($_GET['upload']) && $_GET['upload'] === 'ok'): ?>
|
||||||
<p class="auth__alert__sucess account__toast" role="status">Profilbild wurde erfolgreich
|
<p class="auth__alert__sucess account__toast" role="status">Profilbild wurde erfolgreich
|
||||||
aktualisiert.</p>
|
aktualisiert.</p>
|
||||||
@ -54,7 +128,12 @@ include 'header.php';
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- ═══ Profil-Sidebar ═══ -->
|
<!-- ═══ Profil-Sidebar ═══ -->
|
||||||
|
<!--
|
||||||
|
@brief Container für die Profildaten.
|
||||||
|
Zeigt Avatar, Anzeigename, User-ID und E-Mail-Adresse an.
|
||||||
|
-->
|
||||||
<div class="auth__card account__profile">
|
<div class="auth__card account__profile">
|
||||||
|
<!-- Avatar-Anzeige -->
|
||||||
<div class="account__avatar-wrapper">
|
<div class="account__avatar-wrapper">
|
||||||
<img class="account__avatar"
|
<img class="account__avatar"
|
||||||
src="<?php echo htmlspecialchars($user['profilePicture']); ?>"
|
src="<?php echo htmlspecialchars($user['profilePicture']); ?>"
|
||||||
@ -62,8 +141,10 @@ include 'header.php';
|
|||||||
width="180">
|
width="180">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Name des Benutzers -->
|
||||||
<h1 class="account__displayname"><?php echo htmlspecialchars($user['displayName'], ENT_QUOTES, 'UTF-8'); ?></h1>
|
<h1 class="account__displayname"><?php echo htmlspecialchars($user['displayName'], ENT_QUOTES, 'UTF-8'); ?></h1>
|
||||||
|
|
||||||
|
<!-- Zusätzliche Details -->
|
||||||
<dl class="account__details">
|
<dl class="account__details">
|
||||||
<div class="account__detail-row">
|
<div class="account__detail-row">
|
||||||
<dt>User-ID</dt>
|
<dt>User-ID</dt>
|
||||||
@ -77,9 +158,17 @@ include 'header.php';
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ═══ Einstellungen ═══ -->
|
<!-- ═══ Einstellungen ═══ -->
|
||||||
|
<!--
|
||||||
|
@brief Container für Account-Einstellungen.
|
||||||
|
Beinhaltet Abschnitte für Profilbild ändern, Schnellaktionen und das Ausloggen.
|
||||||
|
-->
|
||||||
<div class="account__settings">
|
<div class="account__settings">
|
||||||
|
|
||||||
<!-- Profilbild ändern -->
|
<!-- Profilbild ändern -->
|
||||||
|
<!--
|
||||||
|
@brief Formular zum Hochladen eines neuen Profilbilds.
|
||||||
|
Es wird ein POST-Request mit multipart/form-data an upload.php gesendet.
|
||||||
|
-->
|
||||||
<div class="auth__card account__section">
|
<div class="auth__card account__section">
|
||||||
<h2 class="account__section-title">
|
<h2 class="account__section-title">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
@ -103,6 +192,10 @@ include 'header.php';
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Schnellaktionen -->
|
<!-- Schnellaktionen -->
|
||||||
|
<!--
|
||||||
|
@brief Bereich für schnellen Zugriff auf wichtige Funktionen.
|
||||||
|
Wenn der Benutzer die Rolle ADMIN hat, werden zusätzliche Links angezeigt.
|
||||||
|
-->
|
||||||
<div class="auth__card account__section">
|
<div class="auth__card account__section">
|
||||||
<h2 class="account__section-title">
|
<h2 class="account__section-title">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
@ -112,7 +205,13 @@ include 'header.php';
|
|||||||
Schnellaktionen
|
Schnellaktionen
|
||||||
</h2>
|
</h2>
|
||||||
<div class="account__quick-actions">
|
<div class="account__quick-actions">
|
||||||
<?php if (!empty($_SESSION['user_roles']) && in_array('ADMIN', $_SESSION['user_roles'], true)): ?>
|
<?php
|
||||||
|
/**
|
||||||
|
* @brief Prüfung der Administrator-Rolle.
|
||||||
|
* Falls der Benutzer ein Admin ist, werden Links zur Produkt- und Benutzerverwaltung gerendert.
|
||||||
|
*/
|
||||||
|
if (!empty($_SESSION['user_roles']) && in_array('ADMIN', $_SESSION['user_roles'], true)):
|
||||||
|
?>
|
||||||
<a href="productAdder.php" class="auth__submit account__action-link">
|
<a href="productAdder.php" class="auth__submit account__action-link">
|
||||||
Produkt hinzufügen
|
Produkt hinzufügen
|
||||||
</a>
|
</a>
|
||||||
@ -120,6 +219,9 @@ include 'header.php';
|
|||||||
Benutzerverwaltung
|
Benutzerverwaltung
|
||||||
</a>
|
</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<!--
|
||||||
|
@brief Link zur Wunschliste für alle Benutzer.
|
||||||
|
-->
|
||||||
<a href="wunschliste.php"
|
<a href="wunschliste.php"
|
||||||
class="auth__submit account__action-link account__action-link--secondary">
|
class="auth__submit account__action-link account__action-link--secondary">
|
||||||
Meine Wunschliste
|
Meine Wunschliste
|
||||||
@ -128,6 +230,10 @@ include 'header.php';
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Abmelden -->
|
<!-- Abmelden -->
|
||||||
|
<!--
|
||||||
|
@brief Logout-Bereich.
|
||||||
|
Stellt ein Formular bereit, das ein Ausloggen aus der Anwendung ermöglicht.
|
||||||
|
-->
|
||||||
<div class="auth__card account__section account__section--danger">
|
<div class="auth__card account__section account__section--danger">
|
||||||
<h2 class="account__section-title account__section-title--danger">
|
<h2 class="account__section-title account__section-title--danger">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
@ -150,4 +256,11 @@ include 'header.php';
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<?php include 'footer.php'; ?>
|
<?php
|
||||||
|
/**
|
||||||
|
* @brief Einbinden des HTML-Footers.
|
||||||
|
*
|
||||||
|
* Schließt die HTML-Struktur ab und lädt mögliche globale Skripte.
|
||||||
|
*/
|
||||||
|
include 'footer.php';
|
||||||
|
?>
|
||||||
|
|||||||
@ -1,8 +1,33 @@
|
|||||||
<?php
|
<?php
|
||||||
// ad_recommendation.php
|
/**
|
||||||
|
* @file ad_recommendation.php
|
||||||
|
* @brief Zeigt eine zufällige Produktempfehlung als Werbebanner an (Ad Recommendation).
|
||||||
|
*
|
||||||
|
* @details Diese Datei verbindet sich mit der Datenbank, wählt ein zufälliges Produkt
|
||||||
|
* aus der Tabelle `products` aus, ermittelt den günstigsten Preis für dieses Produkt
|
||||||
|
* aus der Tabelle `offers` und stellt es dann in einem ansprechenden Werbebanner dar.
|
||||||
|
* Das Banner enthält aufmerksamkeitsstarke CSS-Animationen und -Stylings.
|
||||||
|
*
|
||||||
|
* @author GitHub Copilot
|
||||||
|
* @date 2026-04-03
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Binde die Bootstrap-Datei ein, welche grundlegende Konfigurationen und Bibliotheken lädt
|
||||||
require_once __DIR__ . '/lib/bootstrap.php';
|
require_once __DIR__ . '/lib/bootstrap.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var mysqli $conn Die aktive Datenbankverbindung.
|
||||||
|
*/
|
||||||
$conn = db_connect();
|
$conn = db_connect();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Führt eine SQL-Abfrage aus, um ein zufälliges Produkt mit dessen Mindestpreis zu holen.
|
||||||
|
*
|
||||||
|
* @details Die Abfrage nutzt einen LEFT JOIN zwischen `products` (p) und `offers` (o).
|
||||||
|
* Sie gruppiert nach der Produkt-ID und wählt das Minimum der offer.price Spalte.
|
||||||
|
* ORDER BY RAND() wird verwendet, um bei jedem Aufruf ein zufälliges Produkt zu erhalten.
|
||||||
|
* LIMIT 1 stellt sicher, dass exakt ein Datensatz zurückgeliefert wird.
|
||||||
|
*/
|
||||||
$stmt = $conn->query("
|
$stmt = $conn->query("
|
||||||
SELECT p.productID, p.model, p.description, p.imagePath, MIN(o.price) as minPrice
|
SELECT p.productID, p.model, p.description, p.imagePath, MIN(o.price) as minPrice
|
||||||
FROM products p
|
FROM products p
|
||||||
@ -12,13 +37,29 @@ $stmt = $conn->query("
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
");
|
");
|
||||||
|
|
||||||
|
// Prüfe, ob die Abfrage erfolgreich war und mindestens ein Ergebnis gefunden wurde
|
||||||
if ($stmt && $stmt->num_rows > 0) {
|
if ($stmt && $stmt->num_rows > 0) {
|
||||||
|
/**
|
||||||
|
* @var array $randomProduct Assoziatives Array mit den abgerufenen Produktdaten.
|
||||||
|
*/
|
||||||
$randomProduct = $stmt->fetch_assoc();
|
$randomProduct = $stmt->fetch_assoc();
|
||||||
|
|
||||||
|
/** @var int $rID Die eindeutige Produkt-ID, zu einem Integer gecastet. */
|
||||||
$rID = (int)$randomProduct['productID'];
|
$rID = (int)$randomProduct['productID'];
|
||||||
|
|
||||||
|
/** @var string $rModel Das Modell bzw. der Name des Produkts, HTML-Entitäten umgewandelt. */
|
||||||
$rModel = htmlspecialchars($randomProduct['model'] ?? '');
|
$rModel = htmlspecialchars($randomProduct['model'] ?? '');
|
||||||
|
|
||||||
|
/** @var string $rDesc Die Beschreibung des Produkts, HTML-Entitäten umgewandelt. */
|
||||||
$rDesc = htmlspecialchars($randomProduct['description'] ?? '');
|
$rDesc = htmlspecialchars($randomProduct['description'] ?? '');
|
||||||
|
|
||||||
|
/** @var string $rImg Der Pfad zum Produktbild, Fallback auf Platzhalter falls leer. */
|
||||||
$rImg = htmlspecialchars($randomProduct['imagePath'] ?? 'assets/images/placeholder.png');
|
$rImg = htmlspecialchars($randomProduct['imagePath'] ?? 'assets/images/placeholder.png');
|
||||||
|
|
||||||
|
/** @var float|null $rPriceRaw Der unverarbeitete Mindestpreis aus der Datenbank. */
|
||||||
$rPriceRaw = $randomProduct['minPrice'];
|
$rPriceRaw = $randomProduct['minPrice'];
|
||||||
|
|
||||||
|
/** @var string $rPriceFormatted Der formatierte Preis im deutschen Format (z.B. 100,00), oder Fallback 100,00 falls kein Preis gefunden wurde. */
|
||||||
$rPriceFormatted = $rPriceRaw ? number_format((float)$rPriceRaw, 2, ',', '.') : '100,00';
|
$rPriceFormatted = $rPriceRaw ? number_format((float)$rPriceRaw, 2, ',', '.') : '100,00';
|
||||||
?>
|
?>
|
||||||
<style>
|
<style>
|
||||||
@ -219,21 +260,35 @@ if ($stmt && $stmt->num_rows > 0) {
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<!-- Start des Werbebanner-Containers -->
|
||||||
<div class="ad-recommendation-wrapper">
|
<div class="ad-recommendation-wrapper">
|
||||||
|
<!-- Fliegende Catchy Tags für mehr Aufmerksamkeit -->
|
||||||
<div class="ad-floating-tag tag-left">SALE!</div>
|
<div class="ad-floating-tag tag-left">SALE!</div>
|
||||||
<div class="ad-floating-tag tag-right">HOT DEAL</div>
|
<div class="ad-floating-tag tag-right">HOT DEAL</div>
|
||||||
|
|
||||||
|
<!-- Eigentlicher Inhalt der Empfehlung -->
|
||||||
<div class="ad-recommendation">
|
<div class="ad-recommendation">
|
||||||
|
<!-- Textinhalt des Banners (Badge, Titel, Preis, Beschreibung, Button) -->
|
||||||
<div class="ad-recommendation__content">
|
<div class="ad-recommendation__content">
|
||||||
<span class="ad-recommendation__badge">Empfehlung des Tages</span>
|
<span class="ad-recommendation__badge">Empfehlung des Tages</span>
|
||||||
<h2><?= $rModel ?></h2>
|
<h2><?= $rModel ?></h2>
|
||||||
<div class="ad-recommendation__price">ab <?= $rPriceFormatted ?> €</div>
|
<div class="ad-recommendation__price">ab <?= $rPriceFormatted ?> €</div>
|
||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @brief Bestimmt die Länge der Beschreibung und kürzt sie bei Bedarf ab.
|
||||||
|
* @details Verwendet Multibyte-String-Funktionen falls verfügbar (mb_strlen / mb_substr),
|
||||||
|
* ansonsten die Standard-String-Funktionen.
|
||||||
|
*/
|
||||||
$descLen = function_exists('mb_strlen') ? mb_strlen($rDesc) : strlen($rDesc);
|
$descLen = function_exists('mb_strlen') ? mb_strlen($rDesc) : strlen($rDesc);
|
||||||
|
|
||||||
|
/** @var string $descShort Gekürzte Beschreibung (max 120 Zeichen), mit angehängten Punkten falls gekürzt. */
|
||||||
$descShort = $descLen > 120 ? (function_exists('mb_substr') ? mb_substr($rDesc, 0, 120) : substr($rDesc, 0, 120)) . '...' : $rDesc;
|
$descShort = $descLen > 120 ? (function_exists('mb_substr') ? mb_substr($rDesc, 0, 120) : substr($rDesc, 0, 120)) . '...' : $rDesc;
|
||||||
?>
|
?>
|
||||||
<p><?= $descShort ?></p>
|
<p><?= $descShort ?></p>
|
||||||
<a href="productpage.php?id=<?= $rID ?>" class="ad-recommendation__btn">Jetzt ansehen ›</a>
|
<a href="productpage.php?id=<?= $rID ?>" class="ad-recommendation__btn">Jetzt ansehen ›</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Bild-Container zur Darstellung des Produktfotos -->
|
||||||
<div class="ad-recommendation__image-wrapper">
|
<div class="ad-recommendation__image-wrapper">
|
||||||
<img src="<?= $rImg ?>" alt="<?= $rModel ?>">
|
<img src="<?= $rImg ?>" alt="<?= $rModel ?>">
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
142
admin_users.php
142
admin_users.php
@ -1,79 +1,142 @@
|
|||||||
<?php
|
<?php
|
||||||
// admin_users.php
|
/**
|
||||||
|
* @file admin_users.php
|
||||||
|
* @brief Backend-Skript für die Benutzerverwaltung (Admin-Panel).
|
||||||
|
* @author GitHub Copilot
|
||||||
|
* @date 2026-04-03
|
||||||
|
*
|
||||||
|
* @details Ermöglicht es Administratoren, Benutzer anzuzeigen, nach ihnen zu suchen,
|
||||||
|
* ihre Rollen zu ändern und sie endgültig zu löschen. Zentrale administrative Komponente
|
||||||
|
* des Geizkragen-Projekts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Bindet die Bootstrap-Datei ein, um die grundlegende Konfiguration, Session-Start
|
||||||
|
* und Hilfsfunktionen zu laden.
|
||||||
|
*/
|
||||||
require_once __DIR__ . '/lib/bootstrap.php';
|
require_once __DIR__ . '/lib/bootstrap.php';
|
||||||
|
|
||||||
// 1) Zugriffskontrolle – nur ADMIN
|
/**
|
||||||
|
* 1) Zugriffskontrolle – nur ADMIN
|
||||||
|
* @brief Überprüft, ob der angemeldete Benutzer die "ADMIN"-Rolle besitzt, um unbefugten
|
||||||
|
* Zugriff zu verhindern.
|
||||||
|
*/
|
||||||
if (empty($_SESSION['user_id']) || empty($_SESSION['user_roles']) || !in_array('ADMIN', $_SESSION['user_roles'], true)) {
|
if (empty($_SESSION['user_id']) || empty($_SESSION['user_roles']) || !in_array('ADMIN', $_SESSION['user_roles'], true)) {
|
||||||
|
/// @details Beendet die Skriptausführung mit einer Fehlermeldung, falls die Berechtigungen unzureichend sind.
|
||||||
|
/// Das verhindert das Ausführen jeglicher Administrationslogik durch normale Nutzer.
|
||||||
die("Zugriff verweigert. Nur Administratoren dürfen diese Seite sehen.");
|
die("Zugriff verweigert. Nur Administratoren dürfen diese Seite sehen.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var mysqli $conn
|
||||||
|
* @brief Datenbankverbindung, um Abfragen auf der Nutzer-Tabelle auszuführen.
|
||||||
|
*/
|
||||||
$conn = db_connect();
|
$conn = db_connect();
|
||||||
|
|
||||||
// 2) Aktion: Benutzer löschen
|
/**
|
||||||
|
* 2) Aktion: Benutzer löschen
|
||||||
|
* @brief Verarbeitet Löschanfragen für Benutzer.
|
||||||
|
*/
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_user_id'])) {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_user_id'])) {
|
||||||
|
/// @var int $deleteId Konvertiert die übergebene Benutzer-ID sicher in einen Integer.
|
||||||
$deleteId = (int)$_POST['delete_user_id'];
|
$deleteId = (int)$_POST['delete_user_id'];
|
||||||
|
|
||||||
// Vermeide Selbstlöschung zur Sicherheit
|
/**
|
||||||
|
* @brief Vermeidet Selbstlöschung zur Sicherheit.
|
||||||
|
*/
|
||||||
if ($deleteId !== (int)$_SESSION['user_id']) {
|
if ($deleteId !== (int)$_SESSION['user_id']) {
|
||||||
// Zunächst Abhängigkeiten wie Rollen löschen
|
/// Entfernt zunächst die Einträge des Benutzers aus der `userRoles`-Tabelle, um Fremdschlüsselprobleme zu vermeiden.
|
||||||
$conn->query("DELETE FROM userRoles WHERE userID = $deleteId");
|
$conn->query("DELETE FROM userRoles WHERE userID = $deleteId");
|
||||||
|
|
||||||
|
/// @var mysqli_stmt $delStmt Bereitet das Lösch-Statement für die `users`-Tabelle vor.
|
||||||
$delStmt = $conn->prepare("DELETE FROM users WHERE userID = ?");
|
$delStmt = $conn->prepare("DELETE FROM users WHERE userID = ?");
|
||||||
|
/// Bindet die Benutzer-ID (Parameter-Typ i=Integer) an das Statement.
|
||||||
$delStmt->bind_param("i", $deleteId);
|
$delStmt->bind_param("i", $deleteId);
|
||||||
|
/// Führt die Löschung in der Datenbank aus.
|
||||||
$delStmt->execute();
|
$delStmt->execute();
|
||||||
|
/// Schließt das Prepared Statement.
|
||||||
$delStmt->close();
|
$delStmt->close();
|
||||||
|
|
||||||
|
/// @var string $successMsg Setzt die Erfolgsmeldung.
|
||||||
$successMsg = "Benutzer erfolgreich gelöscht.";
|
$successMsg = "Benutzer erfolgreich gelöscht.";
|
||||||
} else {
|
} else {
|
||||||
|
/// @var string $errorMsg Setzt die Fehlermeldung (Selbstlöschung unzulässig).
|
||||||
$errorMsg = "Du kannst dich nicht selbst löschen.";
|
$errorMsg = "Du kannst dich nicht selbst löschen.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2b) Aktion: Rollen aktualisieren
|
/**
|
||||||
|
* 2b) Aktion: Rollen aktualisieren
|
||||||
|
* @brief Verarbeitet Massen-Aktualisierungen von Benutzerrollen aus dem Formular.
|
||||||
|
* @details Diese Aktion wird ausgelöst, wenn der Administrator auf "Alle Rollen speichern" klickt.
|
||||||
|
*/
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['update_all_roles'])) {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['update_all_roles'])) {
|
||||||
|
/// @var array $usersRolesData Die neu zugewiesenen Benutzerrollen (ID als Key, Rollen-ID als Value).
|
||||||
$usersRolesData = isset($_POST['user_roles']) && is_array($_POST['user_roles']) ? $_POST['user_roles'] : [];
|
$usersRolesData = isset($_POST['user_roles']) && is_array($_POST['user_roles']) ? $_POST['user_roles'] : [];
|
||||||
|
/// @var array $submittedUsers Liste der Benutzer, deren Rollen übermittelt wurden.
|
||||||
$submittedUsers = isset($_POST['submitted_users']) && is_array($_POST['submitted_users']) ? $_POST['submitted_users'] : [];
|
$submittedUsers = isset($_POST['submitted_users']) && is_array($_POST['submitted_users']) ? $_POST['submitted_users'] : [];
|
||||||
|
|
||||||
|
/// Iteriert über jeden übermittelten Benutzer, um seine Rollenzuweisungen anzupassen.
|
||||||
foreach ($submittedUsers as $uId) {
|
foreach ($submittedUsers as $uId) {
|
||||||
|
/// @var int $updateId Die ID des aktuellen Benutzers.
|
||||||
$updateId = (int)$uId;
|
$updateId = (int)$uId;
|
||||||
|
|
||||||
|
/// Übergeht den Administrator, der die Seite gerade aufruft (Sicherheitsmassnahme).
|
||||||
if ($updateId === (int)$_SESSION['user_id']) {
|
if ($updateId === (int)$_SESSION['user_id']) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @var string $selectedRole Die aus dem Formular ausgewählte Rolle.
|
||||||
$selectedRole = isset($usersRolesData[$updateId]) ? $usersRolesData[$updateId] : '';
|
$selectedRole = isset($usersRolesData[$updateId]) ? $usersRolesData[$updateId] : '';
|
||||||
|
|
||||||
|
/// @var mysqli_stmt $delStmt Entfernt vorab bestehende Rollenzuweisungen des Benutzers aus der DB.
|
||||||
$delStmt = $conn->prepare("DELETE FROM userRoles WHERE userID = ?");
|
$delStmt = $conn->prepare("DELETE FROM userRoles WHERE userID = ?");
|
||||||
$delStmt->bind_param("i", $updateId);
|
$delStmt->bind_param("i", $updateId);
|
||||||
$delStmt->execute();
|
$delStmt->execute();
|
||||||
$delStmt->close();
|
$delStmt->close();
|
||||||
|
|
||||||
|
/// Falls eine neue Rolle gewählt wurde, fügt diese in die Datenbank ein.
|
||||||
if (!empty($selectedRole)) {
|
if (!empty($selectedRole)) {
|
||||||
|
/// @var mysqli_stmt $insStmt Bereitet das Insert-Statement für `userRoles` vor.
|
||||||
$insStmt = $conn->prepare("INSERT INTO userRoles (userID, roleID) VALUES (?, ?)");
|
$insStmt = $conn->prepare("INSERT INTO userRoles (userID, roleID) VALUES (?, ?)");
|
||||||
|
/// @var int $roleIdInt Die Rolle wird sicher auf Integer gecastet.
|
||||||
$roleIdInt = (int)$selectedRole;
|
$roleIdInt = (int)$selectedRole;
|
||||||
|
/// Bindet UserID und Rollen-ID als Integer-Werte an die Query.
|
||||||
$insStmt->bind_param("ii", $updateId, $roleIdInt);
|
$insStmt->bind_param("ii", $updateId, $roleIdInt);
|
||||||
$insStmt->execute();
|
$insStmt->execute();
|
||||||
$insStmt->close();
|
$insStmt->close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// Setzt Erfolgs-Feedback für den Bereich Rollenverwaltung.
|
||||||
$successMsg = "Rollen erfolgreich aktualisiert.";
|
$successMsg = "Rollen erfolgreich aktualisiert.";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2c) Alle verfügbaren Rollen laden
|
/**
|
||||||
|
* 2c) Alle verfügbaren Rollen laden
|
||||||
|
* @brief Liest alle Rollen aus, um sie in den Dropdowns zur Auswahl anzuzeigen.
|
||||||
|
*/
|
||||||
$allRoles = [];
|
$allRoles = [];
|
||||||
|
/// Startet die Abfrage aller Datensätze der Tabelle `roles`, aufsteigend sortiert nach Namen.
|
||||||
$rolesQuery = $conn->query("SELECT roleID, name FROM roles ORDER BY name ASC");
|
$rolesQuery = $conn->query("SELECT roleID, name FROM roles ORDER BY name ASC");
|
||||||
if ($rolesQuery) {
|
if ($rolesQuery) {
|
||||||
|
/// Fügt jede gefundene Rolle dem Array hinzu.
|
||||||
while ($r = $rolesQuery->fetch_assoc()) {
|
while ($r = $rolesQuery->fetch_assoc()) {
|
||||||
$allRoles[] = $r;
|
$allRoles[] = $r;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Alle Benutzer laden (mit Suche)
|
/**
|
||||||
|
* 3) Alle Benutzer laden (mit Suche)
|
||||||
|
* @brief Verarbeitet Such- und Filterkriterien, um ein Set an Benutzern abzufragen.
|
||||||
|
*/
|
||||||
|
/// @var string $searchQuery Die übermittelte Suchanfrage.
|
||||||
$searchQuery = isset($_GET['search']) ? trim($_GET['search']) : '';
|
$searchQuery = isset($_GET['search']) ? trim($_GET['search']) : '';
|
||||||
|
/// @var string $searchParam Wird als Parameter für die SQL-Wildcard-Suche vorbereitet.
|
||||||
$searchParam = '%' . $searchQuery . '%';
|
$searchParam = '%' . $searchQuery . '%';
|
||||||
|
/// @var int $filterRole Ggf. übergebene Rollen-Filterung des Nutzers.
|
||||||
$filterRole = isset($_GET['role']) ? (int)$_GET['role'] : 0;
|
$filterRole = isset($_GET['role']) ? (int)$_GET['role'] : 0;
|
||||||
|
|
||||||
|
/// @var string $sql Basis-Select zum Abfragen der Benutzerinformationen und der gruppierten Rollen-IDs.
|
||||||
$sql = "
|
$sql = "
|
||||||
SELECT u.userID, u.email, u.displayname, u.profilePicture, u.isActive,
|
SELECT u.userID, u.email, u.displayname, u.profilePicture, u.isActive,
|
||||||
GROUP_CONCAT(ur.roleID) as roleIDs
|
GROUP_CONCAT(ur.roleID) as roleIDs
|
||||||
@ -81,44 +144,61 @@ $sql = "
|
|||||||
LEFT JOIN userRoles ur ON u.userID = ur.userID
|
LEFT JOIN userRoles ur ON u.userID = ur.userID
|
||||||
";
|
";
|
||||||
|
|
||||||
|
/// @var array $whereClauses Array, mit dem die WHERE-Bedingungen flexibel aufgebaut werden sollen.
|
||||||
$whereClauses = [];
|
$whereClauses = [];
|
||||||
|
/// @var string $types Zusammenstellung von Typ-Spezifizierern für das `bind_param`.
|
||||||
$types = "";
|
$types = "";
|
||||||
|
/// @var array $params Liste der Werte, die für Prepared Statements gebunden werden.
|
||||||
$params = [];
|
$params = [];
|
||||||
|
|
||||||
|
/// Sofern die Suchanfrage nicht leer ist, wird nach Displayname und E-Mail gefiltert.
|
||||||
if ($searchQuery !== '') {
|
if ($searchQuery !== '') {
|
||||||
$whereClauses[] = "(u.displayname LIKE ? OR u.email LIKE ?)";
|
$whereClauses[] = "(u.displayname LIKE ? OR u.email LIKE ?)";
|
||||||
$types .= "ss";
|
$types .= "ss"; // ss referenziert 2 String Parameter
|
||||||
$params[] = $searchParam;
|
$params[] = $searchParam;
|
||||||
$params[] = $searchParam;
|
$params[] = $searchParam;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fügt eine Bedingung hinzu, sofern mit einer bestimmten Rolle gefiltert werden soll.
|
||||||
if ($filterRole > 0) {
|
if ($filterRole > 0) {
|
||||||
// Da wir einen LEFT JOIN mit GROUP_CONCAT haben und auf Rollen filtern wollen,
|
/**
|
||||||
// können wir als einfache Lösung einen Subselect für EXISTS machen, damit
|
* Da wir einen LEFT JOIN mit GROUP_CONCAT haben und auf Rollen filtern wollen,
|
||||||
// alle Rollen des Benutzers in GROUP_CONCAT erhalten bleiben,
|
* können wir als einfache Lösung einen Subselect für EXISTS machen, damit
|
||||||
// aber nur Nutzer gezeigt werden, die auch die geforderte Rolle haben.
|
* alle Rollen des Benutzers in GROUP_CONCAT erhalten bleiben,
|
||||||
|
* aber nur Nutzer gezeigt werden, die auch die geforderte Rolle haben.
|
||||||
|
*/
|
||||||
$whereClauses[] = "EXISTS (SELECT 1 FROM userRoles sub_ur WHERE sub_ur.userID = u.userID AND sub_ur.roleID = ?)";
|
$whereClauses[] = "EXISTS (SELECT 1 FROM userRoles sub_ur WHERE sub_ur.userID = u.userID AND sub_ur.roleID = ?)";
|
||||||
$types .= "i";
|
$types .= "i"; // i referenziert einen Integer
|
||||||
$params[] = $filterRole;
|
$params[] = $filterRole;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Erweitert den SQL-String um die zusammengefassten Filter-Clauses.
|
||||||
if (!empty($whereClauses)) {
|
if (!empty($whereClauses)) {
|
||||||
$sql .= " WHERE " . implode(" AND ", $whereClauses);
|
$sql .= " WHERE " . implode(" AND ", $whereClauses);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fügt der SQL die finale Gruppierung und Sortierung nach User ID an.
|
||||||
$sql .= " GROUP BY u.userID ORDER BY u.userID ASC";
|
$sql .= " GROUP BY u.userID ORDER BY u.userID ASC";
|
||||||
|
|
||||||
|
/// @var mysqli_stmt $stmtUsers Bereitet das komplette Nutzer-Bezugs-Statement vor.
|
||||||
$stmtUsers = $conn->prepare($sql);
|
$stmtUsers = $conn->prepare($sql);
|
||||||
|
/// Wenn Bindings existieren, binden wir sie dynamisch via `$params`-Spread-Operator an das SQL-Statement.
|
||||||
if (!empty($params)) {
|
if (!empty($params)) {
|
||||||
$stmtUsers->bind_param($types, ...$params);
|
$stmtUsers->bind_param($types, ...$params);
|
||||||
}
|
}
|
||||||
|
/// Das vorbereitete SQL ausführen.
|
||||||
$stmtUsers->execute();
|
$stmtUsers->execute();
|
||||||
|
/// @var mysqli_result $usersResult Empfängt das Result-Set aus der getätigten Fetch-Operation.
|
||||||
$usersResult = $stmtUsers->get_result();
|
$usersResult = $stmtUsers->get_result();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Sammelt Filter-Attribute für die Fortführung der URL-Parameter bei Massenänderungen.
|
||||||
|
*/
|
||||||
$formActionParams = [];
|
$formActionParams = [];
|
||||||
if ($searchQuery !== '') $formActionParams['search'] = $searchQuery;
|
if ($searchQuery !== '') $formActionParams['search'] = $searchQuery;
|
||||||
if ($filterRole > 0) $formActionParams['role'] = $filterRole;
|
if ($filterRole > 0) $formActionParams['role'] = $filterRole;
|
||||||
|
|
||||||
|
/// @var string $formActionUrl Stellt die Request-URI des Formulars samt GET-Parametern zusammen.
|
||||||
$formActionUrl = "admin_users.php";
|
$formActionUrl = "admin_users.php";
|
||||||
if (!empty($formActionParams)) {
|
if (!empty($formActionParams)) {
|
||||||
$formActionUrl .= "?" . http_build_query($formActionParams);
|
$formActionUrl .= "?" . http_build_query($formActionParams);
|
||||||
@ -126,9 +206,22 @@ if (!empty($formActionParams)) {
|
|||||||
|
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<?php include 'header.php'; ?>
|
<?php
|
||||||
|
/** @brief Bindet das allgemeine Header-Template ein.
|
||||||
|
* @details Stellt den HTML-Kopf, CSS-Einbindungen und die Navigation bereit.
|
||||||
|
*/
|
||||||
|
include 'header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
@brief Hauptcontainer für die Benutzerverwaltungs-Ansicht.
|
||||||
|
@details Nutzt die Klasse 'auth' für konsistentes Styling.
|
||||||
|
-->
|
||||||
<main class="auth" role="main">
|
<main class="auth" role="main">
|
||||||
|
<!--
|
||||||
|
@brief Abschnitt für das Grid-Layout der Admin-Tabelle.
|
||||||
|
@details Ermöglicht horizontales Scrollen auf kleineren Bildschirmen.
|
||||||
|
-->
|
||||||
<section class="auth__grid" style="display: block; max-width: 95%; width: max-content; margin: 40px auto; overflow-x: auto;">
|
<section class="auth__grid" style="display: block; max-width: 95%; width: max-content; margin: 40px auto; overflow-x: auto;">
|
||||||
<div class="auth__card" style="width: 100%; min-width: max-content;">
|
<div class="auth__card" style="width: 100%; min-width: max-content;">
|
||||||
<header class="auth__header">
|
<header class="auth__header">
|
||||||
@ -136,10 +229,14 @@ if (!empty($formActionParams)) {
|
|||||||
<p style="text-align: center; color: #94a3b8; font-size: 0.9rem; margin-top: 5px;">Hier siehst du alle registrierten Benutzer.</p>
|
<p style="text-align: center; color: #94a3b8; font-size: 0.9rem; margin-top: 5px;">Hier siehst du alle registrierten Benutzer.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<?php if (!empty($successMsg)): ?>
|
<?php
|
||||||
|
/** @brief Zeigt ggf. anfallende Erfolgsmeldungen formatiert an. */
|
||||||
|
if (!empty($successMsg)): ?>
|
||||||
<div class="auth__message auth__message--success" style="color: #4ade80; background: #064e3b; padding: 10px; border-radius: 4px; margin-bottom: 15px; text-align: center;"><?= htmlspecialchars($successMsg) ?></div>
|
<div class="auth__message auth__message--success" style="color: #4ade80; background: #064e3b; padding: 10px; border-radius: 4px; margin-bottom: 15px; text-align: center;"><?= htmlspecialchars($successMsg) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if (!empty($errorMsg)): ?>
|
<?php
|
||||||
|
/** @brief Zeigt ggf. anfallende Fehlermeldungen formatiert an. */
|
||||||
|
if (!empty($errorMsg)): ?>
|
||||||
<div class="auth__message auth__message--error" style="color: #f87171; background: #7f1d1d; padding: 10px; border-radius: 4px; margin-bottom: 15px; text-align: center;"><?= htmlspecialchars($errorMsg) ?></div>
|
<div class="auth__message auth__message--error" style="color: #f87171; background: #7f1d1d; padding: 10px; border-radius: 4px; margin-bottom: 15px; text-align: center;"><?= htmlspecialchars($errorMsg) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
@ -188,9 +285,13 @@ if (!empty($formActionParams)) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php while ($user = $usersResult->fetch_assoc()): ?>
|
<?php
|
||||||
|
/** @brief Iteriert über die aus der Datenbank empfangene Liste der User. */
|
||||||
|
while ($user = $usersResult->fetch_assoc()): ?>
|
||||||
<?php
|
<?php
|
||||||
|
/// @var array $userRoles Explodiert (splittet) die aus den DB-Verknüpfungen abgeleiteten Strings in Arrays.
|
||||||
$userRoles = !empty($user['roleIDs']) ? explode(',', $user['roleIDs']) : [];
|
$userRoles = !empty($user['roleIDs']) ? explode(',', $user['roleIDs']) : [];
|
||||||
|
/// @var bool $isSelf Validiert, ob das bearbeitete Profil zum gerade agierenden Admin-User gehört.
|
||||||
$isSelf = (int)$user['userID'] === (int)$_SESSION['user_id'];
|
$isSelf = (int)$user['userID'] === (int)$_SESSION['user_id'];
|
||||||
?>
|
?>
|
||||||
<tr style="border-bottom: 1px solid #1e293b;">
|
<tr style="border-bottom: 1px solid #1e293b;">
|
||||||
@ -251,4 +352,7 @@ if (!empty($formActionParams)) {
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<?php include 'footer.php'; ?>
|
<?php
|
||||||
|
/** @brief Bindet das allgemeine Footer-Template ein. */
|
||||||
|
include 'footer.php';
|
||||||
|
?>
|
||||||
|
|||||||
@ -1,39 +1,90 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @file search_products.php
|
||||||
|
* @brief Skript zur asynchronen Produktsuche (Live-Suche).
|
||||||
|
*
|
||||||
|
* @details Diese Datei dient als API-Endpunkt für die Echtzeit-Suche. Sie nimmt eine
|
||||||
|
* Suchanfrage über den GET-Parameter 'q' entgegen, durchsucht die Datenbanktabelle 'products'
|
||||||
|
* nach übereinstimmenden Modellnamen oder Beschreibungen und gibt die Resultate im JSON-Format zurück.
|
||||||
|
* Die Ergebnisse werden priorisiert, sodass exakte oder teilweise Übereinstimmungen im Modellnamen
|
||||||
|
* weiter oben erscheinen.
|
||||||
|
*
|
||||||
|
* @author Fabian
|
||||||
|
* @date 2026-04-04
|
||||||
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einbinden der Bootstrap-Datei.
|
||||||
|
* Übernimmt die Basiskonfiguration und lädt wichtige Bibliotheken.
|
||||||
|
*/
|
||||||
require_once __DIR__ . '/../lib/bootstrap.php';
|
require_once __DIR__ . '/../lib/bootstrap.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzen des Content-Type-Headers auf JSON, da dieses Skript
|
||||||
|
* von JavaScript (AJAX/Fetch) aufgerufen wird und JSON erwartet.
|
||||||
|
*/
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sicherheits-Header, der verhindert, dass der Browser den MIME-Typ der Antwort
|
||||||
|
* errät (MIME-Sniffing). Erhöht die Sicherheit der API.
|
||||||
|
*/
|
||||||
header('X-Content-Type-Options: nosniff');
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
/**
|
||||||
|
* @var mysqli|PDO db_connect() Stellt eine Verbindung zur Datenbank her.
|
||||||
|
*/
|
||||||
$conn = db_connect();
|
$conn = db_connect();
|
||||||
|
|
||||||
// Query
|
/**
|
||||||
|
* @var string $q Der Such-String aus dem GET-Parameter 'q'.
|
||||||
|
* Falls nicht vorhanden, wird ein leerer String verwendet.
|
||||||
|
*/
|
||||||
$q = isset($_GET['q']) ? (string)$_GET['q'] : '';
|
$q = isset($_GET['q']) ? (string)$_GET['q'] : '';
|
||||||
$q = trim($q);
|
$q = trim($q);
|
||||||
|
|
||||||
// Limit
|
/**
|
||||||
|
* @var int $limit Die maximale Anzahl der zurückzugebenden Suchergebnisse.
|
||||||
|
* Standard ist 8, falls kein Limit oder ein ungültiges Limit angegeben wird.
|
||||||
|
*/
|
||||||
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 8;
|
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 8;
|
||||||
|
|
||||||
|
// Sicherstellen, dass mindestens 1 Ergebnis zurückgegeben wird
|
||||||
if ($limit < 1) {
|
if ($limit < 1) {
|
||||||
$limit = 1;
|
$limit = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Begrenzen der maximalen Ergebnisse auf 15, um Datenbanküberlastung zu vermeiden
|
||||||
if ($limit > 15) {
|
if ($limit > 15) {
|
||||||
$limit = 15;
|
$limit = 15;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Minimum query length to reduce load/noise
|
/**
|
||||||
|
* Mindestlänge für die Suche.
|
||||||
|
* Wenn der Suchbegriff leer ist oder aus weniger als 1 Zeichen besteht,
|
||||||
|
* wird ein leeres JSON-Array zurückgegeben und die Ausführung beendet.
|
||||||
|
*/
|
||||||
if (mb_strlen($q, 'UTF-8') < 1) {
|
if (mb_strlen($q, 'UTF-8') < 1) {
|
||||||
echo json_encode(['items' => []], JSON_UNESCAPED_UNICODE);
|
echo json_encode(['items' => []], JSON_UNESCAPED_UNICODE);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Escape LIKE wildcards (% _), then add %...%
|
/**
|
||||||
|
* @var string $like Der escapte Such-String für das LIKE-Statement in SQL.
|
||||||
|
* Spezielle Wildcard-Zeichen (%) und (_) werden escapt, um SQL-Fehler
|
||||||
|
* oder unerwartetes Verhalten bei Benutzereingaben zu verhindern.
|
||||||
|
*/
|
||||||
$like = addcslashes($q, "%_\\");
|
$like = addcslashes($q, "%_\\");
|
||||||
$like = '%' . $like . '%';
|
$like = '%' . $like . '%';
|
||||||
|
|
||||||
// Simple search: model + description
|
/**
|
||||||
|
* @var string $sql Die SQL-Abfrage zur Suche nach Produkten.
|
||||||
|
* Durchsucht die Spalten `model` und `description`.
|
||||||
|
* Sortiert Treffer im `model` vor Treffern in der `description`.
|
||||||
|
*/
|
||||||
$sql = "
|
$sql = "
|
||||||
SELECT p.productID, p.model, p.description, p.imagePath
|
SELECT p.productID, p.model, p.description, p.imagePath
|
||||||
FROM products p
|
FROM products p
|
||||||
@ -44,24 +95,49 @@ try {
|
|||||||
LIMIT ?
|
LIMIT ?
|
||||||
";
|
";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var mysqli_stmt|PDOStatement $stmt Das vorbereitete SQL-Statement.
|
||||||
|
*/
|
||||||
$stmt = $conn->prepare($sql);
|
$stmt = $conn->prepare($sql);
|
||||||
|
|
||||||
|
// Überprüfen, ob das Statement erfolgreich vorbereitet wurde
|
||||||
if (!$stmt) {
|
if (!$stmt) {
|
||||||
|
// HTTP-Statuscode 500 für einen internen Serverfehler setzen
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['error' => 'DB-Query konnte nicht vorbereitet werden.'], JSON_UNESCAPED_UNICODE);
|
echo json_encode(['error' => 'DB-Query konnte nicht vorbereitet werden.'], JSON_UNESCAPED_UNICODE);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binden der Parameter an das vorbereitete Statement.
|
||||||
|
* 'sssi' bedeutet: String, String, String, Integer.
|
||||||
|
*/
|
||||||
$stmt->bind_param('sssi', $like, $like, $like, $limit);
|
$stmt->bind_param('sssi', $like, $like, $like, $limit);
|
||||||
|
|
||||||
|
// Ausführen der vorbereiteten Abfrage
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
|
|
||||||
|
// Holen des Ergebnisses aus der Datenbank
|
||||||
$res = $stmt->get_result();
|
$res = $stmt->get_result();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array $items Das Array zur Speicherung der aufbereiteten Suchergebnisse.
|
||||||
|
*/
|
||||||
$items = [];
|
$items = [];
|
||||||
|
|
||||||
|
// Durchlaufen der einzelnen Datensätze / Zeilen
|
||||||
while ($row = $res->fetch_assoc()) {
|
while ($row = $res->fetch_assoc()) {
|
||||||
|
/**
|
||||||
|
* @var int $id Die Produkt-ID iterierten Produkts.
|
||||||
|
*/
|
||||||
$id = (int)($row['productID'] ?? 0);
|
$id = (int)($row['productID'] ?? 0);
|
||||||
|
|
||||||
|
// Überspringe ungültige oder defekte IDs
|
||||||
if ($id <= 0) {
|
if ($id <= 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hinzufügen des formatierten Produkts in das Result-Array
|
||||||
$items[] = [
|
$items[] = [
|
||||||
'id' => $id,
|
'id' => $id,
|
||||||
'model' => (string)($row['model'] ?? ''),
|
'model' => (string)($row['model'] ?? ''),
|
||||||
@ -71,9 +147,17 @@ try {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rückgabe des Arrays als JSON-kodierter String.
|
||||||
|
* JSON_UNESCAPED_UNICODE verhindert, dass Umlaute escapet werden (z.B. %u00e4).
|
||||||
|
*/
|
||||||
echo json_encode(['items' => $items], JSON_UNESCAPED_UNICODE);
|
echo json_encode(['items' => $items], JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
|
/**
|
||||||
|
* Fehlerbehandlung im Falle einer Exception (z.B. bei einem Datenbankfehler).
|
||||||
|
* Sendet einen 500er Statuscode und eine generische Fehlermeldung als JSON.
|
||||||
|
*/
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['error' => 'Serverfehler'], JSON_UNESCAPED_UNICODE);
|
echo json_encode(['error' => 'Serverfehler'], JSON_UNESCAPED_UNICODE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,21 @@
|
|||||||
/* ==========================================================
|
/**
|
||||||
CATEGORY BAR
|
* @file catbar.css
|
||||||
========================================================== */
|
* @brief This stylesheet defines the styling for the category navigation bar and the attribute filter bar.
|
||||||
|
* It includes styles for navigation items, active/hover states, responsiveness, and filter form components.
|
||||||
|
* @author Geizkragen Project
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ==========================================================
|
||||||
|
* CATEGORY BAR
|
||||||
|
* ==========================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .home-nav
|
||||||
|
* @brief Main container for the home navigation bar.
|
||||||
|
* Provides the background color, padding, and flexbox layout.
|
||||||
|
*/
|
||||||
.home-nav {
|
.home-nav {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -10,7 +24,11 @@
|
|||||||
padding: 1px 0;
|
padding: 1px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inner zentriert */
|
/**
|
||||||
|
* @class .home-nav__inner
|
||||||
|
* @brief Inner wrapper for the home navigation bar.
|
||||||
|
* Ensures the content expands and is centered correctly using flexbox properties.
|
||||||
|
*/
|
||||||
.home-nav__inner {
|
.home-nav__inner {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -19,7 +37,11 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* List */
|
/**
|
||||||
|
* @class .home-nav__list
|
||||||
|
* @brief Represents the unordered list containing the navigation links.
|
||||||
|
* Removes default list styling and sets up a horizontal flexbox container.
|
||||||
|
*/
|
||||||
.home-nav__list {
|
.home-nav__list {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -28,16 +50,27 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* LI nur als Container */
|
/**
|
||||||
|
* @selector .home-nav__list li
|
||||||
|
* @brief Defines the list items inside the navigation list.
|
||||||
|
* Acts as a relative container for flex items.
|
||||||
|
*/
|
||||||
.home-nav__list li {
|
.home-nav__list li {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =============================== */
|
/**
|
||||||
/* HOME CATEGORY LINKS */
|
* ===============================
|
||||||
/* =============================== */
|
* HOME CATEGORY LINKS
|
||||||
|
* ===============================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @selector .home-nav__list li a
|
||||||
|
* @brief Styles the anchor tags inside the list items.
|
||||||
|
* Sets up dimensions, padding, colors, font styling, and transitions for hover effects.
|
||||||
|
*/
|
||||||
.home-nav__list li a {
|
.home-nav__list li a {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
@ -59,32 +92,53 @@
|
|||||||
transition: background-color 0.2s ease;
|
transition: background-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hover */
|
/**
|
||||||
|
* @selector .home-nav__list li a:hover
|
||||||
|
* @brief Defines the hover state for the navigation links.
|
||||||
|
* Applies a subtle white overlay using rgba.
|
||||||
|
*/
|
||||||
.home-nav__list li a:hover {
|
.home-nav__list li a:hover {
|
||||||
background-color: rgba(255,255,255,0.18);
|
background-color: rgba(255,255,255,0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Active (Mausklick) */
|
/**
|
||||||
|
* @selector .home-nav__list li a:active
|
||||||
|
* @brief Defines the active (mouse click down) state for the navigation links.
|
||||||
|
* Increases the opacity of the white overlay for visual feedback.
|
||||||
|
*/
|
||||||
.home-nav__list li a:active {
|
.home-nav__list li a:active {
|
||||||
background-color: rgba(255,255,255,0.28);
|
background-color: rgba(255,255,255,0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fokus (Tastatur) */
|
/**
|
||||||
|
* @selector .home-nav__list li a:focus-visible
|
||||||
|
* @brief Defines the focus state, mainly for keyboard navigation accessibility.
|
||||||
|
* Removes the default outline and adds a bespoke white box-shadow inset.
|
||||||
|
*/
|
||||||
.home-nav__list li a:focus-visible {
|
.home-nav__list li a:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: inset 0 0 0 2px rgba(255,255,255,0.5);
|
box-shadow: inset 0 0 0 2px rgba(255,255,255,0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Aktive Kategorie (per PHP) */
|
/**
|
||||||
|
* @selector .home-nav__list li a.active
|
||||||
|
* @brief Styles the currently active category link, typically set server-side via PHP or by JS.
|
||||||
|
*/
|
||||||
.home-nav__list li a.active {
|
.home-nav__list li a.active {
|
||||||
background-color: rgba(255,255,255,0.25);
|
background-color: rgba(255,255,255,0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =============================== */
|
/**
|
||||||
/* TRENNSTRICHE ZWISCHEN KATEGORIEN */
|
* ===============================
|
||||||
/* =============================== */
|
* TRENNSTRICHE ZWISCHEN KATEGORIEN
|
||||||
|
* ===============================
|
||||||
|
*/
|
||||||
|
|
||||||
/* Trennstrich rechts – außer beim letzten */
|
/**
|
||||||
|
* @selector .home-nav__list li:not(:last-child)::after
|
||||||
|
* @brief Creates vertical separator lines between category items,
|
||||||
|
* except after the last item in the list.
|
||||||
|
*/
|
||||||
.home-nav__list li:not(:last-child)::after {
|
.home-nav__list li:not(:last-child)::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@ -96,7 +150,11 @@
|
|||||||
background-color: rgba(255,255,255,0.3);
|
background-color: rgba(255,255,255,0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Responsive: horizontal scroll on mobile ─── */
|
/**
|
||||||
|
* @media (max-width: 768px)
|
||||||
|
* @brief Responsive styles for mobile and small tablet screens.
|
||||||
|
* Enables horizontal scrolling for the category list with smooth scroll-snapping.
|
||||||
|
*/
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.home-nav__inner {
|
.home-nav__inner {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
@ -140,16 +198,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================================
|
/**
|
||||||
ATTRIBUTE FILTER BAR
|
* ==========================================================
|
||||||
========================================================== */
|
* ATTRIBUTE FILTER BAR
|
||||||
|
* ==========================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .attrbar
|
||||||
|
* @brief Main container for the attribute filtering section.
|
||||||
|
* Matches the background color of the category bar and provides bottom padding.
|
||||||
|
*/
|
||||||
.attrbar {
|
.attrbar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: rgb(45, 59, 80); /* Same as catbar background */
|
background-color: rgb(45, 59, 80); /* Same as catbar background */
|
||||||
padding: 0 0 16px 0;
|
padding: 0 0 16px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .attrbar__inner
|
||||||
|
* @brief Inner container for the filter bar.
|
||||||
|
* Includes top padding and a subtle top border to separate it from the category links above.
|
||||||
|
*/
|
||||||
.attrbar__inner {
|
.attrbar__inner {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@ -159,6 +229,11 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .attr-filter-form
|
||||||
|
* @brief Wrapper for the filter form components.
|
||||||
|
* Uses flexbox wrap to display filter items fluidly, aligning them appropriately across widths.
|
||||||
|
*/
|
||||||
.attr-filter-form {
|
.attr-filter-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@ -171,6 +246,11 @@
|
|||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .attr-filter-item
|
||||||
|
* @brief Container for a single filter component (e.g. property label + dropdown).
|
||||||
|
* Lays out child elements in a flex column.
|
||||||
|
*/
|
||||||
.attr-filter-item {
|
.attr-filter-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column; /* Stack label and select */
|
flex-direction: column; /* Stack label and select */
|
||||||
@ -183,6 +263,11 @@
|
|||||||
max-width: 250px;
|
max-width: 250px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @selector .attr-filter-item label
|
||||||
|
* @brief Styles the label text above each dropdown within the filter items.
|
||||||
|
* Uses uppercase text, smaller font size, and light spacing.
|
||||||
|
*/
|
||||||
.attr-filter-item label {
|
.attr-filter-item label {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@ -191,6 +276,11 @@
|
|||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @selector .attr-filter-item select
|
||||||
|
* @brief Styles the select (dropdown) inputs in the filter bar.
|
||||||
|
* Removes default webkit/moz appearance and replaces it with custom backgrounds, borders, and a custom SVG arrow.
|
||||||
|
*/
|
||||||
.attr-filter-item select {
|
.attr-filter-item select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
@ -209,28 +299,51 @@
|
|||||||
background-size: 16px;
|
background-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @selector .attr-filter-item select:hover
|
||||||
|
* @brief Hover state for the select elements, adjusting background and border color.
|
||||||
|
*/
|
||||||
.attr-filter-item select:hover {
|
.attr-filter-item select:hover {
|
||||||
background-color: rgba(25, 30, 40, 0.8);
|
background-color: rgba(25, 30, 40, 0.8);
|
||||||
border-color: rgba(255, 255, 255, 0.3);
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @selector .attr-filter-item select:focus
|
||||||
|
* @brief Focus state for the select elements, applying primary color highlights.
|
||||||
|
*/
|
||||||
.attr-filter-item select:focus {
|
.attr-filter-item select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-primary, #274a97);
|
border-color: var(--color-primary, #274a97);
|
||||||
box-shadow: 0 0 0 3px var(--color-primary-soft, rgba(39, 74, 151, 0.15));
|
box-shadow: 0 0 0 3px var(--color-primary-soft, rgba(39, 74, 151, 0.15));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @selector .attr-filter-item select option
|
||||||
|
* @brief Styles the option elements inside the dropdown.
|
||||||
|
* Sets background and text colors to match the dark theme.
|
||||||
|
*/
|
||||||
.attr-filter-item select option {
|
.attr-filter-item select option {
|
||||||
background-color: var(--bg-surface, #2d3b50);
|
background-color: var(--bg-surface, #2d3b50);
|
||||||
color: var(--text-primary, #ffffff);
|
color: var(--text-primary, #ffffff);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .attr-filter-action
|
||||||
|
* @brief Container for form action elements, like the reset button.
|
||||||
|
* Aligns to the bottom to match the input fields structurally.
|
||||||
|
*/
|
||||||
.attr-filter-action {
|
.attr-filter-action {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
padding-bottom: 2px; /* optical alignment with inputs */
|
padding-bottom: 2px; /* optical alignment with inputs */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .attr-filter-reset
|
||||||
|
* @brief Styles for the filter reset button.
|
||||||
|
* Provides an inline-flex layout with a distinct danger (red) color scheme and hover effects.
|
||||||
|
*/
|
||||||
.attr-filter-reset {
|
.attr-filter-reset {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -248,12 +361,22 @@
|
|||||||
height: 40px; /* match select height */
|
height: 40px; /* match select height */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @selector .attr-filter-reset:hover
|
||||||
|
* @brief Hover state for the reset button.
|
||||||
|
* Creates a slight lift effect using transform and adds a shadow.
|
||||||
|
*/
|
||||||
.attr-filter-reset:hover {
|
.attr-filter-reset:hover {
|
||||||
background-color: #b91c1c; /* slightly darker danger */
|
background-color: #b91c1c; /* slightly darker danger */
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 4px 12px var(--color-danger-soft, rgba(217, 45, 32, 0.2));
|
box-shadow: 0 4px 12px var(--color-danger-soft, rgba(217, 45, 32, 0.2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @media (max-width: 768px)
|
||||||
|
* @brief Adjustments to the attribute filter layout for mobile and tablet devices.
|
||||||
|
* Forces items to take up near-half width or full width, and correctly spaces action buttons.
|
||||||
|
*/
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.attr-filter-item {
|
.attr-filter-item {
|
||||||
flex: 1 1 45%;
|
flex: 1 1 45%;
|
||||||
|
|||||||
@ -1,8 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* @file compare.css
|
||||||
|
* @brief Stylesheet für die Produktvergleichsseite.
|
||||||
|
*
|
||||||
|
* Diese Datei enthält alle spezifischen Styles für die Darstellung
|
||||||
|
* von zu vergleichenden Produkten in einer übersichtlichen Tabelle.
|
||||||
|
* Sie definiert Farben, Ränder, Schatten und Hover-Effekte für
|
||||||
|
* ein ansprechendes Dark-Mode-Design.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Hauptcontainer für die Vergleichsseite.
|
||||||
|
*
|
||||||
|
* Setzt den oberen Abstand und das allgemeine Padding für die Seite.
|
||||||
|
*/
|
||||||
.compare-page {
|
.compare-page {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Stil für den Seitentitel der Vergleichsseite.
|
||||||
|
*
|
||||||
|
* Definiert Schriftgröße, Ausrichtung, Farbe und Schriftstärke.
|
||||||
|
*/
|
||||||
.compare-title {
|
.compare-title {
|
||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
@ -11,6 +31,12 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Styling für dem Fallstrick "Leerer Vergleich".
|
||||||
|
*
|
||||||
|
* Wird angezeigt, wenn keine Produkte zum Vergleich ausgewählt wurden.
|
||||||
|
* Enthält ein hervorgehobenes Box-Design mit Hintergrund und Schatten.
|
||||||
|
*/
|
||||||
.compare-empty {
|
.compare-empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #cbd5e1;
|
color: #cbd5e1;
|
||||||
@ -21,6 +47,11 @@
|
|||||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Titel-Stil für Produktkategorien beim Vergleich.
|
||||||
|
*
|
||||||
|
* Unterteilt die Tabelle grafisch nach Kategorien mit entsprechenden Rändern.
|
||||||
|
*/
|
||||||
.compare-category-title {
|
.compare-category-title {
|
||||||
margin-top: 3rem;
|
margin-top: 3rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
@ -30,6 +61,12 @@
|
|||||||
padding-bottom: 0.8rem;
|
padding-bottom: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Wrapper für die scrollbare Vergleichstabelle.
|
||||||
|
*
|
||||||
|
* Ermöglicht horizontales Scrollen bei vielen Produkten auf kleinen Bildschirmen
|
||||||
|
* und setzt das äußere Erscheinungsbild (Hintergrund, Rahmen, Schatten).
|
||||||
|
*/
|
||||||
.compare-table-wrapper {
|
.compare-table-wrapper {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
background: #1e293b;
|
background: #1e293b;
|
||||||
@ -40,6 +77,11 @@
|
|||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Grund-Styling der Vergleichstabelle.
|
||||||
|
*
|
||||||
|
* Definiert Mindestbreite und reguliert die Zellabstände (Border-Collapse).
|
||||||
|
*/
|
||||||
.compare-table {
|
.compare-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: separate;
|
border-collapse: separate;
|
||||||
@ -47,6 +89,11 @@
|
|||||||
min-width: 800px;
|
min-width: 800px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Stile für Tabellenköpfe und Datenzellen.
|
||||||
|
*
|
||||||
|
* Fügt Ränder, Abstände und zentrierte Ausrichtung sowie die Textfarbe hinzu.
|
||||||
|
*/
|
||||||
.compare-table th,
|
.compare-table th,
|
||||||
.compare-table td {
|
.compare-table td {
|
||||||
padding: 1.5rem 1rem;
|
padding: 1.5rem 1rem;
|
||||||
@ -56,10 +103,18 @@
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Entfernt den unteren Rand für die letzte Tabellenzeile.
|
||||||
|
*/
|
||||||
.compare-table tbody tr:last-child td {
|
.compare-table tbody tr:last-child td {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Styling für die erste Spalte (Eigenschaftsnamen).
|
||||||
|
*
|
||||||
|
* Fixiert die Spalte links (Sticky) für leichteres horizontales Scrollen.
|
||||||
|
*/
|
||||||
.compare-table th:first-child,
|
.compare-table th:first-child,
|
||||||
.compare-table td:first-child {
|
.compare-table td:first-child {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@ -73,6 +128,11 @@
|
|||||||
box-shadow: 4px 0 6px -2px rgba(0, 0, 0, 0.2);
|
box-shadow: 4px 0 6px -2px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Styling für den Tabellenkopf.
|
||||||
|
*
|
||||||
|
* Hebt die Kopfzeile farblich vom Rest der Tabelle ab.
|
||||||
|
*/
|
||||||
.compare-table th {
|
.compare-table th {
|
||||||
background: #0f172a;
|
background: #0f172a;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
@ -81,20 +141,41 @@
|
|||||||
padding-bottom: 2rem;
|
padding-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Spezifisches Styling für die obere linke Zelle im Tabellenkopf.
|
||||||
|
*
|
||||||
|
* Erhöht den z-Index, damit sie beim Scrollen immer oben liegt.
|
||||||
|
*/
|
||||||
.compare-table th:first-child {
|
.compare-table th:first-child {
|
||||||
background: #0f172a;
|
background: #0f172a;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Hover-Effekt für Tabellenzeilen im Körper (Datenzellen).
|
||||||
|
*
|
||||||
|
* Hebt die gesamte Zeile hervor, wenn der Benutzer darüber fährt.
|
||||||
|
*/
|
||||||
.compare-table tbody tr:hover td {
|
.compare-table tbody tr:hover td {
|
||||||
background: #2a3a52;
|
background: #2a3a52;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Hover-Effekt für die erste Zelle in der hovernden Tabellenzeile.
|
||||||
|
*
|
||||||
|
* Sorgt für eine konsistente Hervorhebung, auch bei der fixierten Spalte.
|
||||||
|
*/
|
||||||
.compare-table tbody tr:hover td:first-child {
|
.compare-table tbody tr:hover td:first-child {
|
||||||
background: #2a3a52;
|
background: #2a3a52;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Styling für das Produktbild im Tabellenkopf.
|
||||||
|
*
|
||||||
|
* Passt die Bildgröße an, setzt einen hellen Verlaufshintergrund und
|
||||||
|
* implementiert einen abgerundeten Rahmen sowie eine Größenübergangsanimation.
|
||||||
|
*/
|
||||||
.compare-product-img {
|
.compare-product-img {
|
||||||
height: 140px;
|
height: 140px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
@ -106,10 +187,20 @@
|
|||||||
transition: transform 0.3s ease;
|
transition: transform 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Hover-Animation für das Produktbild.
|
||||||
|
*
|
||||||
|
* Vergrößert das Bild leicht, wenn der Nutzer mit der Maus darüber fährt.
|
||||||
|
*/
|
||||||
.compare-product-img:hover {
|
.compare-product-img:hover {
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Styling für den Produktnamen.
|
||||||
|
*
|
||||||
|
* Definiert Farbe, Schriftgröße und Zeilenhöhe für den anklickbaren Produktnamen.
|
||||||
|
*/
|
||||||
.compare-product-name {
|
.compare-product-name {
|
||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
color: #60a5fa;
|
color: #60a5fa;
|
||||||
@ -121,10 +212,20 @@
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Hover-Effekt für den Produktnamen.
|
||||||
|
*
|
||||||
|
* Ändert die Textfarbe für visuelles Feedback beim Drüberfahren.
|
||||||
|
*/
|
||||||
.compare-product-name:hover {
|
.compare-product-name:hover {
|
||||||
color: #93c5fd;
|
color: #93c5fd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Styling für den Button zum Entfernen eines Produkts aus dem Vergleich.
|
||||||
|
*
|
||||||
|
* Roter Button (Pill-Shape) mit Schatten und Übergangseffekten.
|
||||||
|
*/
|
||||||
.compare-remove-btn {
|
.compare-remove-btn {
|
||||||
background: #ef4444;
|
background: #ef4444;
|
||||||
color: white;
|
color: white;
|
||||||
@ -138,16 +239,32 @@
|
|||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Hover-Effekt für den Entfernen-Button.
|
||||||
|
*
|
||||||
|
* Dunkelt das Rot ab, bewegt den Button leicht nach oben und vergrößert den Schatten.
|
||||||
|
*/
|
||||||
.compare-remove-btn:hover {
|
.compare-remove-btn:hover {
|
||||||
background: #dc2626;
|
background: #dc2626;
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Zebra-Streifen-Muster für gerade Tabellenzeilen.
|
||||||
|
*
|
||||||
|
* Wirkt sich nicht auf die fixierte erste Spalte aus, um die Lesbarkeit
|
||||||
|
* dunkel schattierter Hintergründe aufrechtzuerhalten.
|
||||||
|
*/
|
||||||
.compare-table tbody tr:nth-child(even) td:not(:first-child) {
|
.compare-table tbody tr:nth-child(even) td:not(:first-child) {
|
||||||
background: rgba(15, 23, 42, 0.2);
|
background: rgba(15, 23, 42, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Styling für längere Beschreibungstexte im Vergleich.
|
||||||
|
*
|
||||||
|
* Begrenzt die Breite, richtet den Text linksbündig aus und optimiert die Zeilenhöhe.
|
||||||
|
*/
|
||||||
.desc-text {
|
.desc-text {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@ -155,4 +272,3 @@
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,22 @@
|
|||||||
/* ==========================================================
|
/**
|
||||||
PRODUCT CARDS & SCROLL – Animated Glass Cards
|
* @file compcard.css
|
||||||
========================================================== */
|
* @brief Styles for Product Cards and Wishlist Cards.
|
||||||
|
* @details This file contains all CSS rules for displaying animated glass-style
|
||||||
|
* product cards, grid layouts, horizontal scroll sections, and the wishlist vertical layout.
|
||||||
|
* It also includes responsive design media queries for tablets and mobile devices.
|
||||||
|
*/
|
||||||
|
|
||||||
/* ─── Product Grid ─── */
|
/**
|
||||||
|
* ==========================================================
|
||||||
|
* @section PRODUCT CARDS & SCROLL – Animated Glass Cards
|
||||||
|
* ==========================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Product Grid Container
|
||||||
|
* @details Creates a CSS grid with autofit columns of 260px each.
|
||||||
|
* Defines the spacing (gap) and padding around the grid.
|
||||||
|
*/
|
||||||
.product-grid {
|
.product-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, 260px);
|
grid-template-columns: repeat(auto-fit, 260px);
|
||||||
@ -11,7 +25,12 @@
|
|||||||
justify-content: start;
|
justify-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Product Card ─── */
|
/**
|
||||||
|
* @brief Base Product Card Styles
|
||||||
|
* @details Sets up the generic layout, colors, shadow, and transitions for a product card.
|
||||||
|
* The card is designed with a dark background (#2d3b50) and an outline/border,
|
||||||
|
* utilizing a flexbox column layout. A fade-in animation (fadeInUp) is applied.
|
||||||
|
*/
|
||||||
.product-card {
|
.product-card {
|
||||||
background: #2d3b50;
|
background: #2d3b50;
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
@ -29,7 +48,11 @@
|
|||||||
animation: fadeInUp 0.5s ease both;
|
animation: fadeInUp 0.5s ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Gradient border glow on hover */
|
/**
|
||||||
|
* @brief Gradient border glow effect on hover
|
||||||
|
* @details Uses a pseudo-element (::before) to create a glowing border using a linear gradient.
|
||||||
|
* It features a mask to only reveal the border area. Hidden by default (opacity: 0) and shown on hover.
|
||||||
|
*/
|
||||||
.product-card::before {
|
.product-card::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@ -51,7 +74,11 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inner glow effect on hover */
|
/**
|
||||||
|
* @brief Inner glow effect on hover
|
||||||
|
* @details Uses a pseudo-element (::after) to add a subtle gradient at the top of the card.
|
||||||
|
* It is hidden by default and becomes visible when the card is hovered over.
|
||||||
|
*/
|
||||||
.product-card::after {
|
.product-card::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@ -85,6 +112,11 @@
|
|||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Staggered entrance animation delays
|
||||||
|
* @details Applies incremental animation delays to cards within a horizontal scroll container
|
||||||
|
* to create a cascading fade-in effect.
|
||||||
|
*/
|
||||||
/* Stagger cards in scroll */
|
/* Stagger cards in scroll */
|
||||||
.product-scroll .product-card:nth-child(1) { animation-delay: 0.05s; }
|
.product-scroll .product-card:nth-child(1) { animation-delay: 0.05s; }
|
||||||
.product-scroll .product-card:nth-child(2) { animation-delay: 0.1s; }
|
.product-scroll .product-card:nth-child(2) { animation-delay: 0.1s; }
|
||||||
@ -95,7 +127,11 @@
|
|||||||
.product-scroll .product-card:nth-child(7) { animation-delay: 0.35s; }
|
.product-scroll .product-card:nth-child(7) { animation-delay: 0.35s; }
|
||||||
.product-scroll .product-card:nth-child(8) { animation-delay: 0.4s; }
|
.product-scroll .product-card:nth-child(8) { animation-delay: 0.4s; }
|
||||||
|
|
||||||
/* ─── Card Image ─── */
|
/**
|
||||||
|
* @brief Card Image Styling
|
||||||
|
* @details Defines the size, padding, and positioning of the product image inside the card.
|
||||||
|
* Uses a white background and adds a bottom border.
|
||||||
|
*/
|
||||||
.product-card img {
|
.product-card img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 175px;
|
height: 175px;
|
||||||
@ -115,7 +151,11 @@
|
|||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Card Content ─── */
|
/**
|
||||||
|
* @brief Card Content Container
|
||||||
|
* @details The wrapper for textual content (title, description, price) inside the card.
|
||||||
|
* Uses flexbox to space and align the text vertically.
|
||||||
|
*/
|
||||||
.product-card__content {
|
.product-card__content {
|
||||||
padding: 1rem 1.25rem 1.15rem;
|
padding: 1rem 1.25rem 1.15rem;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
@ -127,6 +167,11 @@
|
|||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Card Title Styling
|
||||||
|
* @details Styles the <h3> heading, limits it to 2 lines using webkit-line-clamp,
|
||||||
|
* and sets up a transition for color changes on hover.
|
||||||
|
*/
|
||||||
.product-card__content h3 {
|
.product-card__content h3 {
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@ -140,10 +185,19 @@
|
|||||||
transition: color var(--transition-normal);
|
transition: color var(--transition-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Card Title Hover State
|
||||||
|
* @details Changes the color of the <h3> heading when the parent card is hovered.
|
||||||
|
*/
|
||||||
.product-card:hover .product-card__content h3 {
|
.product-card:hover .product-card__content h3 {
|
||||||
color: var(--text-invert);
|
color: var(--text-invert);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Card Description Styling
|
||||||
|
* @details Styles the <p> paragraph tag. Limits text to 4 lines with ellipsis
|
||||||
|
* and handles word-breaking to prevent overflow.
|
||||||
|
*/
|
||||||
.product-card__content p {
|
.product-card__content p {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@ -156,6 +210,10 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Card Price Styling
|
||||||
|
* @details Formats the price element, making it bold, blue, and pushed to the bottom of the card.
|
||||||
|
*/
|
||||||
.product-card__content .price {
|
.product-card__content .price {
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@ -164,12 +222,19 @@
|
|||||||
padding-top: 0.5rem;
|
padding-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Product Sections ─── */
|
/**
|
||||||
|
* @brief Product Section Wrapper
|
||||||
|
* @details Adds padding and an entrance animation for sections containing product lists/scrolls.
|
||||||
|
*/
|
||||||
.product-section {
|
.product-section {
|
||||||
padding: 2rem 0 0.5rem;
|
padding: 2rem 0 0.5rem;
|
||||||
animation: fadeInUp 0.5s ease both;
|
animation: fadeInUp 0.5s ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Product Section Title
|
||||||
|
* @details Styles the <h2> heading of the section, relative positioning is used for decorators.
|
||||||
|
*/
|
||||||
.product-section h2 {
|
.product-section h2 {
|
||||||
margin-left: 2rem;
|
margin-left: 2rem;
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
@ -181,6 +246,11 @@
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Animated Accent Line for Section Title
|
||||||
|
* @details Creates a vertical gradient line next to the <h2> tag that GROWS into place
|
||||||
|
* using a CSS keyframe animation.
|
||||||
|
*/
|
||||||
/* Animated accent line */
|
/* Animated accent line */
|
||||||
.product-section h2::before {
|
.product-section h2::before {
|
||||||
content: "";
|
content: "";
|
||||||
@ -195,10 +265,18 @@
|
|||||||
animation: lineGrow 0.6s ease 0.3s forwards;
|
animation: lineGrow 0.6s ease 0.3s forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Line Grow Keyframes
|
||||||
|
* @details Animation definition for the accent line, expanding its height to 80%.
|
||||||
|
*/
|
||||||
@keyframes lineGrow {
|
@keyframes lineGrow {
|
||||||
to { height: 80%; }
|
to { height: 80%; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Subtle Glow for Section Title
|
||||||
|
* @details Adds a blurred, colored circle behind the <h2> for a decorative lighting effect.
|
||||||
|
*/
|
||||||
/* Subtle glow behind section title */
|
/* Subtle glow behind section title */
|
||||||
.product-section h2::after {
|
.product-section h2::after {
|
||||||
content: "";
|
content: "";
|
||||||
@ -214,7 +292,11 @@
|
|||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Horizontal Scroll ─── */
|
/**
|
||||||
|
* @brief Horizontal Scroll Container
|
||||||
|
* @details A flexible container that allows horizontal scrolling for product cards.
|
||||||
|
* Implements CSS scroll snapping and scroll masks to fade edges.
|
||||||
|
*/
|
||||||
.product-scroll {
|
.product-scroll {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
@ -229,32 +311,59 @@
|
|||||||
-webkit-mask-image: linear-gradient(90deg, transparent, black 2rem, black calc(100% - 2rem), transparent);
|
-webkit-mask-image: linear-gradient(90deg, transparent, black 2rem, black calc(100% - 2rem), transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Product Scroll Item
|
||||||
|
* @details Fixes the width of cards within the scroll container and enables scroll snapping alignments.
|
||||||
|
*/
|
||||||
.product-scroll .product-card {
|
.product-scroll .product-card {
|
||||||
flex: 0 0 270px;
|
flex: 0 0 270px;
|
||||||
scroll-snap-align: start;
|
scroll-snap-align: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Hide Scrollbar
|
||||||
|
* @details Removes the default scrollbar for Webkit browsers for a cleaner look.
|
||||||
|
*/
|
||||||
.product-scroll::-webkit-scrollbar {
|
.product-scroll::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Responsive ─── */
|
/**
|
||||||
|
* @section RESPONSIVE DESIGN FOR PRODUCT CARDS
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Tablet Breakpoint (max-width: 768px)
|
||||||
|
* @details Adjusts grid columns, padding, font sizes, and card dimensions for tablet displays.
|
||||||
|
*/
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
/**
|
||||||
|
* @brief Responsive Product Grid
|
||||||
|
*/
|
||||||
.product-grid {
|
.product-grid {
|
||||||
grid-template-columns: repeat(auto-fit, 160px);
|
grid-template-columns: repeat(auto-fit, 160px);
|
||||||
gap: 0.8rem;
|
gap: 0.8rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Responsive Product Section Padding
|
||||||
|
*/
|
||||||
.product-section {
|
.product-section {
|
||||||
padding: 1.25rem 0 0.25rem;
|
padding: 1.25rem 0 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Responsive Section Title
|
||||||
|
*/
|
||||||
.product-section h2 {
|
.product-section h2 {
|
||||||
margin-left: 1rem;
|
margin-left: 1rem;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Responsive Horizontal Scroll
|
||||||
|
*/
|
||||||
.product-scroll {
|
.product-scroll {
|
||||||
padding: 0.5rem 1rem 1.5rem;
|
padding: 0.5rem 1rem 1.5rem;
|
||||||
gap: 0.8rem;
|
gap: 0.8rem;
|
||||||
@ -262,69 +371,119 @@
|
|||||||
-webkit-mask-image: linear-gradient(90deg, transparent, black 1rem, black calc(100% - 1rem), transparent);
|
-webkit-mask-image: linear-gradient(90deg, transparent, black 1rem, black calc(100% - 1rem), transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Responsive Product Scroll Item Size
|
||||||
|
*/
|
||||||
.product-scroll .product-card {
|
.product-scroll .product-card {
|
||||||
flex: 0 0 200px;
|
flex: 0 0 200px;
|
||||||
min-height: 230px;
|
min-height: 230px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Responsive Image Dimensions
|
||||||
|
*/
|
||||||
.product-card img {
|
.product-card img {
|
||||||
height: 130px;
|
height: 130px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Responsive Content Padding
|
||||||
|
*/
|
||||||
.product-card__content {
|
.product-card__content {
|
||||||
padding: 0.75rem 0.9rem 0.85rem;
|
padding: 0.75rem 0.9rem 0.85rem;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Responsive Title Size
|
||||||
|
*/
|
||||||
.product-card__content h3 {
|
.product-card__content h3 {
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Responsive Description Size
|
||||||
|
* @details Further limits description lines to 2 on tablets.
|
||||||
|
*/
|
||||||
.product-card__content p {
|
.product-card__content p {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Responsive Price Size
|
||||||
|
*/
|
||||||
.product-card__content .price {
|
.product-card__content .price {
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Mobile Breakpoint (max-width: 480px)
|
||||||
|
* @details Optimizes layouts for very small screens, converting grids and making cards even smaller.
|
||||||
|
*/
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
|
/**
|
||||||
|
* @brief Mobile Strict 2-Column Grid
|
||||||
|
*/
|
||||||
.product-grid {
|
.product-grid {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Mobile Horizontal Scroll Item
|
||||||
|
*/
|
||||||
.product-scroll .product-card {
|
.product-scroll .product-card {
|
||||||
flex: 0 0 170px;
|
flex: 0 0 170px;
|
||||||
min-height: 210px;
|
min-height: 210px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Mobile Card Image
|
||||||
|
*/
|
||||||
.product-card img {
|
.product-card img {
|
||||||
height: 110px;
|
height: 110px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Mobile Content Padding
|
||||||
|
*/
|
||||||
.product-card__content {
|
.product-card__content {
|
||||||
padding: 0.6rem 0.7rem 0.7rem;
|
padding: 0.6rem 0.7rem 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Mobile Title
|
||||||
|
* @details Limits the title to a single line.
|
||||||
|
*/
|
||||||
.product-card__content h3 {
|
.product-card__content h3 {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
-webkit-line-clamp: 1;
|
-webkit-line-clamp: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Mobile Description
|
||||||
|
* @details Completely hides the description text on small mobile screens to save space.
|
||||||
|
*/
|
||||||
.product-card__content p {
|
.product-card__content p {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================================
|
/**
|
||||||
WISHLIST – Vertical List Layout
|
* ==========================================================
|
||||||
========================================================== */
|
* @section WISHLIST – Vertical List Layout
|
||||||
|
* ==========================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Wishlist Layout Container
|
||||||
|
* @details Arranges wishlist items in a vertical, single-column flex column.
|
||||||
|
*/
|
||||||
.wishlist-grid {
|
.wishlist-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -334,6 +493,11 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Wishlist Item Card
|
||||||
|
* @details Modifies the default product card to a horizontal row layout specifically for the wishlist.
|
||||||
|
* Ensures an exact height and equal stretching.
|
||||||
|
*/
|
||||||
.wishlist-card.product-card {
|
.wishlist-card.product-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@ -351,6 +515,11 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Wishlist Image Wrapper
|
||||||
|
* @details Styles the image specifically for the horizontal wishlist layout. Adds a border right
|
||||||
|
* instead of border bottom, ensuring it takes up full height with contain object-fit.
|
||||||
|
*/
|
||||||
.wishlist-card.product-card img {
|
.wishlist-card.product-card img {
|
||||||
width: 160px;
|
width: 160px;
|
||||||
flex: 0 0 160px;
|
flex: 0 0 160px;
|
||||||
@ -376,6 +545,10 @@
|
|||||||
transition: transform var(--transition-smooth);
|
transition: transform var(--transition-smooth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Wishlist Content Wrapper
|
||||||
|
* @details Vertically centers text in the available remaining width alongside the image.
|
||||||
|
*/
|
||||||
.wishlist-card.product-card .product-card__content {
|
.wishlist-card.product-card .product-card__content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -384,15 +557,27 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Wishlist Hover Animation
|
||||||
|
* @details Moves the horizontal card slightly to the right entirely with a small scale increment.
|
||||||
|
*/
|
||||||
.wishlist-card.product-card:hover {
|
.wishlist-card.product-card:hover {
|
||||||
transform: translateX(6px) scale(1.01);
|
transform: translateX(6px) scale(1.01);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Wishlist Image Hover Zoom
|
||||||
|
* @details Zooms into the product image when hovering over a wishlist card.
|
||||||
|
*/
|
||||||
/* Bild-Zoom beim Hovern */
|
/* Bild-Zoom beim Hovern */
|
||||||
.wishlist-card.product-card:hover img {
|
.wishlist-card.product-card:hover img {
|
||||||
transform: scale(1.08);
|
transform: scale(1.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Staggered entrance animation delays for Wishlist
|
||||||
|
* @details Sequential entry animations for wishlist items to replicate the staggered scroll effect.
|
||||||
|
*/
|
||||||
/* Stagger animation for wishlist items */
|
/* Stagger animation for wishlist items */
|
||||||
.wishlist-grid .wishlist-card:nth-child(1) { animation-delay: 0.05s; }
|
.wishlist-grid .wishlist-card:nth-child(1) { animation-delay: 0.05s; }
|
||||||
.wishlist-grid .wishlist-card:nth-child(2) { animation-delay: 0.1s; }
|
.wishlist-grid .wishlist-card:nth-child(2) { animation-delay: 0.1s; }
|
||||||
@ -403,6 +588,14 @@
|
|||||||
.wishlist-grid .wishlist-card:nth-child(7) { animation-delay: 0.35s; }
|
.wishlist-grid .wishlist-card:nth-child(7) { animation-delay: 0.35s; }
|
||||||
.wishlist-grid .wishlist-card:nth-child(8) { animation-delay: 0.4s; }
|
.wishlist-grid .wishlist-card:nth-child(8) { animation-delay: 0.4s; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @section RESPONSIVE DESIGN FOR WISHLIST
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Tablet Breakpoint for Wishlist (max-width: 768px)
|
||||||
|
* @details Shrinks the total height, image size, and adjusts padding/font-size accordingly.
|
||||||
|
*/
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.wishlist-grid {
|
.wishlist-grid {
|
||||||
padding: 0.5rem 1rem 1.5rem;
|
padding: 0.5rem 1rem 1.5rem;
|
||||||
@ -433,6 +626,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Mobile Breakpoint for Wishlist (max-width: 480px)
|
||||||
|
* @details Minimizes the card height and hides descriptions to maximize screen real estate on mobile devices.
|
||||||
|
*/
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.wishlist-card.product-card {
|
.wishlist-card.product-card {
|
||||||
height: 90px;
|
height: 90px;
|
||||||
|
|||||||
@ -1,8 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* @file login.css
|
||||||
|
* @brief Stylesheet für die Authentifizierungsseiten (Login, Registrierung, Account).
|
||||||
|
* @details Dieses Stylesheet definiert das Layout, die Farben, Animationen und das responsive Verhalten
|
||||||
|
* für alle authentifizierungsbezogenen Ansichten. Es setzt auf ein "Animated Glassmorphism Design"
|
||||||
|
* und Grid-Layouts für strukturierte und ansprechende Benutzeroberflächen.
|
||||||
|
*/
|
||||||
|
|
||||||
/* ==========================================================
|
/* ==========================================================
|
||||||
AUTH PAGES – Login, Register, Account
|
AUTH PAGES – Login, Register, Account
|
||||||
Animated Glassmorphism Design
|
Animated Glassmorphism Design
|
||||||
========================================================== */
|
========================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @section AuthWrapper Auth-Wrapper
|
||||||
|
* @brief Das Hauptelement, das die Authentifizierungsformulare umschließt.
|
||||||
|
*/
|
||||||
/* ─── Auth Wrapper ─── */
|
/* ─── Auth Wrapper ─── */
|
||||||
.auth {
|
.auth {
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -13,6 +25,10 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Dekorative animierte Kugel im Hintergrund.
|
||||||
|
* @details Nutzt einen radialen Farbverlauf und eine unendliche Float-Animation, um Tiefe zu erzeugen.
|
||||||
|
*/
|
||||||
/* Decorative ambient orb */
|
/* Decorative ambient orb */
|
||||||
.auth::before {
|
.auth::before {
|
||||||
content: "";
|
content: "";
|
||||||
@ -29,6 +45,10 @@
|
|||||||
animation: float 12s ease-in-out infinite;
|
animation: float 12s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Standard-Raster für das Auth-Formular.
|
||||||
|
* @details Zentriert die Elemente horizontal und passt sich an die Bildschirmbreite bis zu 480px an.
|
||||||
|
*/
|
||||||
.auth__grid {
|
.auth__grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@ -39,6 +59,10 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @section SideLayout Side Layout (Account page)
|
||||||
|
* @brief Layout für breitere Ansichten (z. B. auf der Account-Seite).
|
||||||
|
*/
|
||||||
/* ─── Side Layout (Account page) ─── */
|
/* ─── Side Layout (Account page) ─── */
|
||||||
.auth__grid.auth__card__side {
|
.auth__grid.auth__card__side {
|
||||||
grid-template-columns: max-content 1fr;
|
grid-template-columns: max-content 1fr;
|
||||||
@ -51,6 +75,9 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Layout für die seitliche Profilbild-Anzeige.
|
||||||
|
*/
|
||||||
.auth__grid.auth__card__side .auth__card.auth__card__side__picture {
|
.auth__grid.auth__card__side .auth__card.auth__card__side__picture {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
@ -68,6 +95,9 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Styling und Hover-Effekte für das Profilbild.
|
||||||
|
*/
|
||||||
.auth__grid.auth__card__side .auth__card.auth__card__side__picture img {
|
.auth__grid.auth__card__side .auth__card.auth__card__side__picture img {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
@ -86,6 +116,11 @@
|
|||||||
justify-items: center;
|
justify-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @section CardGlassmorphism Card – Glassmorphism
|
||||||
|
* @brief Stil für die Container-Karten der Formulare.
|
||||||
|
* @details Nutzt Dunkelmodus-Farben mit feinen Box-Shadows und Hover-Effekten zur Hervorhebung.
|
||||||
|
*/
|
||||||
/* ─── Card – Glassmorphism ─── */
|
/* ─── Card – Glassmorphism ─── */
|
||||||
.auth__card,
|
.auth__card,
|
||||||
.auth__sideCard {
|
.auth__sideCard {
|
||||||
@ -103,6 +138,10 @@
|
|||||||
box-shadow: var(--shadow-md), var(--shadow-glow-blue);
|
box-shadow: var(--shadow-md), var(--shadow-glow-blue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Gestaffelte Animation für die Formular-Karten.
|
||||||
|
* @details Sorgt dafür, dass Elemente nacheinander eingeflogen werden.
|
||||||
|
*/
|
||||||
/* Stagger cards */
|
/* Stagger cards */
|
||||||
.auth__grid .auth__card:nth-child(1) { animation-delay: 0s; }
|
.auth__grid .auth__card:nth-child(1) { animation-delay: 0s; }
|
||||||
.auth__grid .auth__card:nth-child(2) { animation-delay: 0.08s; }
|
.auth__grid .auth__card:nth-child(2) { animation-delay: 0.08s; }
|
||||||
@ -113,6 +152,10 @@
|
|||||||
padding: 22px;
|
padding: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @section CardHeader Card Header
|
||||||
|
* @brief Kopfzeile der Formular-Karten (Logo und Titel).
|
||||||
|
*/
|
||||||
/* ─── Card Header ─── */
|
/* ─── Card Header ─── */
|
||||||
.auth__header {
|
.auth__header {
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -130,6 +173,9 @@
|
|||||||
grid-row: span 2;
|
grid-row: span 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Styling des Haupttitels mit Text-Gradient-Effekt.
|
||||||
|
*/
|
||||||
.auth__title {
|
.auth__title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.45rem;
|
font-size: 1.45rem;
|
||||||
@ -142,6 +188,9 @@
|
|||||||
background-clip: text;
|
background-clip: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Untertitel im Header (z.B. Eingabeaufforderungen).
|
||||||
|
*/
|
||||||
.auth__subtitle {
|
.auth__subtitle {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@ -149,6 +198,10 @@
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @section Alerts Animated Alerts
|
||||||
|
* @brief Verschiedene Benachrichtigungsboxen (Erfolg, Fehler, Warnungen).
|
||||||
|
*/
|
||||||
/* ─── Alerts – Animated ─── */
|
/* ─── Alerts – Animated ─── */
|
||||||
.auth__alert__error {
|
.auth__alert__error {
|
||||||
margin: 12px 0 14px;
|
margin: 12px 0 14px;
|
||||||
@ -168,6 +221,9 @@
|
|||||||
box-shadow: 0 8px 18px rgba(72, 142, 62, 0.18);
|
box-shadow: 0 8px 18px rgba(72, 142, 62, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Standard-Benachrichtigungs-Container mit Gradient-Hintergrund.
|
||||||
|
*/
|
||||||
.auth__alert {
|
.auth__alert {
|
||||||
margin: 0.75rem 0 1rem;
|
margin: 0.75rem 0 1rem;
|
||||||
color: var(--text-invert);
|
color: var(--text-invert);
|
||||||
@ -187,6 +243,9 @@
|
|||||||
margin: 0.25rem 0;
|
margin: 0.25rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Spezifisches Styling für Fehlermeldungen innerhalb von Formularen.
|
||||||
|
*/
|
||||||
.auth__error {
|
.auth__error {
|
||||||
margin: 0.75rem 0;
|
margin: 0.75rem 0;
|
||||||
color: var(--color-danger);
|
color: var(--color-danger);
|
||||||
@ -198,11 +257,18 @@
|
|||||||
animation: scaleIn 0.3s ease;
|
animation: scaleIn 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @section Formular Formular-Elemente
|
||||||
|
* @brief Standard-Layout für die inneren Formularelemente.
|
||||||
|
*/
|
||||||
/* ─── Form ─── */
|
/* ─── Form ─── */
|
||||||
.auth__form {
|
.auth__form {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Abstände für einzelne Eingabefelder.
|
||||||
|
*/
|
||||||
.auth__field {
|
.auth__field {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
@ -211,6 +277,9 @@
|
|||||||
margin-top: 1px;
|
margin-top: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Styling der Labels über den Eingabefeldern.
|
||||||
|
*/
|
||||||
.auth__field label {
|
.auth__field label {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 0 0 0.4rem;
|
margin: 0 0 0.4rem;
|
||||||
@ -224,6 +293,9 @@
|
|||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Grundlegendes Styling für Text-, Passwort- und Datei-Eingabefelder.
|
||||||
|
*/
|
||||||
.auth__field input[type="text"],
|
.auth__field input[type="text"],
|
||||||
.auth__field input[type="password"],
|
.auth__field input[type="password"],
|
||||||
.auth__field input[type="email"],
|
.auth__field input[type="email"],
|
||||||
@ -241,6 +313,9 @@
|
|||||||
transition: all var(--transition-smooth);
|
transition: all var(--transition-smooth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Fokus-Effekte (Ränder und Glow) für Eingabefelder.
|
||||||
|
*/
|
||||||
.auth__field input[type="text"]:focus,
|
.auth__field input[type="text"]:focus,
|
||||||
.auth__field input[type="password"]:focus,
|
.auth__field input[type="password"]:focus,
|
||||||
.auth__field input[type="email"]:focus,
|
.auth__field input[type="email"]:focus,
|
||||||
@ -255,6 +330,9 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Spezifisches Styling für den Datei-Auswahl-Button.
|
||||||
|
*/
|
||||||
.auth__field input[type="file"]::file-selector-button {
|
.auth__field input[type="file"]::file-selector-button {
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
@ -276,11 +354,18 @@
|
|||||||
transform: translateY(0px);
|
transform: translateY(0px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @section Actions Buttons & Aktionen
|
||||||
|
* @brief Hauptbuttons für die Formularübermittlung.
|
||||||
|
*/
|
||||||
/* ─── Actions ─── */
|
/* ─── Actions ─── */
|
||||||
.auth__actions {
|
.auth__actions {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Primärer Submit-Button mit Hover- und Active-Zuständen.
|
||||||
|
*/
|
||||||
.auth__submit {
|
.auth__submit {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
@ -303,6 +388,9 @@
|
|||||||
transform: translateY(0px);
|
transform: translateY(0px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Deaktivierter Zustand für Submit-Buttons.
|
||||||
|
*/
|
||||||
.auth__submit:disabled {
|
.auth__submit:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
@ -314,11 +402,18 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Rote Variante des Submit-Buttons für gefährliche Aktionen (Löschen).
|
||||||
|
*/
|
||||||
/* Danger button variant */
|
/* Danger button variant */
|
||||||
.auth__submit--danger {
|
.auth__submit--danger {
|
||||||
background: #d92d20;
|
background: #d92d20;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @section Links Verlinkungen
|
||||||
|
* @brief Zusätzliche Textlinks unter den Formularen (z.B. Passwort vergessen, Registrieren).
|
||||||
|
*/
|
||||||
/* ─── Links ─── */
|
/* ─── Links ─── */
|
||||||
.auth__links {
|
.auth__links {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
@ -341,6 +436,10 @@
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @section SideCard Seiten-Box
|
||||||
|
* @brief Gestaltung der Informationsbox auf der Seite von Registrierungsformularen etc.
|
||||||
|
*/
|
||||||
/* ─── Side Card ─── */
|
/* ─── Side Card ─── */
|
||||||
.auth__sideCard {
|
.auth__sideCard {
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
@ -364,6 +463,10 @@
|
|||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @section TipBox Tipp-Box
|
||||||
|
* @brief Container für hilfreiche Hinweise (oft neben Formularen platziert).
|
||||||
|
*/
|
||||||
/* ─── Tip Box ─── */
|
/* ─── Tip Box ─── */
|
||||||
.auth__tip {
|
.auth__tip {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
@ -374,10 +477,17 @@
|
|||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @section AccountPage ACCOUNT PAGE
|
||||||
|
* @brief Layout und spezifische Stile für die Account-Ansicht (Profil & Einstellungen).
|
||||||
|
*/
|
||||||
/* ==========================================================
|
/* ==========================================================
|
||||||
ACCOUNT PAGE – Profile + Settings Layout
|
ACCOUNT PAGE – Profile + Settings Layout
|
||||||
========================================================== */
|
========================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Modulares Grid-Layout für die Profilübersicht.
|
||||||
|
*/
|
||||||
/* ─── Main Grid ─── */
|
/* ─── Main Grid ─── */
|
||||||
.account {
|
.account {
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -393,6 +503,10 @@
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @section AccountProfile Profil-Sidebar
|
||||||
|
* @brief Der seitliche Bereich in der Account-Seite, der Profilbild und Name anzeigt.
|
||||||
|
*/
|
||||||
/* ─── Profile Sidebar ─── */
|
/* ─── Profile Sidebar ─── */
|
||||||
.account__profile {
|
.account__profile {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
@ -401,6 +515,9 @@
|
|||||||
padding: 2rem 1.5rem !important;
|
padding: 2rem 1.5rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Äußerer Container für das Profilbild auf der Profilseite mit Hover-Effekten.
|
||||||
|
*/
|
||||||
.account__avatar-wrapper {
|
.account__avatar-wrapper {
|
||||||
width: 140px;
|
width: 140px;
|
||||||
height: 140px;
|
height: 140px;
|
||||||
@ -425,6 +542,9 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Der angezeigte Name auf dem Benutzerprofil.
|
||||||
|
*/
|
||||||
.account__displayname {
|
.account__displayname {
|
||||||
margin: 0 0 1.25rem;
|
margin: 0 0 1.25rem;
|
||||||
font-size: 1.35rem;
|
font-size: 1.35rem;
|
||||||
@ -433,6 +553,10 @@
|
|||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @section AccountDetails Benutzerinformationen
|
||||||
|
* @brief Zeilenbasiertes Layout für die Auflistung von Profil-Details (wie E-Mail, Beitrittsdatum).
|
||||||
|
*/
|
||||||
/* ─── Detail Rows (User info) ─── */
|
/* ─── Detail Rows (User info) ─── */
|
||||||
.account__details {
|
.account__details {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -466,6 +590,10 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @section AccountSettings Account-Einstellungen
|
||||||
|
* @brief Der Hautpbereich für sämtliche Account-bezogene Aktionen und Einstellungen.
|
||||||
|
*/
|
||||||
/* ─── Settings Column ─── */
|
/* ─── Settings Column ─── */
|
||||||
.account__settings {
|
.account__settings {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -473,11 +601,17 @@
|
|||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Karten-Container in den Account-Einstellungen.
|
||||||
|
*/
|
||||||
/* ─── Section Cards ─── */
|
/* ─── Section Cards ─── */
|
||||||
.account__section {
|
.account__section {
|
||||||
padding: 1.5rem !important;
|
padding: 1.5rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Bereichstitel mit optionalem Icon (Nutzt Flexlayout).
|
||||||
|
*/
|
||||||
.account__section-title {
|
.account__section-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -497,6 +631,10 @@
|
|||||||
color: var(--color-danger);
|
color: var(--color-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @section QuickActions Quick Actions
|
||||||
|
* @brief Verknüpfungen zu bestimmten Aktionen oder externen Seiten im Profil.
|
||||||
|
*/
|
||||||
/* ─── Quick Actions ─── */
|
/* ─── Quick Actions ─── */
|
||||||
.account__quick-actions {
|
.account__quick-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -520,6 +658,10 @@
|
|||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @section DangerSection Danger Zone
|
||||||
|
* @brief Rote Warnbereiche für unwiderrufliche Aktionen (z.B. Profil löschen).
|
||||||
|
*/
|
||||||
/* ─── Danger Section ─── */
|
/* ─── Danger Section ─── */
|
||||||
.account__section--danger {
|
.account__section--danger {
|
||||||
border-color: rgba(217, 45, 32, 0.15);
|
border-color: rgba(217, 45, 32, 0.15);
|
||||||
@ -537,6 +679,10 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @section Responsive Responsive Design
|
||||||
|
* @brief Media-Queries für mobile Endgeräte und Tablet-Optimierungen.
|
||||||
|
*/
|
||||||
/* ==========================================================
|
/* ==========================================================
|
||||||
RESPONSIVE
|
RESPONSIVE
|
||||||
========================================================== */
|
========================================================== */
|
||||||
@ -597,6 +743,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Optimierungen für Smartphones (max-width: 520px).
|
||||||
|
*/
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 520px) {
|
||||||
.auth {
|
.auth {
|
||||||
padding: 1rem 0.75rem 2rem;
|
padding: 1rem 0.75rem 2rem;
|
||||||
@ -650,6 +799,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Optimierungen für sehr schmale Bildschirme (max-width: 380px).
|
||||||
|
*/
|
||||||
@media (max-width: 380px) {
|
@media (max-width: 380px) {
|
||||||
.auth {
|
.auth {
|
||||||
padding: 0.75rem 0.5rem 1.5rem;
|
padding: 0.75rem 0.5rem 1.5rem;
|
||||||
|
|||||||
@ -1,8 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* @file productAdder.css
|
||||||
|
* @brief Stylesheet für die Produkt-Hinzufügen-Seite (Product Adder).
|
||||||
|
* @details Enthält spezielle Stile für Dropdowns (Select-Elemente) und Formulare,
|
||||||
|
* die für die Dateneingabe beim Hinzufügen von Produkten benötigt werden.
|
||||||
|
* Das grundlegende Layout basiert auf den gleichen Prinzipien wie die Auth-Bereiche,
|
||||||
|
* wurde jedoch für breitere Formulare (bis zu 1100px) angepasst.
|
||||||
|
*/
|
||||||
|
|
||||||
/* ==========================================================
|
/* ==========================================================
|
||||||
PRODUCT ADDER – Dropdown & Form Styles
|
PRODUCT ADDER – Dropdown & Form Styles
|
||||||
========================================================== */
|
========================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @section SelectDropdown Dropdown-Auswahl
|
||||||
|
* @brief Stile für den Container und die Elemente eines Dropdown-Menüs.
|
||||||
|
*/
|
||||||
/* ─── Select Wrapper ─── */
|
/* ─── Select Wrapper ─── */
|
||||||
|
/**
|
||||||
|
* @brief Umhüllender Container für das Select-Element.
|
||||||
|
* @details Zentriert das Element, beschränkt die Maximalbreite auf 520px und
|
||||||
|
* verwendet ein CSS-Grid für saubere Abstände (gap) zwischen Label und Feld.
|
||||||
|
*/
|
||||||
.auth__select__wrap {
|
.auth__select__wrap {
|
||||||
width: min(520px, 100%);
|
width: min(520px, 100%);
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -11,11 +29,20 @@
|
|||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Label-Text für die Dropdown-Auswahl.
|
||||||
|
*/
|
||||||
.auth__select__label {
|
.auth__select__label {
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Hauptstil für das Dropdown-/Select-Feld.
|
||||||
|
* @details Beinhaltet Customizing des nativen Aussehens (appearance: none) und
|
||||||
|
* fügt ein eigenes Pfeil-Icon via Hintergrund-Gradient (background-image) hinzu.
|
||||||
|
* Das Feld besitzt Übergangseffekte für den Hover- und Focus-State.
|
||||||
|
*/
|
||||||
.auth__select {
|
.auth__select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@ -39,17 +66,31 @@
|
|||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Fokus-Status für das Dropdown-Feld.
|
||||||
|
* @details Erhält den gleichen leuchtenden Rand (Glow) wie die Standard-Eingabefelder.
|
||||||
|
*/
|
||||||
.auth__select:focus {
|
.auth__select:focus {
|
||||||
border-color: rgba(37, 99, 235, 0.75);
|
border-color: rgba(37, 99, 235, 0.75);
|
||||||
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.28);
|
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Stil der Auswahloptionen (options) innerhalb des Selects.
|
||||||
|
*/
|
||||||
.auth__select option {
|
.auth__select option {
|
||||||
background: #1e2537;
|
background: #1e2537;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @section ProductAdderLayout Seiten-Layout
|
||||||
|
* @brief Definition der Haupt-Grid-Strukturen auf der Product-Adder-Seite.
|
||||||
|
*/
|
||||||
/* ─── Product Adder Layout ─── */
|
/* ─── Product Adder Layout ─── */
|
||||||
|
/**
|
||||||
|
* @brief Äußerer Container für das Formular, der die volle Viewport-Höhe einnimmt.
|
||||||
|
*/
|
||||||
.auth {
|
.auth {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -58,6 +99,11 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Das Raster für die Formular-Zellen/Karten.
|
||||||
|
* @details Erlaubt eine maximale Breite von 1100px. Bietet genügend Platz für
|
||||||
|
* umfangreiche Produkt-Eigenschaften und Beschreibungen.
|
||||||
|
*/
|
||||||
.auth__grid {
|
.auth__grid {
|
||||||
width: min(1100px, 100%);
|
width: min(1100px, 100%);
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -65,6 +111,10 @@
|
|||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Die Kachel/Karte für einzelne Bereiche des Formulars.
|
||||||
|
* @details Hebt Formularabschnitte visuell mit Hintergrundfarbe, Rand und Schatteneffekt ab.
|
||||||
|
*/
|
||||||
.auth__card {
|
.auth__card {
|
||||||
background: #1f2937;
|
background: #1f2937;
|
||||||
border: 1px solid #5e6075;
|
border: 1px solid #5e6075;
|
||||||
@ -73,27 +123,47 @@
|
|||||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Kopfbereich innerhalb einer Kachel (auth__card).
|
||||||
|
*/
|
||||||
.auth__header {
|
.auth__header {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Titeltext der Kachel.
|
||||||
|
*/
|
||||||
.auth__title {
|
.auth__title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @section FormularElemente Formulare und Eingabefelder
|
||||||
|
* @brief Spezifische Layout-Vorgaben für die Formular-Bestandteile.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* @brief Ein einzelner Formular-Eintrag (Label + Input).
|
||||||
|
*/
|
||||||
.auth__form {
|
.auth__form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Labels, die sich im Product-Adder-Formular befinden.
|
||||||
|
*/
|
||||||
.auth__form label {
|
.auth__form label {
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Standard-Text-Eingabefeld.
|
||||||
|
* @details Verfügt über anpassbare Abstände, Randfarben und einen sanften Übergang bei Interaktionen.
|
||||||
|
*/
|
||||||
.auth__input {
|
.auth__input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@ -106,26 +176,47 @@
|
|||||||
transition: border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
|
transition: border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Zustand bei aktivem Feld (Fokus).
|
||||||
|
*/
|
||||||
.auth__input:focus {
|
.auth__input:focus {
|
||||||
border-color: rgba(37, 99, 235, 0.75);
|
border-color: rgba(37, 99, 235, 0.75);
|
||||||
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.28);
|
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Aussehen des Platzhalter-Textes.
|
||||||
|
*/
|
||||||
.auth__input::placeholder {
|
.auth__input::placeholder {
|
||||||
color: #cbd5e1;
|
color: #cbd5e1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Mehrzeilige Textarea für die Beschreibung.
|
||||||
|
* @details Mindesthöhe definiert, nur vertikal anpassbar, verbesserte Zeilenhöhe für besseren Lesefluss.
|
||||||
|
*/
|
||||||
textarea.auth__input {
|
textarea.auth__input {
|
||||||
min-height: 80px;
|
min-height: 80px;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Verhindert unnötigen Abstand beim letzten Formular-Element innerhalb der Karte.
|
||||||
|
*/
|
||||||
.auth__card .auth__form:last-child {
|
.auth__card .auth__form:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @section Responsive Responsive Design
|
||||||
|
* @brief Anpassungen für Tabets und mobile Geräte.
|
||||||
|
*/
|
||||||
/* ─── Responsive ─── */
|
/* ─── Responsive ─── */
|
||||||
|
/**
|
||||||
|
* @brief Ansichten bis zu 720px Breite (Tablets/kleine Bildschirme).
|
||||||
|
* @details Reduziert die Paddings, um mehr Platz für den Inhalt zu schaffen.
|
||||||
|
*/
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.auth {
|
.auth {
|
||||||
padding: 24px 12px;
|
padding: 24px 12px;
|
||||||
@ -136,6 +227,11 @@ textarea.auth__input {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Ansichten bis zu 480px Breite (Smartphones).
|
||||||
|
* @details Stark reduzierte Abstände (Padding, Gaps) und angepasste Schriftgrößen
|
||||||
|
* zur Sicherstellung der Lesbarkeit auf mobilen Displays.
|
||||||
|
*/
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.auth {
|
.auth {
|
||||||
padding: 1rem 0.5rem;
|
padding: 1rem 0.5rem;
|
||||||
|
|||||||
@ -1,8 +1,24 @@
|
|||||||
/* ==========================================================
|
/**
|
||||||
PRODUCT PAGE – Animated Detail View & Shop Offers
|
* @file productpage.css
|
||||||
========================================================== */
|
* @brief Stylesheet für die Produktdetailseite
|
||||||
|
* @details Diese Datei enthält alle CSS-Regeln für die Darstellung der Produktdetails, der Shop-Angebote sowie der Bewertungsbereiche.
|
||||||
|
* Es werden moderne CSS-Features wie Flexbox, Grid, CSS-Variablen und Keyframe-Animationen verwendet.
|
||||||
|
* @version 1.0
|
||||||
|
*/
|
||||||
|
|
||||||
/* ─── Wrapper ─── */
|
/**
|
||||||
|
* ==========================================================
|
||||||
|
* @section PRODUCT PAGE – Animated Detail View & Shop Offers
|
||||||
|
* @brief Hauptbereich der Produktseite, der das Bild und die Details umschließt.
|
||||||
|
* ==========================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .product-wrapper
|
||||||
|
* @brief Haupt-Container für die Produktpräsentation.
|
||||||
|
* @details Setzt eine maximale Breite, zentriert den Inhalt und ordnet die linke und rechte Spalte nebeneinander an.
|
||||||
|
* Nutzt eine Fade-In-Animation beim Laden.
|
||||||
|
*/
|
||||||
.product-wrapper {
|
.product-wrapper {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 3rem auto;
|
margin: 3rem auto;
|
||||||
@ -12,12 +28,25 @@
|
|||||||
animation: fadeInUp 0.6s ease both;
|
animation: fadeInUp 0.6s ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Left Column (Image) ─── */
|
/**
|
||||||
|
* ─── Left Column (Image) ───
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .product-left
|
||||||
|
* @brief Linke Spalte, die das Produktbild enthält.
|
||||||
|
* @details Nimmt 1 Teil des Flex-Containers ein und wird mit einer leichten Verzögerung eingeblendet.
|
||||||
|
*/
|
||||||
.product-left {
|
.product-left {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
animation: fadeInUp 0.5s ease 0.1s both;
|
animation: fadeInUp 0.5s ease 0.1s both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .product-image-box
|
||||||
|
* @brief Box-Container für das eigentliche Produktbild.
|
||||||
|
* @details Beinhaltet Hintergrund, Schatten, Abrundungen und positioniert pseudo-Elemente für Hover-Effekte.
|
||||||
|
*/
|
||||||
.product-image-box {
|
.product-image-box {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
@ -29,7 +58,11 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animated gradient border on hover */
|
/**
|
||||||
|
* @pseudo .product-image-box::before
|
||||||
|
* @brief Animierter Gradient-Rahmen beim Hover.
|
||||||
|
* @details Nutzt Maskierungen (-webkit-mask), um einen leuchtenden Rahmen über den bestehenden Border zu zeichnen, der weich eingeblendet wird.
|
||||||
|
*/
|
||||||
.product-image-box::before {
|
.product-image-box::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@ -44,15 +77,29 @@
|
|||||||
transition: opacity var(--transition-smooth);
|
transition: opacity var(--transition-smooth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @state .product-image-box:hover
|
||||||
|
* @brief Hover-Zustand für die Bild-Box.
|
||||||
|
* @details Verstärkt den Schatten und hebt das Element leicht an (TranslateY).
|
||||||
|
*/
|
||||||
.product-image-box:hover {
|
.product-image-box:hover {
|
||||||
box-shadow: var(--shadow-lg), var(--shadow-glow-blue);
|
box-shadow: var(--shadow-lg), var(--shadow-glow-blue);
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @state .product-image-box:hover::before
|
||||||
|
* @brief Macht den Gradient-Rahmen beim Hover sichtbar.
|
||||||
|
*/
|
||||||
.product-image-box:hover::before {
|
.product-image-box:hover::before {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @element .product-image-box img
|
||||||
|
* @brief Das Produktbild selbst.
|
||||||
|
* @details Skaliert sich weich beim Hovern.
|
||||||
|
*/
|
||||||
.product-image-box img {
|
.product-image-box img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
@ -60,16 +107,33 @@
|
|||||||
transition: transform var(--transition-smooth);
|
transition: transform var(--transition-smooth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @state .product-image-box:hover img
|
||||||
|
* @brief Vergrößert das Bild beim Hover leicht (Scale).
|
||||||
|
*/
|
||||||
.product-image-box:hover img {
|
.product-image-box:hover img {
|
||||||
transform: scale(1.03);
|
transform: scale(1.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Right Column (Details) ─── */
|
/**
|
||||||
|
* ─── Right Column (Details) ───
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .product-right
|
||||||
|
* @brief Rechte Spalte für die Produktdetails (Titel, Beschreibung, Spezifikationen).
|
||||||
|
* @details Nimmt 1.2 Teile des Flex-Platzes ein, etwas mehr als das Bild. Besitzt ebenfalls eine Fade-In Animation.
|
||||||
|
*/
|
||||||
.product-right {
|
.product-right {
|
||||||
flex: 1.2;
|
flex: 1.2;
|
||||||
animation: fadeInUp 0.5s ease 0.2s both;
|
animation: fadeInUp 0.5s ease 0.2s both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .product-title
|
||||||
|
* @brief Titel des Produkts.
|
||||||
|
* @details Große weiße Schrift mit einer Trennlinie nach unten.
|
||||||
|
*/
|
||||||
.product-title {
|
.product-title {
|
||||||
font-size: 32px;
|
font-size: 32px;
|
||||||
color: white;
|
color: white;
|
||||||
@ -79,6 +143,10 @@
|
|||||||
padding-bottom: 15px;
|
padding-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .product-desc
|
||||||
|
* @brief Kurze Beschreibungstexte zum Produkt.
|
||||||
|
*/
|
||||||
.product-desc {
|
.product-desc {
|
||||||
font-size: 23px;
|
font-size: 23px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
@ -86,6 +154,11 @@
|
|||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .product-specs
|
||||||
|
* @brief Container für die Haupt-Spezifikationen.
|
||||||
|
* @details Richtet Elemente vertikal aus (Flex-Column) mit entsprechendem Abstand (Gap).
|
||||||
|
*/
|
||||||
.product-specs {
|
.product-specs {
|
||||||
display: flex;
|
display: flex;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@ -93,6 +166,11 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @element .product-specs p
|
||||||
|
* @brief Einzelner Spec-Absatz.
|
||||||
|
* @details Halbtransparenter Hintergrund mit Blur-Effekt, um Tiefe zu simulieren.
|
||||||
|
*/
|
||||||
.product-specs p {
|
.product-specs p {
|
||||||
padding: 0.7rem 1rem;
|
padding: 0.7rem 1rem;
|
||||||
background: rgba(27, 34, 48, 0.65);
|
background: rgba(27, 34, 48, 0.65);
|
||||||
@ -103,19 +181,36 @@
|
|||||||
transition: all var(--transition-normal);
|
transition: all var(--transition-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @state .product-specs p:hover
|
||||||
|
* @brief Hover-Effekt für Specs.
|
||||||
|
* @details Der Hintergrund wird deckender und der Text rückt leicht nach rechts ein.
|
||||||
|
*/
|
||||||
.product-specs p:hover {
|
.product-specs p:hover {
|
||||||
background: rgba(27, 34, 48, 0.9);
|
background: rgba(27, 34, 48, 0.9);
|
||||||
border-color: var(--border-default);
|
border-color: var(--border-default);
|
||||||
transform: translateX(4px);
|
transform: translateX(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @element .product-specs p strong
|
||||||
|
* @brief Hervorhebung des Spec-Labels innerhalb eines Absatzes.
|
||||||
|
*/
|
||||||
.product-specs p strong {
|
.product-specs p strong {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Spec Rows ─── */
|
/**
|
||||||
|
* ─── Spec Rows ───
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .spec-row
|
||||||
|
* @brief Zeilenbasierte Darstellung einer einzelnen Spezifikation.
|
||||||
|
* @details Nutzt Flexbox zur Verteilung von Label und Wert an die äußeren Ränder.
|
||||||
|
*/
|
||||||
.spec-row {
|
.spec-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@ -126,22 +221,38 @@
|
|||||||
transition: all var(--transition-normal);
|
transition: all var(--transition-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @state .spec-row:hover
|
||||||
|
* @brief Hover-Verhalten einer Spec-Row.
|
||||||
|
* @details Ähnlich wie bei .product-specs p, leichtes Verschieben nach rechts.
|
||||||
|
*/
|
||||||
.spec-row:hover {
|
.spec-row:hover {
|
||||||
background: rgba(27, 34, 48, 0.9);
|
background: rgba(27, 34, 48, 0.9);
|
||||||
transform: translateX(4px);
|
transform: translateX(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .spec-name
|
||||||
|
* @brief Der Name bzw. die Bezeichnung der Spezifikation in der Zeile.
|
||||||
|
*/
|
||||||
.spec-name {
|
.spec-name {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .spec-value
|
||||||
|
* @brief Der tatsächliche Wert der Spezifikation, fett hervorgehoben.
|
||||||
|
*/
|
||||||
.spec-value {
|
.spec-value {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Responsive Product Page ─── */
|
/**
|
||||||
|
* ─── Responsive Product Page ───
|
||||||
|
* @details Media-Queries für Tablets und Smartphones im Produkt-Bereich.
|
||||||
|
*/
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.product-wrapper {
|
.product-wrapper {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -207,9 +318,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================================
|
/**
|
||||||
SHOP OFFERS – Animated List
|
* ==========================================================
|
||||||
========================================================== */
|
* @section SHOP OFFERS – Animated List
|
||||||
|
* @brief Bereich für die Anzeige verfügbarer Angebote verschiedener Shops.
|
||||||
|
* ==========================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .shop-offers
|
||||||
|
* @brief Container, der alle Shop-Angebote umschließt.
|
||||||
|
* @details Vertikales Flexbox-Layout mit Animation.
|
||||||
|
*/
|
||||||
.shop-offers {
|
.shop-offers {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto 4rem;
|
margin: 0 auto 4rem;
|
||||||
@ -220,6 +340,11 @@
|
|||||||
animation: fadeInUp 0.6s ease 0.3s both;
|
animation: fadeInUp 0.6s ease 0.3s both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .shop-line
|
||||||
|
* @brief Eine einzelne Zeile für ein Shop-Angebot.
|
||||||
|
* @details Nutzt ein CSS Grid mit 3 Spalten (Bild, Details, Preis/Button).
|
||||||
|
*/
|
||||||
.shop-line {
|
.shop-line {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 250px 1fr auto;
|
grid-template-columns: 250px 1fr auto;
|
||||||
@ -235,7 +360,10 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Gradient accent line on left */
|
/**
|
||||||
|
* @pseudo .shop-line::before
|
||||||
|
* @brief Dekorationslinie (Gradient), die links auftaucht, wenn man über das Angebot fährt.
|
||||||
|
*/
|
||||||
.shop-line::before {
|
.shop-line::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@ -249,29 +377,49 @@
|
|||||||
transition: opacity var(--transition-smooth);
|
transition: opacity var(--transition-smooth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @state .shop-line:hover
|
||||||
|
* @brief Interaktion mit einem Shop-Angebot.
|
||||||
|
* @details Ändert die Hintergrundfarbe, erzeugt Schatten und animiert nach oben.
|
||||||
|
*/
|
||||||
.shop-line:hover {
|
.shop-line:hover {
|
||||||
background: #243248;
|
background: #243248;
|
||||||
transform: translateY(-3px);
|
transform: translateY(-3px);
|
||||||
box-shadow: 0 8px 20px rgba(0,0,0,0.25);
|
box-shadow: 0 8px 20px rgba(0,0,0,0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @state .shop-line:hover::before
|
||||||
|
* @brief Blendet die linke Dekorationslinie beim Hovern ein.
|
||||||
|
*/
|
||||||
.shop-line:hover::before {
|
.shop-line:hover::before {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stagger shop lines */
|
/**
|
||||||
|
* @brief Staggered Animations
|
||||||
|
* @details Verzögert das Einblenden der Listenelemente sukzessive für einen Wasserfall-Effekt.
|
||||||
|
*/
|
||||||
.shop-line:nth-child(1) { animation-delay: 0.35s; }
|
.shop-line:nth-child(1) { animation-delay: 0.35s; }
|
||||||
.shop-line:nth-child(2) { animation-delay: 0.42s; }
|
.shop-line:nth-child(2) { animation-delay: 0.42s; }
|
||||||
.shop-line:nth-child(3) { animation-delay: 0.49s; }
|
.shop-line:nth-child(3) { animation-delay: 0.49s; }
|
||||||
.shop-line:nth-child(4) { animation-delay: 0.56s; }
|
.shop-line:nth-child(4) { animation-delay: 0.56s; }
|
||||||
.shop-line:nth-child(5) { animation-delay: 0.63s; }
|
.shop-line:nth-child(5) { animation-delay: 0.63s; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .shop-left
|
||||||
|
* @brief Linker Abschnitt in einem Shop-Angebot (Logo).
|
||||||
|
*/
|
||||||
.shop-left {
|
.shop-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .shop-logo
|
||||||
|
* @brief Container für das Shop-Logo.
|
||||||
|
*/
|
||||||
.shop-logo {
|
.shop-logo {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -279,6 +427,10 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @element .shop-logo img
|
||||||
|
* @brief Das Bild des Shop-Logos.
|
||||||
|
*/
|
||||||
.shop-logo img {
|
.shop-logo img {
|
||||||
max-height: 36px;
|
max-height: 36px;
|
||||||
max-width: 90px;
|
max-width: 90px;
|
||||||
@ -287,25 +439,45 @@
|
|||||||
transition: transform var(--transition-normal);
|
transition: transform var(--transition-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @state .shop-line:hover .shop-logo img
|
||||||
|
* @brief Skaliert das Logo beim Hovern über die Zeile.
|
||||||
|
*/
|
||||||
.shop-line:hover .shop-logo img {
|
.shop-line:hover .shop-logo img {
|
||||||
transform: scale(1.08);
|
transform: scale(1.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .shop-name
|
||||||
|
* @brief Textanzeige des Shop-Namens.
|
||||||
|
*/
|
||||||
.shop-name {
|
.shop-name {
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @element .shop-name a
|
||||||
|
* @brief Link auf den Shop, falls der Name geklickt wird.
|
||||||
|
*/
|
||||||
.shop-name a {
|
.shop-name a {
|
||||||
color: white;
|
color: white;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @state .shop-name a:hover
|
||||||
|
* @brief Unterstreichung beim Hover über den Shop-Namen.
|
||||||
|
*/
|
||||||
.shop-name a:hover {
|
.shop-name a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .shop-middle
|
||||||
|
* @brief Mittlerer Abschnitt mit Zusatzinfos (Versand, Bestand).
|
||||||
|
*/
|
||||||
.shop-middle {
|
.shop-middle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -314,6 +486,10 @@
|
|||||||
color: #cbd5e1;
|
color: #cbd5e1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .shop-shipping
|
||||||
|
* @brief Bereich für Versandinformationen.
|
||||||
|
*/
|
||||||
.shop-shipping {
|
.shop-shipping {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -321,6 +497,10 @@
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .shop-stock
|
||||||
|
* @brief Lagerbestands-Anzeige.
|
||||||
|
*/
|
||||||
.shop-stock {
|
.shop-stock {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -328,16 +508,28 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @state .shop-stock.in-stock::before
|
||||||
|
* @brief Prefix-Icon für vorrätige Artikel (grünes Häkchen).
|
||||||
|
*/
|
||||||
.shop-stock.in-stock::before {
|
.shop-stock.in-stock::before {
|
||||||
content: "✔";
|
content: "✔";
|
||||||
color: #22c55e;
|
color: #22c55e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @state .shop-stock.out-stock::before
|
||||||
|
* @brief Prefix-Icon für nicht vorrätige Artikel (rotes X).
|
||||||
|
*/
|
||||||
.shop-stock.out-stock::before {
|
.shop-stock.out-stock::before {
|
||||||
content: "✖";
|
content: "✖";
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .shop-price
|
||||||
|
* @brief Preis-Hervorhebung ganz rechts in der Shop-Zeile.
|
||||||
|
*/
|
||||||
.shop-price {
|
.shop-price {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
@ -345,10 +537,18 @@
|
|||||||
color: #4ade80;
|
color: #4ade80;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @state .shop-line:hover .shop-price
|
||||||
|
* @brief Skaliert den Preis leicht bei Interaktion.
|
||||||
|
*/
|
||||||
.shop-line:hover .shop-price {
|
.shop-line:hover .shop-price {
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .no-shop
|
||||||
|
* @brief Fallback-Ansicht, wenn kein Angebot vorhanden ist.
|
||||||
|
*/
|
||||||
.no-shop {
|
.no-shop {
|
||||||
background: #1f2a3a;
|
background: #1f2a3a;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
@ -357,7 +557,10 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Responsive Shop Offers ─── */
|
/**
|
||||||
|
* ─── Responsive Shop Offers ───
|
||||||
|
* @details Umbrüche für Shop-Zeilen bei kleineren Screens.
|
||||||
|
*/
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.shop-line {
|
.shop-line {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@ -431,10 +634,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ==========================================================
|
/**
|
||||||
PRODUCT REVIEWS
|
* ==========================================================
|
||||||
========================================================== */
|
* @section PRODUCT REVIEWS
|
||||||
|
* @brief Anzeige und Strukturierung der Produktbewertungen.
|
||||||
|
* ==========================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .reviews
|
||||||
|
* @brief Wrapper für alle Bewertungen, inklusive Überschrift.
|
||||||
|
*/
|
||||||
.reviews {
|
.reviews {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 3rem auto 5rem;
|
margin: 3rem auto 5rem;
|
||||||
@ -445,6 +655,10 @@
|
|||||||
animation: fadeInUp 0.6s ease 0.4s both;
|
animation: fadeInUp 0.6s ease 0.4s both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .reviews-title
|
||||||
|
* @brief Titel der Bewertungs-Sektion.
|
||||||
|
*/
|
||||||
.reviews-title {
|
.reviews-title {
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
@ -452,7 +666,11 @@
|
|||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Review Card */
|
/**
|
||||||
|
* @class .review-card
|
||||||
|
* @brief Einzelne Karte für eine Bewertung von einem User.
|
||||||
|
* @details Beinhaltet Hintergrund, Ränder und relative Positionierung für den Border-Effekt.
|
||||||
|
*/
|
||||||
.review-card {
|
.review-card {
|
||||||
background: #1c2533; /* leicht dunkler als shop */
|
background: #1c2533; /* leicht dunkler als shop */
|
||||||
border: 1px solid #2a374a;
|
border: 1px solid #2a374a;
|
||||||
@ -463,7 +681,10 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* leichter Akzent rechts statt links */
|
/**
|
||||||
|
* @pseudo .review-card::after
|
||||||
|
* @brief Gradient-Rahmenkante auf der rechten Seite, die beim Hover erscheint.
|
||||||
|
*/
|
||||||
.review-card::after {
|
.review-card::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@ -476,17 +697,28 @@
|
|||||||
transition: opacity var(--transition-smooth);
|
transition: opacity var(--transition-smooth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @state .review-card:hover
|
||||||
|
* @brief Hover-State der Bewertung, hellt Hintergrund leicht auf.
|
||||||
|
*/
|
||||||
.review-card:hover {
|
.review-card:hover {
|
||||||
background: #223047;
|
background: #223047;
|
||||||
transform: translateY(-3px);
|
transform: translateY(-3px);
|
||||||
box-shadow: 0 8px 18px rgba(0,0,0,0.25);
|
box-shadow: 0 8px 18px rgba(0,0,0,0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @state .review-card:hover::after
|
||||||
|
* @brief Blendet den farbigen Rand rechts ein.
|
||||||
|
*/
|
||||||
.review-card:hover::after {
|
.review-card:hover::after {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header */
|
/**
|
||||||
|
* @class .review-header
|
||||||
|
* @brief Kopfzeile einer Bewertung (User links, Sterne/Datum z.B. rechts).
|
||||||
|
*/
|
||||||
.review-header {
|
.review-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@ -494,7 +726,10 @@
|
|||||||
margin-bottom: 0.6rem;
|
margin-bottom: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* User-Info (Avatar + Name/Datum) */
|
/**
|
||||||
|
* @class .review-user-info
|
||||||
|
* @brief Container für Avatar, Name und ggf. Datum.
|
||||||
|
*/
|
||||||
.review-user-info {
|
.review-user-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -502,7 +737,10 @@
|
|||||||
min-width: 0; /* erlaubt Text-Ellipsis/Umbruch in flex */
|
min-width: 0; /* erlaubt Text-Ellipsis/Umbruch in flex */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Profilbild in Reviews: immer gleich groß und angenehm klein */
|
/**
|
||||||
|
* @class .review-avatar
|
||||||
|
* @brief Profilbild in kleinen Abmessungen innerhalb der Review.
|
||||||
|
*/
|
||||||
.review-avatar {
|
.review-avatar {
|
||||||
width: clamp(28px, 3.2vw, 34px);
|
width: clamp(28px, 3.2vw, 34px);
|
||||||
height: clamp(28px, 3.2vw, 34px);
|
height: clamp(28px, 3.2vw, 34px);
|
||||||
@ -515,7 +753,10 @@
|
|||||||
background: #0f172a; /* falls Bild transparent/fehlend */
|
background: #0f172a; /* falls Bild transparent/fehlend */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Optional: verhindert, dass lange Namen Layout sprengen */
|
/**
|
||||||
|
* @class .review-user
|
||||||
|
* @brief Benutzername des Reviewers, wird abgeschnitten (ellipsis), wenn zu lang.
|
||||||
|
*/
|
||||||
.review-user {
|
.review-user {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@ -528,34 +769,55 @@
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sterne */
|
/**
|
||||||
|
* @class .review-rating
|
||||||
|
* @brief Container für die Sterne-Bewertung.
|
||||||
|
*/
|
||||||
.review-rating {
|
.review-rating {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .star
|
||||||
|
* @brief Ein einzelnes Stern-Symbol.
|
||||||
|
*/
|
||||||
.star {
|
.star {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: #475569;
|
color: #475569;
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @state .star.filled
|
||||||
|
* @brief Aktiver, goldener Stern.
|
||||||
|
*/
|
||||||
.star.filled {
|
.star.filled {
|
||||||
color: #fbbf24;
|
color: #fbbf24;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @state .review-card:hover .star.filled
|
||||||
|
* @brief Skaliert goldene Sterne innerhalb einer Karte beim Hovern.
|
||||||
|
*/
|
||||||
.review-card:hover .star.filled {
|
.review-card:hover .star.filled {
|
||||||
transform: scale(1.15);
|
transform: scale(1.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Kommentar */
|
/**
|
||||||
|
* @class .review-comment
|
||||||
|
* @brief Textinhalt einer Bewertung.
|
||||||
|
*/
|
||||||
.review-comment {
|
.review-comment {
|
||||||
color: #cbd5e1;
|
color: #cbd5e1;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keine Reviews */
|
/**
|
||||||
|
* @class .no-review
|
||||||
|
* @brief Fallback-Text, falls noch keine Bewertungen existieren.
|
||||||
|
*/
|
||||||
.no-review {
|
.no-review {
|
||||||
background: #1f2a3a;
|
background: #1f2a3a;
|
||||||
border: 1px solid #2f3c52;
|
border: 1px solid #2f3c52;
|
||||||
@ -565,10 +827,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ==========================================================
|
/**
|
||||||
REVIEW OVERVIEW BOX (Linke Spalte) - Kompakte Version
|
* ==========================================================
|
||||||
========================================================== */
|
* @section REVIEW OVERVIEW BOX (Linke Spalte) - Kompakte Version
|
||||||
|
* @brief Zusammenfassung aller Bewertungen (Durchschnittsnote und Sterne-Verteilung).
|
||||||
|
* ==========================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .review-overview-box
|
||||||
|
* @brief Sidebar-Box zur Übersicht der Gesamtbewertung.
|
||||||
|
*/
|
||||||
.review-overview-box {
|
.review-overview-box {
|
||||||
background: #1c2533;
|
background: #1c2533;
|
||||||
border: 1px solid #2a374a;
|
border: 1px solid #2a374a;
|
||||||
@ -580,6 +849,10 @@
|
|||||||
animation: fadeInUp 0.5s ease 0.3s both;
|
animation: fadeInUp 0.5s ease 0.3s both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .overview-header
|
||||||
|
* @brief Headerbereich der Übersicht (Zentriert, Rand unten).
|
||||||
|
*/
|
||||||
.overview-header {
|
.overview-header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
/* Abstände verringert */
|
/* Abstände verringert */
|
||||||
@ -588,6 +861,10 @@
|
|||||||
border-bottom: 1px solid #2a374a;
|
border-bottom: 1px solid #2a374a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .overview-avg
|
||||||
|
* @brief Die durchschnittliche Bewertung als große Zahl.
|
||||||
|
*/
|
||||||
.overview-avg {
|
.overview-avg {
|
||||||
/* Schriftgröße der Durchschnittsnote von 2.5rem auf 2rem verkleinert */
|
/* Schriftgröße der Durchschnittsnote von 2.5rem auf 2rem verkleinert */
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
@ -599,11 +876,19 @@
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @element .overview-avg .star
|
||||||
|
* @brief Der Stern-Icon neben der Durchschnittsbewertung.
|
||||||
|
*/
|
||||||
.overview-avg .star {
|
.overview-avg .star {
|
||||||
/* Stern etwas verkleinert */
|
/* Stern etwas verkleinert */
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .overview-count
|
||||||
|
* @brief Anzahl der Gesamtbewertungen in kleinem Text.
|
||||||
|
*/
|
||||||
.overview-count {
|
.overview-count {
|
||||||
/* Textgröße leicht verkleinert */
|
/* Textgröße leicht verkleinert */
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
@ -611,6 +896,10 @@
|
|||||||
margin-top: 0.2rem;
|
margin-top: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .overview-breakdown
|
||||||
|
* @brief Container für die Verteilung (Progress-Bars) der einzelnen Sterne (5,4,3,2,1).
|
||||||
|
*/
|
||||||
.overview-breakdown {
|
.overview-breakdown {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -618,6 +907,10 @@
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .breakdown-row
|
||||||
|
* @brief Einzelne Zeile der Verteilung (Stellt z.B. 5 Sterne und deren Balken dar).
|
||||||
|
*/
|
||||||
.breakdown-row {
|
.breakdown-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -626,6 +919,10 @@
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .breakdown-stars
|
||||||
|
* @brief Beschriftung am Start des Balkens ("5 Sterne").
|
||||||
|
*/
|
||||||
.breakdown-stars {
|
.breakdown-stars {
|
||||||
width: 50px;
|
width: 50px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@ -633,6 +930,10 @@
|
|||||||
color: #cbd5e1;
|
color: #cbd5e1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .breakdown-bar-bg
|
||||||
|
* @brief Dunkler Hintergrund des Fortschrittsbalkens.
|
||||||
|
*/
|
||||||
.breakdown-bar-bg {
|
.breakdown-bar-bg {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
/* Höhe der Balken von 8px auf 6px reduziert */
|
/* Höhe der Balken von 8px auf 6px reduziert */
|
||||||
@ -642,6 +943,11 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .breakdown-bar-fill
|
||||||
|
* @brief Goldene Füllung des Balkens, je nach prozentualer Verteilung.
|
||||||
|
* @details Nutzt CSS-Transitions für einen aufbauenden Lade-Effekt.
|
||||||
|
*/
|
||||||
.breakdown-bar-fill {
|
.breakdown-bar-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #fbbf24;
|
background: #fbbf24;
|
||||||
@ -649,12 +955,20 @@
|
|||||||
transition: width 0.8s ease-out;
|
transition: width 0.8s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .breakdown-num
|
||||||
|
* @brief Absolute Anzahl der Bewertungen für diese Stern-Reihe.
|
||||||
|
*/
|
||||||
.breakdown-num {
|
.breakdown-num {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .overview-empty
|
||||||
|
* @brief Nachricht in der Übersicht, wenn keine Daten vorliegen.
|
||||||
|
*/
|
||||||
.overview-empty {
|
.overview-empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
@ -662,10 +976,17 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================================
|
/**
|
||||||
REVIEW HINZUFÜGEN (Formular)
|
* ==========================================================
|
||||||
========================================================== */
|
* @section REVIEW HINZUFÜGEN (Formular)
|
||||||
|
* @brief Bereich zum Schreiben einer eigenen Bewertung.
|
||||||
|
* ==========================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .review-add
|
||||||
|
* @brief Wrapper für das Eingabeformular.
|
||||||
|
*/
|
||||||
.review-add {
|
.review-add {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto 2rem;
|
margin: 0 auto 2rem;
|
||||||
@ -673,13 +994,22 @@
|
|||||||
animation: fadeInUp 0.5s ease 0.4s both;
|
animation: fadeInUp 0.5s ease 0.4s both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class .review-input-form
|
||||||
|
* @brief Das eigentliche Formular-Layout (vertikal).
|
||||||
|
*/
|
||||||
.review-input-form {
|
.review-input-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.2rem;
|
gap: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Sterne-Auswahl (Radio-Button Trick) --- */
|
/**
|
||||||
|
* --- Sterne-Auswahl (Radio-Button Trick) ---
|
||||||
|
* @class .rating-input
|
||||||
|
* @brief Behälter für die Radio-Buttons zur Sternbewertung.
|
||||||
|
* @details Nutzt `flex-direction: row-reverse;`, um CSS-basiertes Highlighten vorheriger Sterne durch Geschwister-Selektoren zu ermöglichen.
|
||||||
|
*/
|
||||||
.rating-input {
|
.rating-input {
|
||||||
display: flex;
|
display: flex;
|
||||||
/* Dreht die Reihenfolge um für den Hover-Effekt */
|
/* Dreht die Reihenfolge um für den Hover-Effekt */
|
||||||
@ -688,12 +1018,18 @@
|
|||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Versteckt die eigentlichen runden Radio-Buttons */
|
/**
|
||||||
|
* @element .rating-input input[type="radio"]
|
||||||
|
* @brief Die eigentlichen HTML-Radio-Elemente werden versteckt.
|
||||||
|
*/
|
||||||
.rating-input input[type="radio"] {
|
.rating-input input[type="radio"] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stylt die Labels (die Sterne) */
|
/**
|
||||||
|
* @element .rating-input label
|
||||||
|
* @brief Das angezeigte Label, das visuell als auswählbarer Stern fungiert.
|
||||||
|
*/
|
||||||
.rating-input label {
|
.rating-input label {
|
||||||
font-size: 2.2rem;
|
font-size: 2.2rem;
|
||||||
color: #475569; /* Dunkelgrau für leere Sterne */
|
color: #475569; /* Dunkelgrau für leere Sterne */
|
||||||
@ -701,19 +1037,30 @@
|
|||||||
transition: color 0.2s ease, transform 0.2s ease;
|
transition: color 0.2s ease, transform 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hover-Logik: Färbt den gehoverten/geklickten Stern und alle "folgenden" (durch row-reverse sind das die linken) */
|
/**
|
||||||
|
* @state .rating-input label:hover, ...
|
||||||
|
* @brief Hover- und Auswahl-Mechanik für die interaktiven Sterne.
|
||||||
|
* @details Wenn ein Stern gehovert oder angewählt (checked) wird, färben sich dieser und alle in der row-reverse-Reihenfolge nachfolgenden Sterne golden.
|
||||||
|
*/
|
||||||
.rating-input label:hover,
|
.rating-input label:hover,
|
||||||
.rating-input label:hover ~ label,
|
.rating-input label:hover ~ label,
|
||||||
.rating-input input[type="radio"]:checked ~ label {
|
.rating-input input[type="radio"]:checked ~ label {
|
||||||
color: #fbbf24; /* Das gleiche Gelb wie bei deinen bestehenden Sternen */
|
color: #fbbf24; /* Das gleiche Gelb wie bei deinen bestehenden Sternen */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Kleiner Pop-Effekt beim drüberfahren */
|
/**
|
||||||
|
* @state .rating-input label:hover
|
||||||
|
* @brief Kleiner Skalierungseffekt beim drüberfahren.
|
||||||
|
*/
|
||||||
.rating-input label:hover {
|
.rating-input label:hover {
|
||||||
transform: scale(1.15);
|
transform: scale(1.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Textarea --- */
|
/**
|
||||||
|
* --- Textarea ---
|
||||||
|
* @class .review-comment-input
|
||||||
|
* @brief Das Mahrzeilen-Textfeld für den eigentlichen Kommentar.
|
||||||
|
*/
|
||||||
.review-comment-input {
|
.review-comment-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: #1f2a3a;
|
background: #1f2a3a;
|
||||||
@ -727,6 +1074,10 @@
|
|||||||
transition: all var(--transition-smooth);
|
transition: all var(--transition-smooth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @state .review-comment-input:focus
|
||||||
|
* @brief Klick/Fokus-Status für das Textfeld, zeigt grünen Rahmen.
|
||||||
|
*/
|
||||||
.review-comment-input:focus {
|
.review-comment-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #4ade80; /* Passt zu deinem grünen Button-Stil */
|
border-color: #4ade80; /* Passt zu deinem grünen Button-Stil */
|
||||||
@ -734,13 +1085,20 @@
|
|||||||
box-shadow: 0 0 0 3px rgba(74, 222, 128, 0.1);
|
box-shadow: 0 0 0 3px rgba(74, 222, 128, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Button Positionierung --- */
|
/**
|
||||||
|
* --- Button Positionierung ---
|
||||||
|
* @element .review-input-form .auth__submit
|
||||||
|
* @brief Stilisierung des Absende-Buttons innerhalb der Reviews.
|
||||||
|
*/
|
||||||
.review-input-form .auth__submit {
|
.review-input-form .auth__submit {
|
||||||
align-self: flex-start; /* Button bleibt linksbündig und wird nicht über die ganze Breite gestreckt */
|
align-self: flex-start; /* Button bleibt linksbündig und wird nicht über die ganze Breite gestreckt */
|
||||||
padding: 0.8rem 2.5rem;
|
padding: 0.8rem 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Container für alle fertigen Bewertungen */
|
/**
|
||||||
|
* @class .reviews-all
|
||||||
|
* @brief Container für die Auflistung aller existierenden Bewertungen (ohne das Eingabeformular).
|
||||||
|
*/
|
||||||
.reviews-all {
|
.reviews-all {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -748,7 +1106,11 @@
|
|||||||
margin-bottom: 3rem; /* Etwas Luft nach unten zum "Hinzufügen"-Formular */
|
margin-bottom: 3rem; /* Etwas Luft nach unten zum "Hinzufügen"-Formular */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Kommentar-Text */
|
/**
|
||||||
|
* @class .review-comment
|
||||||
|
* @brief (Wiederholung/Erweiterung) Anpassung des Kommentar-Texts für Wortumbrüche.
|
||||||
|
* @details Zwingt den Browser bei überlangen Wörtern rechtzeitig umzubrechen, damit das Layout nicht explodiert.
|
||||||
|
*/
|
||||||
.review-comment {
|
.review-comment {
|
||||||
color: #cbd5e1;
|
color: #cbd5e1;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.4 KiB |
@ -1,222 +0,0 @@
|
|||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
function debounce(fn, delay) {
|
|
||||||
var t;
|
|
||||||
return function () {
|
|
||||||
var ctx = this;
|
|
||||||
var args = arguments;
|
|
||||||
clearTimeout(t);
|
|
||||||
t = setTimeout(function () { fn.apply(ctx, args); }, delay);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDropdownEl() {
|
|
||||||
var el = document.createElement('div');
|
|
||||||
el.className = 'searchDropdown';
|
|
||||||
el.hidden = true;
|
|
||||||
el.setAttribute('role', 'listbox');
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachToInput(input) {
|
|
||||||
if (!input || input.dataset.autocompleteBound === '1') return;
|
|
||||||
input.dataset.autocompleteBound = '1';
|
|
||||||
|
|
||||||
// wrapper for positioning
|
|
||||||
var wrapper = input.closest('.nav__searchField');
|
|
||||||
if (!wrapper) return;
|
|
||||||
wrapper.classList.add('nav__searchField--hasDropdown');
|
|
||||||
|
|
||||||
// dropdown
|
|
||||||
var dropdown = createDropdownEl();
|
|
||||||
wrapper.appendChild(dropdown);
|
|
||||||
|
|
||||||
var activeIndex = -1;
|
|
||||||
var items = [];
|
|
||||||
var abortController = null;
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
dropdown.hidden = true;
|
|
||||||
dropdown.innerHTML = '';
|
|
||||||
dropdown.classList.remove('is-open');
|
|
||||||
activeIndex = -1;
|
|
||||||
items = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function open() {
|
|
||||||
dropdown.hidden = false;
|
|
||||||
dropdown.classList.add('is-open');
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStatus(text) {
|
|
||||||
dropdown.innerHTML = '';
|
|
||||||
var row = document.createElement('div');
|
|
||||||
row.className = 'searchDropdown__status';
|
|
||||||
row.textContent = text;
|
|
||||||
dropdown.appendChild(row);
|
|
||||||
open();
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(list) {
|
|
||||||
dropdown.innerHTML = '';
|
|
||||||
items = list || [];
|
|
||||||
activeIndex = -1;
|
|
||||||
|
|
||||||
if (!items.length) {
|
|
||||||
setStatus('Keine Produkte gefunden.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var i = 0; i < items.length; i++) {
|
|
||||||
(function (idx) {
|
|
||||||
var it = items[idx];
|
|
||||||
|
|
||||||
var a = document.createElement('a');
|
|
||||||
a.className = 'searchDropdown__item';
|
|
||||||
a.href = it.url;
|
|
||||||
a.setAttribute('role', 'option');
|
|
||||||
a.tabIndex = -1;
|
|
||||||
|
|
||||||
var img = document.createElement('img');
|
|
||||||
img.className = 'searchDropdown__img';
|
|
||||||
img.alt = '';
|
|
||||||
img.loading = 'lazy';
|
|
||||||
img.decoding = 'async';
|
|
||||||
img.src = it.imagePath && it.imagePath.length ? it.imagePath : 'assets/images/placeholder.png';
|
|
||||||
|
|
||||||
var meta = document.createElement('div');
|
|
||||||
meta.className = 'searchDropdown__meta';
|
|
||||||
|
|
||||||
var title = document.createElement('div');
|
|
||||||
title.className = 'searchDropdown__title';
|
|
||||||
title.textContent = it.model || '';
|
|
||||||
|
|
||||||
var desc = document.createElement('div');
|
|
||||||
desc.className = 'searchDropdown__desc';
|
|
||||||
desc.textContent = it.description || '';
|
|
||||||
|
|
||||||
meta.appendChild(title);
|
|
||||||
meta.appendChild(desc);
|
|
||||||
|
|
||||||
a.appendChild(img);
|
|
||||||
a.appendChild(meta);
|
|
||||||
|
|
||||||
a.addEventListener('mouseenter', function () {
|
|
||||||
setActive(idx);
|
|
||||||
});
|
|
||||||
|
|
||||||
dropdown.appendChild(a);
|
|
||||||
})(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
open();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setActive(idx) {
|
|
||||||
var children = dropdown.querySelectorAll('.searchDropdown__item');
|
|
||||||
for (var i = 0; i < children.length; i++) {
|
|
||||||
children[i].classList.toggle('is-active', i === idx);
|
|
||||||
}
|
|
||||||
activeIndex = idx;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchResults(q) {
|
|
||||||
if (abortController) {
|
|
||||||
abortController.abort();
|
|
||||||
}
|
|
||||||
abortController = new AbortController();
|
|
||||||
|
|
||||||
var url = 'api/search_products.php?q=' + encodeURIComponent(q) + '&limit=8';
|
|
||||||
|
|
||||||
setStatus('Suche…');
|
|
||||||
|
|
||||||
fetch(url, { signal: abortController.signal, headers: { 'Accept': 'application/json' } })
|
|
||||||
.then(function (r) {
|
|
||||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
||||||
return r.json();
|
|
||||||
})
|
|
||||||
.then(function (data) {
|
|
||||||
render((data && data.items) ? data.items : []);
|
|
||||||
})
|
|
||||||
.catch(function (err) {
|
|
||||||
if (err && err.name === 'AbortError') return;
|
|
||||||
setStatus('Fehler bei der Suche.');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var debounced = debounce(function () {
|
|
||||||
var q = (input.value || '').trim();
|
|
||||||
if (q.length < 1) {
|
|
||||||
close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fetchResults(q);
|
|
||||||
}, 180);
|
|
||||||
|
|
||||||
input.addEventListener('input', debounced);
|
|
||||||
|
|
||||||
input.addEventListener('focus', function () {
|
|
||||||
var q = (input.value || '').trim();
|
|
||||||
if (q.length >= 1) {
|
|
||||||
fetchResults(q);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
input.addEventListener('keydown', function (e) {
|
|
||||||
if (dropdown.hidden) return;
|
|
||||||
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var max = items.length - 1;
|
|
||||||
if (max < 0) return;
|
|
||||||
|
|
||||||
if (e.key === 'ArrowDown') {
|
|
||||||
e.preventDefault();
|
|
||||||
var next = activeIndex + 1;
|
|
||||||
if (next > max) next = 0;
|
|
||||||
setActive(next);
|
|
||||||
} else if (e.key === 'ArrowUp') {
|
|
||||||
e.preventDefault();
|
|
||||||
var prev = activeIndex - 1;
|
|
||||||
if (prev < 0) prev = max;
|
|
||||||
setActive(prev);
|
|
||||||
} else if (e.key === 'Enter') {
|
|
||||||
if (activeIndex >= 0) {
|
|
||||||
var links = dropdown.querySelectorAll('.searchDropdown__item');
|
|
||||||
if (links[activeIndex]) {
|
|
||||||
e.preventDefault();
|
|
||||||
window.location.href = links[activeIndex].href;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('click', function (e) {
|
|
||||||
if (wrapper.contains(e.target)) return;
|
|
||||||
close();
|
|
||||||
});
|
|
||||||
|
|
||||||
// prevent blur-close when clicking inside dropdown until navigation happens
|
|
||||||
dropdown.addEventListener('mousedown', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
var inputs = document.querySelectorAll('input.nav__searchInput[name="search"]');
|
|
||||||
for (var i = 0; i < inputs.length; i++) {
|
|
||||||
attachToInput(inputs[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
|
||||||
} else {
|
|
||||||
init();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
121
attrbar.php
121
attrbar.php
@ -1,10 +1,40 @@
|
|||||||
<?php
|
<?php
|
||||||
// attrbar.php
|
/**
|
||||||
|
* @file attrbar.php
|
||||||
|
* @brief Inkludierte Datei für die Darstellung und Verarbeitung der Attribut-Filterleiste (attrbar).
|
||||||
|
*
|
||||||
|
* @details Diese Datei generiert eine dynamische Filterleiste basierend auf der aktuell
|
||||||
|
* ausgewählten Produktkategorie. Sie stellt eine Verbindung zur Datenbank her, ruft
|
||||||
|
* zugehörige Attribute ab und generiert ein HTML-Formular zur Filterung der Produkte nach spezifischen Attributen.
|
||||||
|
* Das Senden des Formulars geschieht automatisch bei einer Änderung eines Auswahlfeldes (onchange) oder über
|
||||||
|
* einen Button (falls JavaScript deaktiviert ist).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Binden der benötigten Bootstrap-Datei ein, welche grundlegende Konfigurationen und Hilfsfunktionen zur Verfügung stellt.
|
||||||
require_once __DIR__ . '/lib/bootstrap.php';
|
require_once __DIR__ . '/lib/bootstrap.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var mysqli $conn
|
||||||
|
* @brief Stellt eine Verbindung zur Datenbank her.
|
||||||
|
*/
|
||||||
$conn = db_connect();
|
$conn = db_connect();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string $currentCategory
|
||||||
|
* @brief Speichert die aktuell gesetzte Kategorie aus dem GET-Parameter. Standardwert ist 'all'.
|
||||||
|
*/
|
||||||
$currentCategory = isset($_GET['category']) ? $_GET['category'] : 'all';
|
$currentCategory = isset($_GET['category']) ? $_GET['category'] : 'all';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int|null $catId
|
||||||
|
* @brief Beinhaltet die numerische ID der aktuellen Kategorie zur Verwendung in Datenbankabfragen.
|
||||||
|
*/
|
||||||
$catId = null;
|
$catId = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array $categoriesConfig
|
||||||
|
* @brief Ein Mapping-Array, welches Kategorie-Slugs (Strings) auf deren entsprechende Datenbank-IDs (Integer) mappt.
|
||||||
|
*/
|
||||||
$categoriesConfig = [
|
$categoriesConfig = [
|
||||||
'iphone' => 20,
|
'iphone' => 20,
|
||||||
'ipad' => 21,
|
'ipad' => 21,
|
||||||
@ -13,14 +43,24 @@ $categoriesConfig = [
|
|||||||
'accessories' => 24,
|
'accessories' => 24,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Überprüfen, ob die gewählte Kategorie im Konfigurations-Array vorhanden ist.
|
||||||
if (isset($categoriesConfig[$currentCategory])) {
|
if (isset($categoriesConfig[$currentCategory])) {
|
||||||
|
// Falls vorhanden, wird die entsprechende Kategorie-ID für weitere Abfragen gesetzt.
|
||||||
$catId = $categoriesConfig[$currentCategory];
|
$catId = $categoriesConfig[$currentCategory];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch available attributes for the category if selected
|
/**
|
||||||
|
* @var array $attributes
|
||||||
|
* @brief Speichert die verfügbaren Attribute (z.B. Farbe, Speichergröße) für die selektierte Kategorie.
|
||||||
|
*/
|
||||||
$attributes = [];
|
$attributes = [];
|
||||||
|
|
||||||
|
// Attribute nur laden, wenn eine gültige Kategorie-ID gefunden wurde.
|
||||||
if ($catId) {
|
if ($catId) {
|
||||||
// Show attributes related to the active category
|
/**
|
||||||
|
* @details Holt alle Attribute, die mit der aktiven Kategorie verknüpft sind,
|
||||||
|
* sortiert nach ihrem Namen, um eine geordnete Filterliste anzuzeigen.
|
||||||
|
*/
|
||||||
$stmtAttr = $conn->prepare("
|
$stmtAttr = $conn->prepare("
|
||||||
SELECT a.attributeID, a.name, a.unit, a.dataType
|
SELECT a.attributeID, a.name, a.unit, a.dataType
|
||||||
FROM attributes a
|
FROM attributes a
|
||||||
@ -31,30 +71,51 @@ if ($catId) {
|
|||||||
$stmtAttr->bind_param("i", $catId);
|
$stmtAttr->bind_param("i", $catId);
|
||||||
$stmtAttr->execute();
|
$stmtAttr->execute();
|
||||||
$resAttr = $stmtAttr->get_result();
|
$resAttr = $stmtAttr->get_result();
|
||||||
|
|
||||||
|
// Iteriere über das Result-Set und speichere jedes gefundene Attribut im $attributes-Array.
|
||||||
while ($row = $resAttr->fetch_assoc()) {
|
while ($row = $resAttr->fetch_assoc()) {
|
||||||
$attributes[] = $row;
|
$attributes[] = $row;
|
||||||
}
|
}
|
||||||
|
// Statement schließen, um Ressourcen freizugeben.
|
||||||
$stmtAttr->close();
|
$stmtAttr->close();
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<?php if (!empty($attributes)): ?>
|
<?php
|
||||||
|
// Wenn Attribute existieren, wird der HTML-Container für die Filterleiste gerendert.
|
||||||
|
if (!empty($attributes)): ?>
|
||||||
<div class="attrbar" aria-label="Attributfilter">
|
<div class="attrbar" aria-label="Attributfilter">
|
||||||
<div class="attrbar__inner container">
|
<div class="attrbar__inner container">
|
||||||
|
<!-- Das Formular sendet Daten per GET an die index.php für die Filterung -->
|
||||||
<form action="index.php" method="GET" class="attr-filter-form">
|
<form action="index.php" method="GET" class="attr-filter-form">
|
||||||
<?php if (isset($_GET['category'])): ?>
|
|
||||||
|
<?php
|
||||||
|
// Aktuelle Kategorie-ID im Formular versteckt mitsenden, damit man in der Kategorie bleibt.
|
||||||
|
if (isset($_GET['category'])): ?>
|
||||||
<input type="hidden" name="category" value="<?= htmlspecialchars($_GET['category']) ?>">
|
<input type="hidden" name="category" value="<?= htmlspecialchars($_GET['category']) ?>">
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if (isset($_GET['search'])): ?>
|
|
||||||
|
<?php
|
||||||
|
// Einen eventuellen Suchbegriff ebenfalls beibehalten.
|
||||||
|
if (isset($_GET['search'])): ?>
|
||||||
<input type="hidden" name="search" value="<?= htmlspecialchars($_GET['search']) ?>">
|
<input type="hidden" name="search" value="<?= htmlspecialchars($_GET['search']) ?>">
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php foreach ($attributes as $attr):
|
<?php
|
||||||
|
/**
|
||||||
|
* @details Iteriert durch jedes gefundene Attribut, um ein entsprechendes
|
||||||
|
* HTML-Select-Feld mit seinen eindeutigen Werten zu generieren.
|
||||||
|
*/
|
||||||
|
foreach ($attributes as $attr):
|
||||||
|
/** @var int $attrId Die Datenbank-ID des Attributs */
|
||||||
$attrId = $attr['attributeID'];
|
$attrId = $attr['attributeID'];
|
||||||
|
|
||||||
|
/** @var string $attrName Setzt sich zusammen aus dem Namen und, falls vorhanden, der Einheit (z.B. GB) */
|
||||||
$attrName = $attr['name'] . ($attr['unit'] ? ' (' . $attr['unit'] . ')' : '');
|
$attrName = $attr['name'] . ($attr['unit'] ? ' (' . $attr['unit'] . ')' : '');
|
||||||
|
|
||||||
// Fetch distinct values for this attribute based on current category
|
// SQL-Abfrage vorbereiten, um alle unterschiedlichen (DISTINCT) Werte für genau dieses Attribut abzufragen.
|
||||||
if ($catId) {
|
if ($catId) {
|
||||||
|
// Abfrage mit Kategorie-Einschränkung
|
||||||
$vStmt = $conn->prepare("
|
$vStmt = $conn->prepare("
|
||||||
SELECT DISTINCT pa.valueString, pa.valueNumber, pa.valueBool
|
SELECT DISTINCT pa.valueString, pa.valueNumber, pa.valueBool
|
||||||
FROM productAttributes pa
|
FROM productAttributes pa
|
||||||
@ -63,6 +124,8 @@ if ($catId) {
|
|||||||
");
|
");
|
||||||
$vStmt->bind_param("ii", $catId, $attrId);
|
$vStmt->bind_param("ii", $catId, $attrId);
|
||||||
} else {
|
} else {
|
||||||
|
// Alternative Abfrage (wird in diesem Skriptteil zwar aktuell nur bei $catId aufgerufen,
|
||||||
|
// aber dient als Fallback für zukünftige globale Filter).
|
||||||
$vStmt = $conn->prepare("
|
$vStmt = $conn->prepare("
|
||||||
SELECT DISTINCT valueString, valueNumber, valueBool
|
SELECT DISTINCT valueString, valueNumber, valueBool
|
||||||
FROM productAttributes
|
FROM productAttributes
|
||||||
@ -72,14 +135,23 @@ if ($catId) {
|
|||||||
}
|
}
|
||||||
$vStmt->execute();
|
$vStmt->execute();
|
||||||
$vRes = $vStmt->get_result();
|
$vRes = $vStmt->get_result();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array $values
|
||||||
|
* @brief Speichert die extrahierten eindeutigen Werte (bereinigt und formatiert) für die Select-Box.
|
||||||
|
*/
|
||||||
$values = [];
|
$values = [];
|
||||||
|
|
||||||
|
// Durchlaufe alle gefundenen Attributwerte für die aktuellen Produkte
|
||||||
while ($vRow = $vRes->fetch_assoc()) {
|
while ($vRow = $vRes->fetch_assoc()) {
|
||||||
|
// Werte je nach Datentyp (Boolean, Number, String) extrahieren
|
||||||
if ($attr['dataType'] === 'boolean' || $vRow['valueBool'] !== null) {
|
if ($attr['dataType'] === 'boolean' || $vRow['valueBool'] !== null) {
|
||||||
$val = $vRow['valueBool'] ? 'Ja' : 'Nein';
|
$val = $vRow['valueBool'] ? 'Ja' : 'Nein';
|
||||||
|
// Duplikate vermeiden
|
||||||
if (!in_array($val, $values)) $values[] = $val;
|
if (!in_array($val, $values)) $values[] = $val;
|
||||||
} elseif ($attr['dataType'] === 'number' || $vRow['valueNumber'] !== null) {
|
} elseif ($attr['dataType'] === 'number' || $vRow['valueNumber'] !== null) {
|
||||||
$val = $vRow['valueNumber'];
|
$val = $vRow['valueNumber'];
|
||||||
// strip trailing zero for decimals if desired, e.g. 5.00 -> 5
|
// Entfernt überflüssige Nullen nach dem Komma (z.B. wird 5.00 zu 5)
|
||||||
$val = rtrim(rtrim((string)$val, '0'), '.');
|
$val = rtrim(rtrim((string)$val, '0'), '.');
|
||||||
if (!in_array($val, $values)) $values[] = $val;
|
if (!in_array($val, $values)) $values[] = $val;
|
||||||
} elseif ($vRow['valueString'] !== null) {
|
} elseif ($vRow['valueString'] !== null) {
|
||||||
@ -89,22 +161,36 @@ if ($catId) {
|
|||||||
}
|
}
|
||||||
$vStmt->close();
|
$vStmt->close();
|
||||||
|
|
||||||
// Sort values
|
/**
|
||||||
|
* @details Sortiert die gesammelten Werte.
|
||||||
|
* Zahlen werden numerisch sortiert (damit z.B. 10 nach 2 kommt),
|
||||||
|
* Strings werden alphabetisch sortiert.
|
||||||
|
*/
|
||||||
if ($attr['dataType'] === 'number') {
|
if ($attr['dataType'] === 'number') {
|
||||||
usort($values, function($a, $b) { return (float)$a <=> (float)$b; });
|
usort($values, function($a, $b) { return (float)$a <=> (float)$b; });
|
||||||
} else {
|
} else {
|
||||||
sort($values);
|
sort($values);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wenn es keine Werte für das Filterfeld gibt, überspringe dieses Attribut.
|
||||||
if (empty($values)) continue;
|
if (empty($values)) continue;
|
||||||
|
|
||||||
|
/** @var string $paramName Der Name des GET-Parameters im Formular (z.B. attr_1) */
|
||||||
$paramName = "attr_" . $attrId;
|
$paramName = "attr_" . $attrId;
|
||||||
|
|
||||||
|
/** @var string $selectedValue Der aktuell ausgewählte Wert aus dem GET-Parameter, falls gesetzt */
|
||||||
$selectedValue = isset($_GET[$paramName]) ? $_GET[$paramName] : '';
|
$selectedValue = isset($_GET[$paramName]) ? $_GET[$paramName] : '';
|
||||||
?>
|
?>
|
||||||
<div class="attr-filter-item">
|
<div class="attr-filter-item">
|
||||||
|
<!-- Label für das Attribut (z.B. Speichergröße (GB)) -->
|
||||||
<label for="attr_<?= $attrId ?>"><?= htmlspecialchars($attrName) ?>:</label>
|
<label for="attr_<?= $attrId ?>"><?= htmlspecialchars($attrName) ?>:</label>
|
||||||
|
|
||||||
|
<!-- Dropdown-Menu zur Auswahl eines Werts. onchange sumbittet das Formular sofort via JS. -->
|
||||||
<select name="<?= $paramName ?>" id="attr_<?= $attrId ?>" onchange="this.form.submit()">
|
<select name="<?= $paramName ?>" id="attr_<?= $attrId ?>" onchange="this.form.submit()">
|
||||||
<option value="">Alle</option>
|
<option value="">Alle</option>
|
||||||
<?php foreach ($values as $val): ?>
|
<?php
|
||||||
|
// Liste alle aufbereiteten Optionen auf
|
||||||
|
foreach ($values as $val): ?>
|
||||||
<option value="<?= htmlspecialchars($val) ?>" <?= (string)$val === (string)$selectedValue ? 'selected' : '' ?>>
|
<option value="<?= htmlspecialchars($val) ?>" <?= (string)$val === (string)$selectedValue ? 'selected' : '' ?>>
|
||||||
<?= htmlspecialchars($val) ?>
|
<?= htmlspecialchars($val) ?>
|
||||||
</option>
|
</option>
|
||||||
@ -113,20 +199,29 @@ if ($catId) {
|
|||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<!-- Fallback-Button, falls der Nutzer JavaScript in seinem Browser deaktiviert hat. -->
|
||||||
<noscript>
|
<noscript>
|
||||||
<button type="submit">Filtern</button>
|
<button type="submit">Filtern</button>
|
||||||
</noscript>
|
</noscript>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
// Show reset button only if at least one attr filter is active
|
/**
|
||||||
|
* @var bool $hasActiveFilter
|
||||||
|
* @brief Überprüft, ob mindestens ein Attributfilter aktiv in der URL vorkommt.
|
||||||
|
*/
|
||||||
$hasActiveFilter = false;
|
$hasActiveFilter = false;
|
||||||
foreach ($_GET as $k => $v) {
|
foreach ($_GET as $k => $v) {
|
||||||
|
// Wenn der GET-Key mit "attr_" beginnt und ein Wert gesetzt ist
|
||||||
if (strpos($k, 'attr_') === 0 && $v !== '') {
|
if (strpos($k, 'attr_') === 0 && $v !== '') {
|
||||||
$hasActiveFilter = true;
|
$hasActiveFilter = true;
|
||||||
break;
|
break; // Sobald ein Filter gefunden ist, kann die Schleife abgebrochen werden
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zeige den "Filter zurücksetzen"-Button nur an, wenn wirklich Filter aktiv sind.
|
||||||
if ($hasActiveFilter): ?>
|
if ($hasActiveFilter): ?>
|
||||||
<div class="attr-filter-action">
|
<div class="attr-filter-action">
|
||||||
|
<!-- Ein Link auf die index.php (mit Kategorie), der alle Attribut-GET-Parameter verwirft -->
|
||||||
<a href="index.php<?= isset($_GET['category']) ? '?category='.urlencode($_GET['category']) : '' ?>" class="attr-filter-reset">Filter zurücksetzen</a>
|
<a href="index.php<?= isset($_GET['category']) ? '?category='.urlencode($_GET['category']) : '' ?>" class="attr-filter-reset">Filter zurücksetzen</a>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
52
catbar.php
52
catbar.php
@ -1,28 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @file catbar.php
|
||||||
|
* @brief Category Navigation Bar / Kategorie-Navigationsleiste
|
||||||
|
*
|
||||||
|
* This file contains the HTML structure for the main category navigation bar
|
||||||
|
* used in the application. It provides links to filter products by their
|
||||||
|
* respective categories.
|
||||||
|
*
|
||||||
|
* @details
|
||||||
|
* Diese Datei enthält die HTML-Struktur für die Hauptkategorie-Navigationsleiste,
|
||||||
|
* die in der Anwendung verwendet wird. Sie stellt Links zur Verfügung, um Produkte
|
||||||
|
* nach ihren jeweiligen Kategorien (z.B. iPhone, iPad, MacBook) zu filtern.
|
||||||
|
* Jeder Link verweist auf index.php und übergibt die gewählte Kategorie als GET-Parameter.
|
||||||
|
*/
|
||||||
|
?>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
@brief Main navigation container for the home page.
|
||||||
|
@details Nutzt das HTML5 <nav> Tag für semantisch korrekte Navigation und enthält ein aria-label zur Barrierefreiheit.
|
||||||
|
-->
|
||||||
<nav class="home-nav" aria-label="Homenavigation">
|
<nav class="home-nav" aria-label="Homenavigation">
|
||||||
|
<!--
|
||||||
|
@brief Inner container to restrict width and center content
|
||||||
|
@details Beschränkt die Breite des Inhalts und zentriert diesen.
|
||||||
|
-->
|
||||||
<div class="home-nav__inner container">
|
<div class="home-nav__inner container">
|
||||||
|
<!--
|
||||||
|
@brief Unordered list representing the navigation items
|
||||||
|
@details Ungeordnete Liste, die die einzelnen Kategorielinks enthält.
|
||||||
|
-->
|
||||||
<ul class="home-nav__list">
|
<ul class="home-nav__list">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
@brief Category link for 'iPhone'
|
||||||
|
@details Führt zur Startseite mit dem Filter category=iphone
|
||||||
|
-->
|
||||||
<li class="home-nav__item">
|
<li class="home-nav__item">
|
||||||
<a href='index.php?category=iphone'>iPhone</a>
|
<a href='index.php?category=iphone'>iPhone</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
@brief Category link for 'iPad'
|
||||||
|
@details Führt zur Startseite mit dem Filter category=ipad
|
||||||
|
-->
|
||||||
<li class="home-nav__item">
|
<li class="home-nav__item">
|
||||||
<a href='index.php?category=ipad'>iPad</a>
|
<a href='index.php?category=ipad'>iPad</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
@brief Category link for 'MacBook'
|
||||||
|
@details Führt zur Startseite mit dem Filter category=macbook
|
||||||
|
-->
|
||||||
<li class="home-nav__item">
|
<li class="home-nav__item">
|
||||||
<a href='index.php?category=macbook'>MacBook</a>
|
<a href='index.php?category=macbook'>MacBook</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
@brief Category link for 'AirPods'
|
||||||
|
@details Führt zur Startseite mit dem Filter category=airpods
|
||||||
|
-->
|
||||||
<li class="home-nav__item">
|
<li class="home-nav__item">
|
||||||
<a href='index.php?category=airpods'>AirPods</a>
|
<a href='index.php?category=airpods'>AirPods</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
@brief Category link for 'Watch'
|
||||||
|
@details Führt zur Startseite mit dem Filter category=watch
|
||||||
|
-->
|
||||||
<li class="home-nav__item">
|
<li class="home-nav__item">
|
||||||
<a href='index.php?category=watch'>Watch</a>
|
<a href='index.php?category=watch'>Watch</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
@brief Category link for 'Accessories' (Zubehör)
|
||||||
|
@details Führt zur Startseite mit dem Filter category=accessories
|
||||||
|
-->
|
||||||
<li class="home-nav__item">
|
<li class="home-nav__item">
|
||||||
<a href='index.php?category=accessories'>Accessories</a>
|
<a href='index.php?category=accessories'>Accessories</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
153
compare.php
153
compare.php
@ -1,19 +1,67 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @file compare.php
|
||||||
|
* @brief Skript zur Darstellung und Verwaltung des Produktvergleichs.
|
||||||
|
*
|
||||||
|
* @details Diese Datei rendert die Produktvergleichsseite. Sie ermöglicht dem Nutzer,
|
||||||
|
* Produkte aus derselben Kategorie nebeneinander tabellarisch aufzulisten, um ihre
|
||||||
|
* Eigenschaften (Attribute, Beschreibungen etc.) zu vergleichen. Produkte, die
|
||||||
|
* zuvor zur Session-Variable 'compare' hinzugefügt wurden, werden hier ausgelesen,
|
||||||
|
* ihre Daten und zugehörigen Attribute aus der Datenbank geladen und übersichtlich
|
||||||
|
* dargestellt. Zudem bietet die Seite die Funktionalität, einzelne Produkte wieder
|
||||||
|
* aus dem Vergleich zu entfernen.
|
||||||
|
*
|
||||||
|
* Beinhaltet:
|
||||||
|
* - Session-Management für den Produktvergleich.
|
||||||
|
* - Datenbankabfragen für Kategorien, Produkte und Attribute.
|
||||||
|
* - HTML-Generierung der Vergleichstabellen.
|
||||||
|
*
|
||||||
|
* @author Geizkragen-Team
|
||||||
|
* @date 2026-04-03
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/lib/bootstrap.php';
|
require_once __DIR__ . '/lib/bootstrap.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var mysqli $conn Die globale Datenbankverbindung, die durch db_connect() erstellt wird.
|
||||||
|
*/
|
||||||
$conn = db_connect();
|
$conn = db_connect();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string $title Der Titel der Webseite, der im Header angezeigt wird.
|
||||||
|
*/
|
||||||
$title = "Produktvergleich | Geizkragen";
|
$title = "Produktvergleich | Geizkragen";
|
||||||
|
|
||||||
|
// Einbinden der Header-Datei für das Layout und die Navigation.
|
||||||
include 'header.php';
|
include 'header.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Behandelt das Entfernen eines Produkts aus dem Vergleich.
|
||||||
|
*
|
||||||
|
* Überprüft, ob ein POST-Request vorliegt und das Flag 'remove_compare' gesetzt ist.
|
||||||
|
* Ist dies der Fall, wird die übergebene Produkt-ID aus der Session-Variable
|
||||||
|
* in allen Kategorien entfernt.
|
||||||
|
*/
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['remove_compare'])) {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['remove_compare'])) {
|
||||||
|
/**
|
||||||
|
* @var int $removeId Die zu entfernende Produkt-ID, sicher in einen Integer umgewandelt.
|
||||||
|
*/
|
||||||
$removeId = (int)$_POST['remove_compare_id'];
|
$removeId = (int)$_POST['remove_compare_id'];
|
||||||
|
|
||||||
|
// Prüfen, ob die Session-Variable für Vergleiche existiert und ein Array ist.
|
||||||
if (isset($_SESSION['compare']) && is_array($_SESSION['compare'])) {
|
if (isset($_SESSION['compare']) && is_array($_SESSION['compare'])) {
|
||||||
|
// Durchsuchen aller Kategorien in der Session nach dem zu entfernenden Produkt.
|
||||||
foreach ($_SESSION['compare'] as $cat => &$prodList) {
|
foreach ($_SESSION['compare'] as $cat => &$prodList) {
|
||||||
|
/**
|
||||||
|
* @var int|false $key Der Array-Index der Produkt-ID in der aktuellen Kategorie-Liste.
|
||||||
|
*/
|
||||||
$key = array_search($removeId, $prodList);
|
$key = array_search($removeId, $prodList);
|
||||||
if ($key !== false) {
|
if ($key !== false) {
|
||||||
|
// Das Produkt wurde gefunden und wird aus dem Array entfernt.
|
||||||
unset($prodList[$key]);
|
unset($prodList[$key]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Referenz auf $prodList löschen, um unbeabsichtigte Nebeneffekte zu vermeiden.
|
||||||
unset($prodList);
|
unset($prodList);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -23,18 +71,33 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['remove_compare'])) {
|
|||||||
<h1 class="compare-title">Produktvergleich</h1>
|
<h1 class="compare-title">Produktvergleich</h1>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @var bool $hasProducts Flag, welches angibt, ob aktuell Produkte für den Vergleich vorhanden sind.
|
||||||
|
*/
|
||||||
$hasProducts = false;
|
$hasProducts = false;
|
||||||
|
|
||||||
|
// Iteration über die Session-Variable 'compare', sofern diese existiert und gültig ist.
|
||||||
if (isset($_SESSION['compare']) && is_array($_SESSION['compare'])) {
|
if (isset($_SESSION['compare']) && is_array($_SESSION['compare'])) {
|
||||||
foreach ($_SESSION['compare'] as $categoryId => $productIds) {
|
foreach ($_SESSION['compare'] as $categoryId => $productIds) {
|
||||||
|
// Wenn die Liste der Produkte für diese Kategorie leer ist, überspringen wir sie.
|
||||||
if (empty($productIds)) continue;
|
if (empty($productIds)) continue;
|
||||||
|
|
||||||
|
// Setze Flag auf true, da mindestens ein zu vergleichendes Produkt existiert.
|
||||||
$hasProducts = true;
|
$hasProducts = true;
|
||||||
|
|
||||||
// Kategorie-Namen holen
|
/**
|
||||||
|
* @var string $catName Der Standardname der Kategorie (wird überschrieben, falls in der DB gefunden).
|
||||||
|
*/
|
||||||
$catName = "Kategorie $categoryId";
|
$catName = "Kategorie $categoryId";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Holt den echten Namen der Kategorie aus der Datenbank.
|
||||||
|
*/
|
||||||
$stmtCat = $conn->prepare("SELECT name FROM categories WHERE categoryID = ?");
|
$stmtCat = $conn->prepare("SELECT name FROM categories WHERE categoryID = ?");
|
||||||
if ($stmtCat) {
|
if ($stmtCat) {
|
||||||
$stmtCat->bind_param("i", $categoryId);
|
$stmtCat->bind_param("i", $categoryId);
|
||||||
$stmtCat->execute();
|
$stmtCat->execute();
|
||||||
|
/** @var mysqli_result $resCat Das Ergebnis der Abfrage des Kategorienamens. */
|
||||||
$resCat = $stmtCat->get_result();
|
$resCat = $stmtCat->get_result();
|
||||||
if ($row = $resCat->fetch_assoc()) {
|
if ($row = $resCat->fetch_assoc()) {
|
||||||
$catName = $row['name'];
|
$catName = $row['name'];
|
||||||
@ -42,8 +105,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['remove_compare'])) {
|
|||||||
$stmtCat->close();
|
$stmtCat->close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attribute der Kategorie holen
|
/**
|
||||||
|
* @var array $attributes Sammelt alle Attribute (Eigenschaften), die zu dieser Kategorie gehören.
|
||||||
|
* Format: $attributes[attributeID] = [ 'attributeID' => ..., 'name' => ..., 'unit' => ..., 'dataType' => ... ]
|
||||||
|
*/
|
||||||
$attributes = [];
|
$attributes = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Holt die Kategorie-Attribute mit Namen, Einheit und Datentyp.
|
||||||
|
*/
|
||||||
$stmtAttr = $conn->prepare("
|
$stmtAttr = $conn->prepare("
|
||||||
SELECT a.attributeID, a.name, a.unit, a.dataType
|
SELECT a.attributeID, a.name, a.unit, a.dataType
|
||||||
FROM categoryAttributes ca
|
FROM categoryAttributes ca
|
||||||
@ -54,6 +124,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['remove_compare'])) {
|
|||||||
if ($stmtAttr) {
|
if ($stmtAttr) {
|
||||||
$stmtAttr->bind_param("i", $categoryId);
|
$stmtAttr->bind_param("i", $categoryId);
|
||||||
$stmtAttr->execute();
|
$stmtAttr->execute();
|
||||||
|
/** @var mysqli_result $resAttr Das Ergebnis der Abfrage der Kategorie-Attribute. */
|
||||||
$resAttr = $stmtAttr->get_result();
|
$resAttr = $stmtAttr->get_result();
|
||||||
while ($row = $resAttr->fetch_assoc()) {
|
while ($row = $resAttr->fetch_assoc()) {
|
||||||
$attributes[$row['attributeID']] = $row;
|
$attributes[$row['attributeID']] = $row;
|
||||||
@ -61,9 +132,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['remove_compare'])) {
|
|||||||
$stmtAttr->close();
|
$stmtAttr->close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Produktdaten holen
|
/**
|
||||||
|
* @var array $products Sammelt die Grunddaten der zugehörigen Produkte (Model, Bild, Beschreibung).
|
||||||
|
* Format: $products[productID] = [ ... Produktdaten ... ]
|
||||||
|
*/
|
||||||
$products = [];
|
$products = [];
|
||||||
|
/**
|
||||||
|
* @var string $idList Kommaseparierte Liste der zu vergleichenden Produkt-IDs, escapet als Integers für den IN-Query.
|
||||||
|
*/
|
||||||
$idList = implode(',', array_map('intval', $productIds));
|
$idList = implode(',', array_map('intval', $productIds));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Holt die Produktinformationen für die IN-Klausel Liste.
|
||||||
|
*/
|
||||||
$stmtProd = $conn->query("SELECT productID, model, imagePath, description FROM products WHERE productID IN ($idList)");
|
$stmtProd = $conn->query("SELECT productID, model, imagePath, description FROM products WHERE productID IN ($idList)");
|
||||||
if ($stmtProd) {
|
if ($stmtProd) {
|
||||||
while ($row = $stmtProd->fetch_assoc()) {
|
while ($row = $stmtProd->fetch_assoc()) {
|
||||||
@ -71,8 +152,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['remove_compare'])) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Produktattribute holen
|
/**
|
||||||
|
* @var array $productAttrVals Sammelt die spezifischen Attribut-Werte je Produkt.
|
||||||
|
* Format: $productAttrVals[productID][attributeID] = [ ... Attributwerte ... ]
|
||||||
|
*/
|
||||||
$productAttrVals = [];
|
$productAttrVals = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Holt die Werte (String, Number, Bool) der Attribute für die ausgewählten Produkte.
|
||||||
|
*/
|
||||||
$stmtProdAttr = $conn->query("SELECT productID, attributeID, valueString, valueNumber, valueBool FROM productAttributes WHERE productID IN ($idList)");
|
$stmtProdAttr = $conn->query("SELECT productID, attributeID, valueString, valueNumber, valueBool FROM productAttributes WHERE productID IN ($idList)");
|
||||||
if ($stmtProdAttr) {
|
if ($stmtProdAttr) {
|
||||||
while ($row = $stmtProdAttr->fetch_assoc()) {
|
while ($row = $stmtProdAttr->fetch_assoc()) {
|
||||||
@ -82,23 +170,34 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['remove_compare'])) {
|
|||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
|
|
||||||
|
<!-- Ausgabe des Kategorie-Titels -->
|
||||||
<h2 class="compare-category-title"><?= htmlspecialchars($catName) ?></h2>
|
<h2 class="compare-category-title"><?= htmlspecialchars($catName) ?></h2>
|
||||||
|
|
||||||
|
<!-- Beginn der Vergleichstabelle der aktuellen Kategorie -->
|
||||||
<div class="compare-table-wrapper">
|
<div class="compare-table-wrapper">
|
||||||
<table class="compare-table">
|
<table class="compare-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<!-- Die erste Spalte enthält den Titel "Eigenschaft" -->
|
||||||
<th>Eigenschaft</th>
|
<th>Eigenschaft</th>
|
||||||
|
|
||||||
|
<!-- Iteration über die Produkt-IDs zur Erzeugung der Spaltenköpfe -->
|
||||||
<?php foreach ($productIds as $pId): ?>
|
<?php foreach ($productIds as $pId): ?>
|
||||||
<?php if (!isset($products[$pId])) continue; ?>
|
<?php
|
||||||
|
// Wenn das Produkt in der DB nicht (mehr) existiert, überspringen.
|
||||||
|
if (!isset($products[$pId])) continue;
|
||||||
|
?>
|
||||||
<th>
|
<th>
|
||||||
<div>
|
<div>
|
||||||
|
<!-- Produktbild mit Link zur Produktdetailseite -->
|
||||||
<a href="productpage.php?id=<?= $pId ?>">
|
<a href="productpage.php?id=<?= $pId ?>">
|
||||||
<img src="<?= htmlspecialchars($products[$pId]['imagePath'] ?? 'assets/images/placeholder.png') ?>" alt="Produktbild" class="compare-product-img">
|
<img src="<?= htmlspecialchars($products[$pId]['imagePath'] ?? 'assets/images/placeholder.png') ?>" alt="Produktbild" class="compare-product-img">
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Produktname mit Link zur Produktdetailseite -->
|
||||||
<h3><a href="productpage.php?id=<?= $pId ?>" class="compare-product-name"><?= htmlspecialchars($products[$pId]['model']) ?></a></h3>
|
<h3><a href="productpage.php?id=<?= $pId ?>" class="compare-product-name"><?= htmlspecialchars($products[$pId]['model']) ?></a></h3>
|
||||||
|
|
||||||
|
<!-- Formular zum Entfernen dieses spezifischen Produkts aus dem Vergleich -->
|
||||||
<form method="POST" action="compare.php">
|
<form method="POST" action="compare.php">
|
||||||
<input type="hidden" name="remove_compare" value="1">
|
<input type="hidden" name="remove_compare" value="1">
|
||||||
<input type="hidden" name="remove_compare_id" value="<?= $pId ?>">
|
<input type="hidden" name="remove_compare_id" value="<?= $pId ?>">
|
||||||
@ -109,34 +208,62 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['remove_compare'])) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
<!-- Zeile für die Produktbeschreibung -->
|
||||||
<tr>
|
<tr>
|
||||||
<td>Beschreibung</td>
|
<td>Beschreibung</td>
|
||||||
<?php foreach ($productIds as $pId): ?>
|
<?php foreach ($productIds as $pId): ?>
|
||||||
<?php if (!isset($products[$pId])) continue; ?>
|
<?php
|
||||||
|
// Auch hier prüfen, ob die Produktdaten valide sind
|
||||||
|
if (!isset($products[$pId])) continue;
|
||||||
|
?>
|
||||||
<td>
|
<td>
|
||||||
|
<!-- Ausgabe der Produktbeschreibung -->
|
||||||
<span class="desc-text"><?= htmlspecialchars($products[$pId]['description']) ?></span>
|
<span class="desc-text"><?= htmlspecialchars($products[$pId]['description']) ?></span>
|
||||||
</td>
|
</td>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
<!-- Dynamische Iteration über alle dieser Kategorie zugeordneten Attribute -->
|
||||||
<?php foreach ($attributes as $attrId => $attr): ?>
|
<?php foreach ($attributes as $attrId => $attr): ?>
|
||||||
<tr>
|
<tr>
|
||||||
|
<!-- Ausgabe des Attributnamens -->
|
||||||
<td><?= htmlspecialchars($attr['name'] ?? '') ?></td>
|
<td><?= htmlspecialchars($attr['name'] ?? '') ?></td>
|
||||||
|
|
||||||
<?php foreach ($productIds as $pId): ?>
|
<?php foreach ($productIds as $pId): ?>
|
||||||
<?php if (!isset($products[$pId])) continue; ?>
|
<?php
|
||||||
|
// Existenzprüfung des Produkts
|
||||||
|
if (!isset($products[$pId])) continue;
|
||||||
|
?>
|
||||||
<td>
|
<td>
|
||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @brief Logik zur Darstellung des passenden Attributwerts.
|
||||||
|
*
|
||||||
|
* Prüft, welcher Datentyp / welches Feld im Datensatz gesetzt ist
|
||||||
|
* und wählt die angemessene Formatierung (inklusive Einheit bei Zahlen,
|
||||||
|
* 'Ja'/'Nein' bei Booleans).
|
||||||
|
*/
|
||||||
if (isset($productAttrVals[$pId][$attrId])) {
|
if (isset($productAttrVals[$pId][$attrId])) {
|
||||||
$valRow = $productAttrVals[$pId][$attrId];
|
$valRow = $productAttrVals[$pId][$attrId];
|
||||||
|
|
||||||
|
// Fall: String-Wert vorhanden
|
||||||
if (!empty($valRow['valueString'])) {
|
if (!empty($valRow['valueString'])) {
|
||||||
echo htmlspecialchars($valRow['valueString'] ?? '');
|
echo htmlspecialchars($valRow['valueString'] ?? '');
|
||||||
} elseif (!empty($valRow['valueNumber']) || $valRow['valueNumber'] === '0.00' || $valRow['valueNumber'] === 0) {
|
}
|
||||||
|
// Fall: Numerischer Wert vorhanden. '0.00' oder 0 ist ebenfalls gültig.
|
||||||
|
elseif (!empty($valRow['valueNumber']) || $valRow['valueNumber'] === '0.00' || $valRow['valueNumber'] === 0) {
|
||||||
echo htmlspecialchars((string)floatval($valRow['valueNumber'])) . " " . htmlspecialchars($attr['unit'] ?? '');
|
echo htmlspecialchars((string)floatval($valRow['valueNumber'])) . " " . htmlspecialchars($attr['unit'] ?? '');
|
||||||
} elseif ($valRow['valueBool'] !== null) {
|
}
|
||||||
|
// Fall: Boolescher Wert (wurde in DB gesetzt, nicht null)
|
||||||
|
elseif ($valRow['valueBool'] !== null) {
|
||||||
echo $valRow['valueBool'] ? 'Ja' : 'Nein';
|
echo $valRow['valueBool'] ? 'Ja' : 'Nein';
|
||||||
} else {
|
}
|
||||||
|
// Kein verwertbarer Wert vorhanden, Ausgabe eines Platzhalters.
|
||||||
|
else {
|
||||||
echo '-';
|
echo '-';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Wenn zu diesem Produkt kein Datensatz für dieses Attribut gefunden wurde.
|
||||||
echo '-';
|
echo '-';
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
@ -151,10 +278,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['remove_compare'])) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Meldung falls keine Produkte für den Vergleich gesetzt sind.
|
||||||
if (!$hasProducts) {
|
if (!$hasProducts) {
|
||||||
echo "<div class='compare-empty'><p>Du hast noch keine Produkte zum Vergleich hinzugefügt. Gehe auf eine Produktseite, um Produkte hinzuzufügen.</p></div>";
|
echo "<div class='compare-empty'><p>Du hast noch keine Produkte zum Vergleich hinzugefügt. Gehe auf eine Produktseite, um Produkte hinzuzufügen.</p></div>";
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php include 'footer.php'; ?>
|
<?php
|
||||||
|
// Einbinden der Footer-Datei am Ende der Seite.
|
||||||
|
include 'footer.php';
|
||||||
|
?>
|
||||||
|
|||||||
174
compcards.php
174
compcards.php
@ -1,28 +1,66 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @file compcards.php
|
||||||
|
* @brief Datei compcards.php
|
||||||
|
*
|
||||||
|
* @details Diese Datei ist für die Anzeige der Produktkarten auf der Übersichts- oder Suchseite zuständig.
|
||||||
|
* Sie verarbeitet Suchanfragen sowie Kategorie- und Attributfilter und generiert die entsprechende HTML-Ausgabe.
|
||||||
|
* Es werden keine Änderungen am bestehenden Code vorgenommen, lediglich diese ausführlichen Kommentare hinzugefügt.
|
||||||
|
*/
|
||||||
|
|
||||||
// login.php
|
// login.php
|
||||||
|
|
||||||
require_once __DIR__ . '/lib/bootstrap.php';
|
require_once __DIR__ . '/lib/bootstrap.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fehleranzeige für Entwicklungszwecke aktivieren.
|
||||||
|
* Hiermit werden alle Fehler, Warnungen und Hinweise direkt ausgegeben.
|
||||||
|
*/
|
||||||
ini_set('display_errors', 1);
|
ini_set('display_errors', 1);
|
||||||
ini_set('display_startup_errors', 1);
|
ini_set('display_startup_errors', 1);
|
||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
// 1) DB-Verbindung (einmal)
|
/**
|
||||||
|
* 1) DB-Verbindung (einmalig aufbauen)
|
||||||
|
* Die Funktion db_connect() muss in den eingebundenen Bibliotheken (z. B. bootstrap.php / db.php) definiert sein.
|
||||||
|
* @var mysqli $conn Die aktive Datenbankverbindung.
|
||||||
|
*/
|
||||||
$conn = db_connect();
|
$conn = db_connect();
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
// ─────────────────────────────────────────────
|
/**
|
||||||
// Reine PHP-Suche (GET ?search=...)
|
* ─────────────────────────────────────────────
|
||||||
// Wenn ein Suchbegriff vorhanden ist, zeigen wir nur Suchergebnisse
|
* Reine PHP-Suche (GET ?search=...)
|
||||||
// (statt der Kategorie-Sektionen).
|
* ─────────────────────────────────────────────
|
||||||
// ─────────────────────────────────────────────
|
* @details Wenn ein Suchbegriff über den URL-Parameter 'search' übergeben wurde,
|
||||||
|
* zeigen wir ausschließlich die Suchergebnisse an und überspringen die Anzeige
|
||||||
|
* der standardmäßigen Kategorie-Sektionen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string $searchTerm Holt den Suchbegriff aus dem GET-Parameter, trimmt überflüssige Leerzeichen.
|
||||||
|
*/
|
||||||
$searchTerm = isset($_GET['search']) ? trim((string)$_GET['search']) : '';
|
$searchTerm = isset($_GET['search']) ? trim((string)$_GET['search']) : '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int $searchLen Berechnet die Länge des Suchbegriffs (multibyte-safe, falls mb_strlen existiert).
|
||||||
|
*/
|
||||||
$searchLen = function_exists('mb_strlen') ? mb_strlen($searchTerm, 'UTF-8') : strlen($searchTerm);
|
$searchLen = function_exists('mb_strlen') ? mb_strlen($searchTerm, 'UTF-8') : strlen($searchTerm);
|
||||||
|
|
||||||
if ($searchTerm !== '') {
|
if ($searchTerm !== '') {
|
||||||
|
/**
|
||||||
|
* @details Escaping des Suchbegriffs für die LIKE-Klausel, um SQL-Injections durch Wildcards zu verhindern.
|
||||||
|
* @var string $like Der für SQL präparierte Such-String mit umschließenden %-Zeichen.
|
||||||
|
*/
|
||||||
$like = addcslashes($searchTerm, "%_\\");
|
$like = addcslashes($searchTerm, "%_\\");
|
||||||
$like = '%' . $like . '%';
|
$like = '%' . $like . '%';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @details Führt eine Suche auf die Tabelle 'products' aus.
|
||||||
|
* Es wird sowohl in der Spalte 'model' als auch in 'description' gesucht.
|
||||||
|
* @var mysqli_stmt|false $stmtSearch Das Prepared Statement für die Suche.
|
||||||
|
*/
|
||||||
$stmtSearch = $conn->prepare("
|
$stmtSearch = $conn->prepare("
|
||||||
SELECT productID, model, description, imagePath
|
SELECT productID, model, description, imagePath
|
||||||
FROM products
|
FROM products
|
||||||
@ -32,8 +70,13 @@ if ($searchTerm !== '') {
|
|||||||
");
|
");
|
||||||
|
|
||||||
if ($stmtSearch) {
|
if ($stmtSearch) {
|
||||||
|
// Parameterbindung: Zwei Strings ('ss') für die doppelten LIKE-Bedingungen.
|
||||||
$stmtSearch->bind_param('ss', $like, $like);
|
$stmtSearch->bind_param('ss', $like, $like);
|
||||||
$stmtSearch->execute();
|
$stmtSearch->execute();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var mysqli_result $resultSearch Das ResultSet der ausgeführten Suchanfrage.
|
||||||
|
*/
|
||||||
$resultSearch = $stmtSearch->get_result();
|
$resultSearch = $stmtSearch->get_result();
|
||||||
?>
|
?>
|
||||||
|
|
||||||
@ -41,18 +84,28 @@ if ($searchTerm !== '') {
|
|||||||
<h2>Suchergebnisse für „<?= htmlspecialchars($searchTerm) ?>“</h2>
|
<h2>Suchergebnisse für „<?= htmlspecialchars($searchTerm) ?>“</h2>
|
||||||
|
|
||||||
<?php if ($resultSearch->num_rows <= 0): ?>
|
<?php if ($resultSearch->num_rows <= 0): ?>
|
||||||
|
<!-- Wenn keine Ergebnisse gefunden wurden -->
|
||||||
<p class="search-empty">Keine Produkte gefunden.</p>
|
<p class="search-empty">Keine Produkte gefunden.</p>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
|
<!-- Grid für die Anzeige der Suchergebnisse -->
|
||||||
<div class="product-grid">
|
<div class="product-grid">
|
||||||
<?php while ($product = $resultSearch->fetch_assoc()): ?>
|
<?php while ($product = $resultSearch->fetch_assoc()): ?>
|
||||||
<?php $productId = (int)$product['productID']; ?>
|
<?php
|
||||||
|
/**
|
||||||
|
* @var int $productId Casting der Produkt-ID auf Integer für den Link.
|
||||||
|
*/
|
||||||
|
$productId = (int)$product['productID'];
|
||||||
|
?>
|
||||||
<a class="product-card" href="productpage.php?id=<?= $productId ?>">
|
<a class="product-card" href="productpage.php?id=<?= $productId ?>">
|
||||||
|
<!-- Anzeige des Produktbildes mit Fallback auf ein Platzhalter-Bild -->
|
||||||
<img
|
<img
|
||||||
src="<?= !empty($product['imagePath']) ? htmlspecialchars($product['imagePath']) : 'assets/images/placeholder.png' ?>"
|
src="<?= !empty($product['imagePath']) ? htmlspecialchars($product['imagePath']) : 'assets/images/placeholder.png' ?>"
|
||||||
alt="<?= htmlspecialchars($product['model'] ?? '') ?>">
|
alt="<?= htmlspecialchars($product['model'] ?? '') ?>">
|
||||||
|
|
||||||
<div class="product-card__content">
|
<div class="product-card__content">
|
||||||
|
<!-- Der Modellname des Produkts -->
|
||||||
<h3><?= htmlspecialchars($product['model'] ?? '') ?></h3>
|
<h3><?= htmlspecialchars($product['model'] ?? '') ?></h3>
|
||||||
|
<!-- Die Beschreibung des Produkts -->
|
||||||
<p><?= htmlspecialchars($product['description'] ?? '') ?></p>
|
<p><?= htmlspecialchars($product['description'] ?? '') ?></p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@ -62,86 +115,163 @@ if ($searchTerm !== '') {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
|
// Schließen des Prepared Statements zur Freigabe von Ressourcen.
|
||||||
$stmtSearch->close();
|
$stmtSearch->close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wichtig: In Suchmodus KEINE Kategorien rendern.
|
/**
|
||||||
|
* Wichtig: Im Suchmodus beenden wir die Einbindung dieser Datei hier (return),
|
||||||
|
* damit keine weiteren Kategorie-Blöcke gerendert werden.
|
||||||
|
*/
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @details Ermitteln der aktiven Kategorie. Falls keine angegeben wurde, ist 'all' der Standard.
|
||||||
|
* @var string $activeCategory Die aktive Kategorie-ID in Textform.
|
||||||
|
*/
|
||||||
$activeCategory = isset($_GET['category']) ? $_GET['category'] : 'all';
|
$activeCategory = isset($_GET['category']) ? $_GET['category'] : 'all';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @details Mapping von Kategorie-Keys zu Datenbank-IDs und lesbaren Labels.
|
||||||
|
* @var array $categories Ein assoziatives Array der verfügbaren Hauptkategorien.
|
||||||
|
*/
|
||||||
$categories = [
|
$categories = [
|
||||||
'iphone' => ['id' => 20, 'label' => 'iPhone'],
|
'iphone' => ['id' => 20, 'label' => 'iPhone'],
|
||||||
'ipad' => ['id' => 21, 'label' => 'iPad'],
|
'ipad' => ['id' => 21, 'label' => 'iPad'],
|
||||||
'macbook' => ['id' => 22, 'label' => 'MacBook'],
|
'macbook' => ['id' => 22, 'label' => 'MacBook'],
|
||||||
'airpods' => ['id' => 23, 'label' => 'AirPods'],
|
'airpods' => ['id' => 23, 'label' => 'AirPods'],
|
||||||
|
'watch' => ['id' => 25, 'label' => 'Watch'],
|
||||||
'accessories' => ['id' => 24, 'label' => 'Accessories'],
|
'accessories' => ['id' => 24, 'label' => 'Accessories'],
|
||||||
];
|
];
|
||||||
?>
|
?>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Iteration über alle definierten Kategorien, um jede als einzelnen Sektor anzuzeigen.
|
||||||
|
*/
|
||||||
|
foreach ($categories as $key => $cat):
|
||||||
|
?>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
<?php foreach ($categories as $key => $cat): ?>
|
* @details Nur dann Daten laden und Sektion anzeigen, wenn entweder alle Kategorien
|
||||||
|
* gewünscht sind oder der key mit der aktuell gewählten Kategorie übereinstimmt.
|
||||||
<?php if ($activeCategory === 'all' || $activeCategory === $key): ?>
|
*/
|
||||||
|
if ($activeCategory === 'all' || $activeCategory === $key):
|
||||||
|
?>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @var string $baseQuery Der grundlegende SELECT-Teil der dynamischen Produktsuche.
|
||||||
|
*/
|
||||||
$baseQuery = "SELECT DISTINCT p.productID, p.model, p.description, p.imagePath FROM products p ";
|
$baseQuery = "SELECT DISTINCT p.productID, p.model, p.description, p.imagePath FROM products p ";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array $whereClauses Array zum Sammeln aller WHERE-Bedingungen.
|
||||||
|
*/
|
||||||
$whereClauses = ["p.categoryID = ?"];
|
$whereClauses = ["p.categoryID = ?"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array $params Array zur Aufnahme der Bind-Parameter für das Prepared Statement.
|
||||||
|
*/
|
||||||
$params = [$cat['id']];
|
$params = [$cat['id']];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string $types Enthält den Typ-String für bind_param (z. B. 'i', 's', 'd').
|
||||||
|
*/
|
||||||
$types = "i";
|
$types = "i";
|
||||||
|
|
||||||
// Find attribute filters from $_GET
|
/**
|
||||||
|
* @details Auslesen von dynamischen Attribut-Filtern aus der URL ($_GET).
|
||||||
|
* @var int $attrIndex Zähler zur Erzeugung eindeutiger Aliase für JOINs.
|
||||||
|
*/
|
||||||
$attrIndex = 0;
|
$attrIndex = 0;
|
||||||
foreach ($_GET as $k => $v) {
|
foreach ($_GET as $k => $v) {
|
||||||
|
// Nur Parameter berücksichtigen, die auf 'attr_' beginnen und einen Wert haben.
|
||||||
if ($v !== '' && strpos($k, 'attr_') === 0) {
|
if ($v !== '' && strpos($k, 'attr_') === 0) {
|
||||||
$attrId = (int)substr($k, 5);
|
$attrId = (int)substr($k, 5);
|
||||||
$attrAlias = "pa" . $attrIndex;
|
$attrAlias = "pa" . $attrIndex;
|
||||||
|
|
||||||
|
// Dynamischer JOIN der productAttributes-Tabelle für jedes gefilterte Attribut.
|
||||||
$baseQuery .= " JOIN productAttributes $attrAlias ON p.productID = $attrAlias.productID ";
|
$baseQuery .= " JOIN productAttributes $attrAlias ON p.productID = $attrAlias.productID ";
|
||||||
|
|
||||||
// Assume string or number comparison. For simplicity, check string or number.
|
/**
|
||||||
// In DB, valueString, valueNumber, valueBool can be checked.
|
* @details Sucht im Attribut entweder nach String, Number oder Boolean ('Ja'/'Nein').
|
||||||
|
*/
|
||||||
$whereClauses[] = "($attrAlias.attributeID = ? AND ($attrAlias.valueString = ? OR $attrAlias.valueNumber = ? OR ($attrAlias.valueBool = 1 AND ? = 'Ja') OR ($attrAlias.valueBool = 0 AND ? = 'Nein')))";
|
$whereClauses[] = "($attrAlias.attributeID = ? AND ($attrAlias.valueString = ? OR $attrAlias.valueNumber = ? OR ($attrAlias.valueBool = 1 AND ? = 'Ja') OR ($attrAlias.valueBool = 0 AND ? = 'Nein')))";
|
||||||
|
|
||||||
|
// Parameter für bind_param befüllen
|
||||||
$params[] = $attrId;
|
$params[] = $attrId;
|
||||||
$params[] = $v;
|
$params[] = $v;
|
||||||
$params[] = is_numeric($v) ? (float)$v : 0;
|
$params[] = is_numeric($v) ? (float)$v : 0;
|
||||||
$params[] = $v;
|
$params[] = $v;
|
||||||
$params[] = $v;
|
$params[] = $v;
|
||||||
|
|
||||||
|
// Typen ergänzen: Integer, String, Double, String, String an SQL übergeben
|
||||||
$types .= "isdss";
|
$types .= "isdss";
|
||||||
|
|
||||||
$attrIndex++;
|
$attrIndex++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string $sql Zusammensetzen der kompletten SQL-Abfrage aus Base, JOINs und WHEREs.
|
||||||
|
*/
|
||||||
$sql = $baseQuery . " WHERE " . implode(" AND ", $whereClauses);
|
$sql = $baseQuery . " WHERE " . implode(" AND ", $whereClauses);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var mysqli_stmt $stmt Das Prepared Statement für die Kategorieabfrage.
|
||||||
|
*/
|
||||||
$stmt = $conn->prepare($sql);
|
$stmt = $conn->prepare($sql);
|
||||||
|
// Bind Parameter per Spread-Operator aus dem Params-Array.
|
||||||
$stmt->bind_param($types, ...$params);
|
$stmt->bind_param($types, ...$params);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var mysqli_result $result Das Ergebnis-Set mit den gefundenen Produkten dieser Kategorie.
|
||||||
|
*/
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<?php if ($result->num_rows > 0): ?>
|
<?php
|
||||||
|
/**
|
||||||
|
* Rendern der Produktsektion nur, falls auch Produkte in dieser Kategorie gefunden wurden.
|
||||||
|
*/
|
||||||
|
if ($result->num_rows > 0):
|
||||||
|
?>
|
||||||
<section class="product-section">
|
<section class="product-section">
|
||||||
|
<!-- Ausgabe des Kategorie-Labels als Überschrift -->
|
||||||
<h2><?= htmlspecialchars($cat['label']) ?></h2>
|
<h2><?= htmlspecialchars($cat['label']) ?></h2>
|
||||||
|
|
||||||
|
<!-- Horizontaler Scroll-Bereich für die Produktkarten einer Kategorie -->
|
||||||
<div class="product-scroll">
|
<div class="product-scroll">
|
||||||
<?php while ($product = $result->fetch_assoc()): ?>
|
<?php
|
||||||
<?php $productId = (int)$product['productID']; ?>
|
/**
|
||||||
|
* Fetch-Schleife über jedes Produkt im Result-Set.
|
||||||
|
*/
|
||||||
|
while ($product = $result->fetch_assoc()):
|
||||||
|
?>
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @var int $productId Casting der Produkt-ID
|
||||||
|
*/
|
||||||
|
$productId = (int)$product['productID'];
|
||||||
|
?>
|
||||||
<a class="product-card" href="productpage.php?id=<?= $productId ?>">
|
<a class="product-card" href="productpage.php?id=<?= $productId ?>">
|
||||||
|
<!-- Produktbild und Fallback auf Platzhalter -->
|
||||||
<img
|
<img
|
||||||
src="<?= isset($product['imagePath']) ? $product['imagePath'] : 'assets/images/placeholder.png' ?>"
|
src="<?= isset($product['imagePath']) ? $product['imagePath'] : 'assets/images/placeholder.png' ?>"
|
||||||
alt="<?= htmlspecialchars($product['model']) ?>">
|
alt="<?= htmlspecialchars($product['model']) ?>">
|
||||||
|
|
||||||
<div class="product-card__content">
|
<div class="product-card__content">
|
||||||
|
<!-- Anzeige des Produktnamens -->
|
||||||
<h3><?= htmlspecialchars($product['model']) ?></h3>
|
<h3><?= htmlspecialchars($product['model']) ?></h3>
|
||||||
|
<!-- Anzeige der Produktbeschreibung -->
|
||||||
<p><?= htmlspecialchars($product['description']) ?></p>
|
<p><?= htmlspecialchars($product['description']) ?></p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@ -150,9 +280,11 @@ $categories = [
|
|||||||
</section>
|
</section>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php $stmt->close(); ?>
|
<?php
|
||||||
|
// Schließen des Prepared Statements der Kategorie, um Ressourcen freizugeben.
|
||||||
|
$stmt->close();
|
||||||
|
?>
|
||||||
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
|||||||
42
footer.php
42
footer.php
@ -1,12 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @file footer.php
|
||||||
|
* @brief Enthält den Footer-Bereich der Geizkragen-Anwendung.
|
||||||
|
*
|
||||||
|
* Diese Datei wird am Ende der Seiten eingebunden, um den Footer darzustellen.
|
||||||
|
* Sie schließt die grundlegenden HTML-Strukturen ab und enthält das Copyright
|
||||||
|
* mit dem sich dynamisch aktualisierenden Jahr, sowie sekundäre Navigationslinks
|
||||||
|
* (wie z.B. das Impressum).
|
||||||
|
*/
|
||||||
|
?>
|
||||||
|
<!--
|
||||||
|
======================================================================
|
||||||
|
Footer Sektion
|
||||||
|
|
||||||
|
Das <footer/> Element stellt den Abschluss der Webseitenstruktur dar.
|
||||||
|
Die Rolle 'contentinfo' ist für die Barrierefreiheit (Screenreader) wichtig
|
||||||
|
und weist darauf hin, dass hier Metainformationen wie Copyright und rechtliche
|
||||||
|
Hinweise zu finden sind.
|
||||||
|
======================================================================
|
||||||
|
-->
|
||||||
<footer class="footer" role="contentinfo">
|
<footer class="footer" role="contentinfo">
|
||||||
|
<!--
|
||||||
|
Container-DIV
|
||||||
|
Dient zur Zentrierung und Breitenbegrenzung des Footer-Inhalts,
|
||||||
|
um ein konsistentes Layout zu gewährleisten.
|
||||||
|
-->
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
<!--
|
||||||
|
Copyright-Absatz
|
||||||
|
Zeigt das Copyright-Zeichen, das aktuelle Jahr (dynamisch erzeugt
|
||||||
|
durch PHP date('Y')) und den Namen der Plattform an.
|
||||||
|
-->
|
||||||
<p>© <?= date('Y') ?> Geizkragen</p>
|
<p>© <?= date('Y') ?> Geizkragen</p>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Footer-Navigation
|
||||||
|
Stellt sekundäre Links bereit.
|
||||||
|
Das Attribut aria-label="Footer Navigation" verbessert die
|
||||||
|
Barrierefreiheit (Accessibility) für assistive Technologien.
|
||||||
|
-->
|
||||||
<nav class="footer__nav" aria-label="Footer Navigation">
|
<nav class="footer__nav" aria-label="Footer Navigation">
|
||||||
|
<!-- Link zur rechtsverbindlichen Impressumsseite -->
|
||||||
<a href="impressum.php">Impressum</a>
|
<a href="impressum.php">Impressum</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Schließen der grundlegenden HTML-Tags.
|
||||||
|
Diese Tags wurden üblicherweise in der header.php geöffnet.
|
||||||
|
-->
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
108
header.php
108
header.php
@ -1,3 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @file header.php
|
||||||
|
* @brief Globale Header-Datei für die Geizkragen-Anwendung.
|
||||||
|
*
|
||||||
|
* Diese Datei bildet den initialen HTML-Kopf (Head) der Anwendung und enthält
|
||||||
|
* alle grundlegenden Meta-Tags, Stylesheets und Favicon-Definitionen.
|
||||||
|
* Zudem definiert sie die globale Navigationsleiste, inklusive Desktop- und
|
||||||
|
* Mobile-Layouts, einer globalen Produktsuche sowie dem Overlay-Menü.
|
||||||
|
*
|
||||||
|
* @details
|
||||||
|
* Folgende Kernkomponenten sind hier implementiert:
|
||||||
|
* - Einbindung aller relevanten CSS-Dateien (@see style.css, @see login.css, etc.).
|
||||||
|
* - Setup für Viewport und Mobile-Geräte (Safe Areas für Notch-Geräte).
|
||||||
|
* - Eine reaktionsfähige Navigationsleiste mit Links zu Home, Vergleich, Wunschliste und Account.
|
||||||
|
* - Ein Mobile-Menü (Off-Canvas), das per JavaScript geöffnet und geschlossen wird.
|
||||||
|
* - Suchformulare, die Benutzeranfragen per GET an die index.php übermitteln.
|
||||||
|
*
|
||||||
|
* @note Diese Datei wird üblicherweise in anderen Seiten inkludiert. Sie enthält KEINEN
|
||||||
|
* eigenständigen PHP-Logikbereich am Anfang, um direkt die HTML-Struktur zu rendern.
|
||||||
|
*/
|
||||||
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
|
|
||||||
@ -20,14 +42,27 @@
|
|||||||
<title>Geizkragen</title>
|
<title>Geizkragen</title>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* ─── Mobile tap highlight ─── */
|
/**
|
||||||
|
* @brief Standard-UI Anpassungen für mobile Geräte.
|
||||||
|
* Verhindert den blauen Tap-Highlight auf iOS und mobilen Browsern.
|
||||||
|
*/
|
||||||
* { -webkit-tap-highlight-color: transparent; }
|
* { -webkit-tap-highlight-color: transparent; }
|
||||||
|
|
||||||
/* ─── Safe areas (notch phones) ─── */
|
/**
|
||||||
|
* @brief Safe Areas für Notch-Geräte (z.B. iPhone).
|
||||||
|
* Sorgt dafür, dass der Header nicht in abgerundete Ecken oder die Kameraaussparung ragt.
|
||||||
|
*/
|
||||||
.header { padding-left: env(safe-area-inset-left); padding-right: env(safe-area-inset-right); }
|
.header { padding-left: env(safe-area-inset-left); padding-right: env(safe-area-inset-right); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Safe Area für den Footer.
|
||||||
|
*/
|
||||||
.footer { padding-bottom: env(safe-area-inset-bottom); }
|
.footer { padding-bottom: env(safe-area-inset-bottom); }
|
||||||
|
|
||||||
/* ─── Mobile overlay ─── */
|
/**
|
||||||
|
* @brief Definiert das Overlay (Hintergrund-Verdunkelung/Blur) für das Mobile-Menü.
|
||||||
|
* Es wird unsichtbar gerendert und erst sichtbar, wenn die Klasse `is-visible` hinzugefügt wird.
|
||||||
|
*/
|
||||||
.nav__overlay {
|
.nav__overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@ -39,6 +74,10 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 300ms ease, visibility 0s 300ms;
|
transition: opacity 300ms ease, visibility 0s 300ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Aktiver Zustand des Overlays. Wichtig für fließende Fade-in Übergänge.
|
||||||
|
*/
|
||||||
.nav__overlay.is-visible {
|
.nav__overlay.is-visible {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@ -48,7 +87,11 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- Overlay (klick = Menü schließen) -->
|
<!--
|
||||||
|
@brief Overlay-Container
|
||||||
|
Dient der Verdunkelung des Bildschirms beim Öffnen des mobilen Menüs.
|
||||||
|
Ein Klick auf dieses Element schließt das Menü.
|
||||||
|
-->
|
||||||
<div class="nav__overlay" id="nav-overlay"></div>
|
<div class="nav__overlay" id="nav-overlay"></div>
|
||||||
|
|
||||||
<header class="header" id="header">
|
<header class="header" id="header">
|
||||||
@ -138,7 +181,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Mobile-Suchleiste (unterhalb der Nav-Zeile, nur auf ≤900px sichtbar) -->
|
<!--
|
||||||
|
@brief Mobile-Suchleiste
|
||||||
|
Wird unterhalb der Nav-Zeile platziert und ist per CSS in der Regel nur
|
||||||
|
auf Bildschirmen mit einer Breite von <=900px sichtbar.
|
||||||
|
-->
|
||||||
<form class="nav__searchBar" action="index.php" method="GET" autocomplete="off">
|
<form class="nav__searchBar" action="index.php" method="GET" autocomplete="off">
|
||||||
<div class="nav__searchField">
|
<div class="nav__searchField">
|
||||||
<input class="nav__searchInput" type="text" name="search"
|
<input class="nav__searchInput" type="text" name="search"
|
||||||
@ -148,46 +195,69 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
/**
|
||||||
|
* @brief Kapselung der gesamten JavaScript-Logik für die Navigation.
|
||||||
|
*
|
||||||
|
* Verwendet eine IIFE (Immediately Invoked Function Expression), um globale Scope-Verschmutzung zu vermeiden.
|
||||||
|
* Handhabt das Öffnen, Schließen und die Event-Bindungen des Slide-In/Mobile-Menüs.
|
||||||
|
*/
|
||||||
(function () {
|
(function () {
|
||||||
var toggle = document.getElementById('nav-toggle');
|
// DOM Elemente für die Mobile-Menü-Steuerung
|
||||||
var closeBtn = document.getElementById('nav-close');
|
var toggle = document.getElementById('nav-toggle'); /**< Button zum Öffnen des Menüs */
|
||||||
var menu = document.getElementById('nav-menu');
|
var closeBtn = document.getElementById('nav-close'); /**< Button zum Schließen des Menüs */
|
||||||
var overlay = document.getElementById('nav-overlay');
|
var menu = document.getElementById('nav-menu'); /**< Container des Slide-In-Menüs */
|
||||||
|
var overlay = document.getElementById('nav-overlay'); /**< Das verdunkelnde Desktop-Overlay */
|
||||||
|
|
||||||
|
// Abbruch, falls eines der essentiellen Elemente fehlt
|
||||||
if (!toggle || !closeBtn || !menu || !overlay) return;
|
if (!toggle || !closeBtn || !menu || !overlay) return;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Öffnet das Mobile-Menü.
|
||||||
|
*
|
||||||
|
* Fügt dem Menü und dem Overlay die notwendigen Styling-Klassen hinzu,
|
||||||
|
* um diese sichtbar zu machen. Blockiert zudem das Scrollen der aktuellen Seite.
|
||||||
|
*/
|
||||||
function open() {
|
function open() {
|
||||||
menu.classList.add('show-menu');
|
menu.classList.add('show-menu');
|
||||||
overlay.classList.add('is-visible');
|
overlay.classList.add('is-visible');
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Schließt das Mobile-Menü.
|
||||||
|
*
|
||||||
|
* Entfernt die aktiven Zustands-Klassen und stellt das gewöhnliche
|
||||||
|
* Scroll-Verhalten (overflow) wieder her.
|
||||||
|
*/
|
||||||
function close() {
|
function close() {
|
||||||
menu.classList.remove('show-menu');
|
menu.classList.remove('show-menu');
|
||||||
overlay.classList.remove('is-visible');
|
overlay.classList.remove('is-visible');
|
||||||
document.body.style.overflow = '';
|
document.body.style.overflow = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Event-Listener für das Öffnen der Navigation
|
||||||
toggle.addEventListener('click', open);
|
toggle.addEventListener('click', open);
|
||||||
|
|
||||||
|
// Event-Listener für das Schließen der Navigation über den X-Button oder Overlay-Klick
|
||||||
closeBtn.addEventListener('click', close);
|
closeBtn.addEventListener('click', close);
|
||||||
overlay.addEventListener('click', close);
|
overlay.addEventListener('click', close);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Accessibility: Schließt das Menü via Escape-Taste.
|
||||||
|
*/
|
||||||
document.addEventListener('keydown', function (e) {
|
document.addEventListener('keydown', function (e) {
|
||||||
if (e.key === 'Escape') close();
|
if (e.key === 'Escape') close();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Links im Menü → schließen
|
/**
|
||||||
|
* @brief Automatisches Schließen des Menüs bei Link-Klick.
|
||||||
|
*
|
||||||
|
* Iteriert über alle im Menü vorhandenen Anchor ('a') Tags. Sobald ein Link
|
||||||
|
* angeklickt wird, wird das Menü geschlossen.
|
||||||
|
*/
|
||||||
var links = menu.querySelectorAll('a');
|
var links = menu.querySelectorAll('a');
|
||||||
for (var i = 0; i < links.length; i++) {
|
for (var i = 0; i < links.length; i++) {
|
||||||
links[i].addEventListener('click', close);
|
links[i].addEventListener('click', close);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
103
impressum.php
103
impressum.php
@ -1,7 +1,24 @@
|
|||||||
<?php include 'header.php'; ?>
|
<?php
|
||||||
|
/**
|
||||||
|
* @file impressum.php
|
||||||
|
* @brief Darstellung der Impressum-Seite für das Geizkragen-Projekt.
|
||||||
|
*
|
||||||
|
* @details Diese Datei generiert das Impressum, welches rechtliche Vorgaben gemäß § 5 TMG
|
||||||
|
* und § 25 MedienG erfüllt. Hier werden die Website-Inhaber, der Server-Betreiber sowie
|
||||||
|
* Informationen zu Haftungsausschluss, Urheberrecht und Datenschutz dargestellt.
|
||||||
|
* Die Anzeige erfolgt in einem responsiven CSS-Grid-Layout.
|
||||||
|
*
|
||||||
|
* @author Fabian Schieder, Paul Eisenbock
|
||||||
|
* @version 1.0
|
||||||
|
* @date 2026
|
||||||
|
*/
|
||||||
|
include 'header.php'; ?>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* ─── Impressum Styles ─── */
|
/**
|
||||||
|
* @brief Hauptcontainer für das Impressum.
|
||||||
|
* @details Setzt Abstände, zentriert den Inhalt und fügt eine Einblend-Animation (fadeInUp) hinzu.
|
||||||
|
*/
|
||||||
.impressum {
|
.impressum {
|
||||||
padding: 3rem 1.5rem 4rem;
|
padding: 3rem 1.5rem 4rem;
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
@ -14,6 +31,10 @@
|
|||||||
margin-bottom: 3rem;
|
margin-bottom: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Styling für das Helden-Icon.
|
||||||
|
* @details Definiert Größe, Form, Hintergrundverlauf und Animation des Icons.
|
||||||
|
*/
|
||||||
.impressum__hero-icon {
|
.impressum__hero-icon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -46,6 +67,10 @@
|
|||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Grid-Layout für die Impressum-Karten.
|
||||||
|
* @details Definiert ein zweispaltiges Layout mit Abstand zwischen den Karten.
|
||||||
|
*/
|
||||||
.impressum__grid {
|
.impressum__grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
@ -53,6 +78,10 @@
|
|||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Styling für die Impressum-Karten.
|
||||||
|
* @details Beinhaltet Hintergrund, Rahmen, Schatten und Animationseffekte beim Hover.
|
||||||
|
*/
|
||||||
.impressum__card {
|
.impressum__card {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-default);
|
border: 1px solid var(--border-default);
|
||||||
@ -89,6 +118,10 @@
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Styling für Icons innerhalb der Impressum-Karten.
|
||||||
|
* @details Definiert Größe, Ausrichtung und Basis-Design der runden Icons.
|
||||||
|
*/
|
||||||
.impressum__card-icon {
|
.impressum__card-icon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -100,26 +133,31 @@
|
|||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @brief Modifikator für blaue Icons (Server-Betreiber, Datenschutz) */
|
||||||
.impressum__card-icon--blue {
|
.impressum__card-icon--blue {
|
||||||
background: var(--color-primary-soft);
|
background: var(--color-primary-soft);
|
||||||
border: 1px solid rgba(59, 130, 246, 0.15);
|
border: 1px solid rgba(59, 130, 246, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @brief Modifikator für grüne Icons (Website-Inhaber) */
|
||||||
.impressum__card-icon--green {
|
.impressum__card-icon--green {
|
||||||
background: var(--color-accent-soft);
|
background: var(--color-accent-soft);
|
||||||
border: 1px solid rgba(16, 185, 129, 0.15);
|
border: 1px solid rgba(16, 185, 129, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @brief Modifikator für orange Icons (Haftungsausschluss) */
|
||||||
.impressum__card-icon--orange {
|
.impressum__card-icon--orange {
|
||||||
background: rgba(245, 158, 11, 0.1);
|
background: rgba(245, 158, 11, 0.1);
|
||||||
border: 1px solid rgba(245, 158, 11, 0.15);
|
border: 1px solid rgba(245, 158, 11, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @brief Modifikator für lila Icons (Projektinfo) */
|
||||||
.impressum__card-icon--purple {
|
.impressum__card-icon--purple {
|
||||||
background: rgba(139, 92, 246, 0.1);
|
background: rgba(139, 92, 246, 0.1);
|
||||||
border: 1px solid rgba(139, 92, 246, 0.15);
|
border: 1px solid rgba(139, 92, 246, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @brief Modifikator für rote Icons (Urheberrecht) */
|
||||||
.impressum__card-icon--red {
|
.impressum__card-icon--red {
|
||||||
background: var(--color-danger-soft);
|
background: var(--color-danger-soft);
|
||||||
border: 1px solid rgba(239, 68, 68, 0.15);
|
border: 1px solid rgba(239, 68, 68, 0.15);
|
||||||
@ -195,7 +233,10 @@
|
|||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Responsive ─── */
|
/**
|
||||||
|
* @brief Media Query für mobile Endgeräte.
|
||||||
|
* @details Bricht das zwei-spaltige Layout bei Bildschirmen unter 700px in ein ein-spaltiges Layout um.
|
||||||
|
*/
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.impressum__grid {
|
.impressum__grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@ -221,6 +262,12 @@
|
|||||||
|
|
||||||
<main class="impressum">
|
<main class="impressum">
|
||||||
|
|
||||||
|
<!-- /**
|
||||||
|
* @section Hero-Bereich
|
||||||
|
* @brief Einleitende Sektion der Seite.
|
||||||
|
* @details Beinhaltet das animierte Waage-Icon, den Haupttitel und einen kurzen Erklärungstext
|
||||||
|
* zu den rechtlichen Grundlagen des Impressums.
|
||||||
|
*/ -->
|
||||||
<!-- ═══ Hero ═══ -->
|
<!-- ═══ Hero ═══ -->
|
||||||
<div class="impressum__hero">
|
<div class="impressum__hero">
|
||||||
<div class="impressum__hero-icon">⚖️</div>
|
<div class="impressum__hero-icon">⚖️</div>
|
||||||
@ -228,8 +275,20 @@
|
|||||||
<p>Angaben gemäß § 5 TMG und § 25 MedienG – Informationen über die Betreiber dieser Website.</p>
|
<p>Angaben gemäß § 5 TMG und § 25 MedienG – Informationen über die Betreiber dieser Website.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- /**
|
||||||
|
* @section Karten-Grid
|
||||||
|
* @brief Hauptbereich mit den rechtlichen Informationen aufgeteilt in Informationskarten.
|
||||||
|
* @details Verwendet ein zweispaltiges CSS-Grid zur Darstellung unterschiedlicher Bereiche
|
||||||
|
* wie Seitenbetreiber, Server und Haftungsausschluss.
|
||||||
|
*/ -->
|
||||||
<!-- ═══ Karten-Grid ═══ -->
|
<!-- ═══ Karten-Grid ═══ -->
|
||||||
<div class="impressum__grid">
|
<div class="impressum__grid">
|
||||||
|
|
||||||
|
<!-- /**
|
||||||
|
* @subsection Website-Inhaber
|
||||||
|
* @brief Karte mit den Kontaktinformationen der Inhaber.
|
||||||
|
* @details Nimmt die volle Breite ein und listet die Details der Projektgründer Fabian Schieder und Paul Eisenbock auf.
|
||||||
|
*/ -->
|
||||||
<!-- Karte 1 – Website-Inhaber (volle Breite) -->
|
<!-- Karte 1 – Website-Inhaber (volle Breite) -->
|
||||||
<div class="impressum__card impressum__card--full">
|
<div class="impressum__card impressum__card--full">
|
||||||
<div class="impressum__card-icon impressum__card-icon--green">🌐</div>
|
<div class="impressum__card-icon impressum__card-icon--green">🌐</div>
|
||||||
@ -266,6 +325,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- /**
|
||||||
|
* @subsection Server-Betreiber
|
||||||
|
* @brief Information zum technischen Hosting der Plattform.
|
||||||
|
* @details Nennt den Verantwortlichen für die technische Bereitstellung, das Hosting und die Wartung des Servers.
|
||||||
|
*/ -->
|
||||||
<!-- Karte 2 – Server-Betreiber -->
|
<!-- Karte 2 – Server-Betreiber -->
|
||||||
<div class="impressum__card impressum__card--full">
|
<div class="impressum__card impressum__card--full">
|
||||||
<div class="impressum__card-icon impressum__card-icon--blue">🖥️</div>
|
<div class="impressum__card-icon impressum__card-icon--blue">🖥️</div>
|
||||||
@ -282,6 +346,11 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- /**
|
||||||
|
* @subsection Über das Projekt
|
||||||
|
* @brief Beschreibung des Projekts Geizkragen.
|
||||||
|
* @details Gibt Hintergrundinformationen, dass das Projekt als Schulprojekt zur Preisüberwachung entstanden ist.
|
||||||
|
*/ -->
|
||||||
<!-- Karte 3 – Projektinfo (volle Breite) -->
|
<!-- Karte 3 – Projektinfo (volle Breite) -->
|
||||||
<div class="impressum__card impressum__card--full">
|
<div class="impressum__card impressum__card--full">
|
||||||
<div class="impressum__card-icon impressum__card-icon--purple">🚀</div>
|
<div class="impressum__card-icon impressum__card-icon--purple">🚀</div>
|
||||||
@ -293,6 +362,11 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- /**
|
||||||
|
* @subsection Haftungsausschluss
|
||||||
|
* @brief Disclaimer bezüglich der angebotenen Inhalte.
|
||||||
|
* @details Schließt die Haftung für Richtigkeit, Vollständigkeit und Aktualität der Inhalte sowie für verlinkte externe Inhalte aus.
|
||||||
|
*/ -->
|
||||||
<!-- Karte 4 – Haftungsausschluss -->
|
<!-- Karte 4 – Haftungsausschluss -->
|
||||||
<div class="impressum__card">
|
<div class="impressum__card">
|
||||||
<div class="impressum__card-icon impressum__card-icon--orange">⚠️</div>
|
<div class="impressum__card-icon impressum__card-icon--orange">⚠️</div>
|
||||||
@ -308,6 +382,11 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- /**
|
||||||
|
* @subsection Urheberrecht
|
||||||
|
* @brief Regelungen bezüglich der erstellten Werke.
|
||||||
|
* @details Weist auf das österreichische Urheberrecht hin und klärt, dass eine Nutzung der Inhalte durch Dritte schriftlich genehmigt werden muss.
|
||||||
|
*/ -->
|
||||||
<!-- Karte 5 – Urheberrecht -->
|
<!-- Karte 5 – Urheberrecht -->
|
||||||
<div class="impressum__card">
|
<div class="impressum__card">
|
||||||
<div class="impressum__card-icon impressum__card-icon--red">©</div>
|
<div class="impressum__card-icon impressum__card-icon--red">©</div>
|
||||||
@ -320,6 +399,11 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- /**
|
||||||
|
* @subsection Datenschutzhinweis
|
||||||
|
* @brief Basisinformationen zur Datenerhebung.
|
||||||
|
* @details Informiert Benutzer über den Umgang mit personenbezogenen Daten und die Freiwilligkeit bei der Datenangabe.
|
||||||
|
*/ -->
|
||||||
<!-- Karte 6 – Datenschutz (volle Breite) -->
|
<!-- Karte 6 – Datenschutz (volle Breite) -->
|
||||||
<div class="impressum__card impressum__card--full">
|
<div class="impressum__card impressum__card--full">
|
||||||
<div class="impressum__card-icon impressum__card-icon--blue">🔒</div>
|
<div class="impressum__card-icon impressum__card-icon--blue">🔒</div>
|
||||||
@ -335,6 +419,11 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- /**
|
||||||
|
* @section Footer-Hinweis
|
||||||
|
* @brief Abschließender Hinweis mit Information zum Erstellungszeitpunkt.
|
||||||
|
* @details Generiert dynamisch den aktuellen Monat und das Jahr der letzten Anpassung.
|
||||||
|
*/ -->
|
||||||
<!-- ═══ Footer-Note ═══ -->
|
<!-- ═══ Footer-Note ═══ -->
|
||||||
<div class="impressum__footer-note">
|
<div class="impressum__footer-note">
|
||||||
<p>
|
<p>
|
||||||
@ -345,4 +434,10 @@
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<?php include 'footer.php'; ?>
|
<?php
|
||||||
|
/**
|
||||||
|
* @brief Einbinden des globalen Footers.
|
||||||
|
* @details Lädt die Datei footer.php, welche den Abschluss des HTML-Dokuments und weitere globale Elemente enthält.
|
||||||
|
*/
|
||||||
|
include 'footer.php';
|
||||||
|
?>
|
||||||
|
|||||||
87
index.php
87
index.php
@ -1,20 +1,97 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @file index.php
|
||||||
|
* @brief Hauptseite der Geizkragen-Anwendung.
|
||||||
|
*
|
||||||
|
* @details Diese Datei dient als Einstiegspunkt für die Benutzer. Sie lädt die grundlegenden
|
||||||
|
* Layout-Komponenten, die Navigation und zeigt, abhängig von Such- oder Kategoriefiltern,
|
||||||
|
* empfohlene Produkte oder gefilterte Listen an.
|
||||||
|
* @author Geizkragen Team
|
||||||
|
* @version 1.0.0
|
||||||
|
*
|
||||||
|
* @section description_index Beschreibung
|
||||||
|
* Dies ist die zentrale Steuerungsdatei zur Darstellung der Homepage.
|
||||||
|
* Über Includes werden Header, Kategorienleiste, Filterleiste und Produktkarten geladen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Bindet die Bootstrap-Datei ein, um die grundlegende Konfiguration und Klassenverfügbarkeit sicherzustellen.
|
||||||
|
* @details Initialisiert Laufzeitumgebung, Datenbankverbindungen, Autoloader
|
||||||
|
* und andere essentielle Parameter, die für die Laufzeit der Applikation benötigt werden.
|
||||||
|
*/
|
||||||
require_once __DIR__ . '/lib/bootstrap.php';
|
require_once __DIR__ . '/lib/bootstrap.php';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<?php include 'header.php'; ?>
|
<?php
|
||||||
<?php include 'catbar.php'; ?>
|
/**
|
||||||
<?php include 'attrbar.php'; ?>
|
* @brief Bindet das Header-Template der Seite ein.
|
||||||
|
* @details Der Header beinhaltet typischerweise den `<head>`-Bereich, Metadaten,
|
||||||
|
* CSS-Einbindungen sowie den sichtbaren Navigationskopf inklusive Logo und Menü.
|
||||||
|
*/
|
||||||
|
include 'header.php';
|
||||||
|
?>
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @brief Bindet die Kategorienleiste (catbar) für die Navigation ein.
|
||||||
|
* @details Diese Leiste ermöglicht die Hauptnavigation über Produktkategorien.
|
||||||
|
*/
|
||||||
|
include 'catbar.php';
|
||||||
|
?>
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @brief Bindet die Attribut-/Filterleiste (attrbar) ein.
|
||||||
|
* @details Erlaubt feingranulare Filterung der angezeigten Produkte nach diversen Eigenschaften,
|
||||||
|
* wie Preis, Hersteller oder Verfügbarkeit.
|
||||||
|
*/
|
||||||
|
include 'attrbar.php';
|
||||||
|
?>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @brief Ermittelt und verarbeitet den Suchbegriff aus der GET-Anfrage, falls vorhanden.
|
||||||
|
* @details Prüft das Array $_GET auf den Schlüssel 'search'. Falls dieser gesetzt ist,
|
||||||
|
* wird der Wert als String gecastet und Leerzeichen am Rand durch `trim()` entfernt.
|
||||||
|
*
|
||||||
|
* @var string $searchTerm Der bereinigte Suchbegriff für die spätere Datenbankabfrage.
|
||||||
|
*/
|
||||||
$searchTerm = isset($_GET['search']) ? trim((string)$_GET['search']) : '';
|
$searchTerm = isset($_GET['search']) ? trim((string)$_GET['search']) : '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Ermittelt und verarbeitet die ausgewählte Kategorie aus der GET-Anfrage.
|
||||||
|
* @details Fallback auf 'all', falls keine Kategorie spezifiziert wurde.
|
||||||
|
*
|
||||||
|
* @var string $activeCategory Die aktuell vom Benutzer fokussierte Kategorie.
|
||||||
|
*/
|
||||||
$activeCategory = isset($_GET['category']) ? $_GET['category'] : 'all';
|
$activeCategory = isset($_GET['category']) ? $_GET['category'] : 'all';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Prüft, ob weder ein Suchbegriff noch ein spezifischer Kategoriefilter aktiv ist (Startseiten-Bedingung).
|
||||||
|
* @details Wenn beides zutrifft, befindet sich der Benutzer auf der "nackten" Startseite
|
||||||
|
* und bekommt spezielle Werbeanzeigen oder Empfehlungen präsentiert.
|
||||||
|
*/
|
||||||
if ($searchTerm === '' && $activeCategory === 'all') {
|
if ($searchTerm === '' && $activeCategory === 'all') {
|
||||||
|
/**
|
||||||
|
* @brief Bindet die Empfehlungen bzw. das Werbebanner ein.
|
||||||
|
* @details Dient zur Kundenbindung auf der Startseite.
|
||||||
|
*/
|
||||||
include 'ad_recommendation.php';
|
include 'ad_recommendation.php';
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<?php include 'compcards.php'; ?>
|
<?php
|
||||||
|
/**
|
||||||
|
* @brief Bindet die Produktkarten zur Darstellung ein.
|
||||||
|
* @details Zentrale Komponente zur Visualisierung der Produkte. Zieht Parameter wie
|
||||||
|
* `$searchTerm` und `$activeCategory` heran, um die gefilterte Liste zu generieren.
|
||||||
|
*/
|
||||||
|
include 'compcards.php';
|
||||||
|
?>
|
||||||
|
|
||||||
<?php include 'footer.php'; ?>
|
<?php
|
||||||
|
/**
|
||||||
|
* @brief Bindet das Footer-Template der Seite ein.
|
||||||
|
* @details Beendet das HTML-Dokument und lädt JavaScripts sowie rechtliche Links
|
||||||
|
* (Impressum, Datenschutz).
|
||||||
|
*/
|
||||||
|
include 'footer.php';
|
||||||
|
?>
|
||||||
|
|||||||
350
info/skript.sql
Normal file
350
info/skript.sql
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
-- MySQL Script generated by MySQL Workbench
|
||||||
|
-- Mon Mar 30 22:13:37 2026
|
||||||
|
-- Model: New Model Version: 1.0
|
||||||
|
-- MySQL Workbench Forward Engineering
|
||||||
|
|
||||||
|
SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0;
|
||||||
|
SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0;
|
||||||
|
SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';
|
||||||
|
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
-- Schema mydb
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
-- Schema FSST
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
-- Schema FSST
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
CREATE SCHEMA IF NOT EXISTS `FSST` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci ;
|
||||||
|
USE `FSST` ;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
-- Table `FSST`.`attributes`
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `FSST`.`attributes` (
|
||||||
|
`attributeID` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` VARCHAR(255) NOT NULL,
|
||||||
|
`unit` VARCHAR(50) NULL DEFAULT NULL,
|
||||||
|
`dataType` VARCHAR(20) NULL DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`attributeID`))
|
||||||
|
ENGINE = InnoDB
|
||||||
|
AUTO_INCREMENT = 70
|
||||||
|
DEFAULT CHARACTER SET = utf8mb4
|
||||||
|
COLLATE = utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
-- Table `FSST`.`brands`
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `FSST`.`brands` (
|
||||||
|
`brandID` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` VARCHAR(255) NOT NULL,
|
||||||
|
PRIMARY KEY (`brandID`))
|
||||||
|
ENGINE = InnoDB
|
||||||
|
AUTO_INCREMENT = 8
|
||||||
|
DEFAULT CHARACTER SET = utf8mb4
|
||||||
|
COLLATE = utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
-- Table `FSST`.`categories`
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `FSST`.`categories` (
|
||||||
|
`categoryID` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` VARCHAR(255) NOT NULL,
|
||||||
|
`parentCategoryID` INT NULL DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`categoryID`),
|
||||||
|
INDEX `categories_ibfk_1` (`parentCategoryID` ASC) VISIBLE,
|
||||||
|
CONSTRAINT `categories_ibfk_1`
|
||||||
|
FOREIGN KEY (`parentCategoryID`)
|
||||||
|
REFERENCES `FSST`.`categories` (`categoryID`)
|
||||||
|
ON DELETE CASCADE)
|
||||||
|
ENGINE = InnoDB
|
||||||
|
AUTO_INCREMENT = 26
|
||||||
|
DEFAULT CHARACTER SET = utf8mb4
|
||||||
|
COLLATE = utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
-- Table `FSST`.`categoryAttributes`
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `FSST`.`categoryAttributes` (
|
||||||
|
`categoryID` INT NOT NULL,
|
||||||
|
`attributeID` INT NOT NULL,
|
||||||
|
PRIMARY KEY (`categoryID`, `attributeID`),
|
||||||
|
INDEX `attributeID` (`attributeID` ASC) VISIBLE,
|
||||||
|
CONSTRAINT `categoryAttributes_ibfk_1`
|
||||||
|
FOREIGN KEY (`categoryID`)
|
||||||
|
REFERENCES `FSST`.`categories` (`categoryID`),
|
||||||
|
CONSTRAINT `categoryAttributes_ibfk_2`
|
||||||
|
FOREIGN KEY (`attributeID`)
|
||||||
|
REFERENCES `FSST`.`attributes` (`attributeID`))
|
||||||
|
ENGINE = InnoDB
|
||||||
|
DEFAULT CHARACTER SET = utf8mb4
|
||||||
|
COLLATE = utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
-- Table `FSST`.`users`
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `FSST`.`users` (
|
||||||
|
`userID` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
`email` VARCHAR(255) NOT NULL,
|
||||||
|
`passwordHash` VARCHAR(255) NOT NULL,
|
||||||
|
`displayName` VARCHAR(255) NULL DEFAULT NULL,
|
||||||
|
`isActive` TINYINT(1) NOT NULL DEFAULT '1',
|
||||||
|
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`profilePicture` VARCHAR(255) NULL DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`userID`),
|
||||||
|
UNIQUE INDEX `email` (`email` ASC) VISIBLE)
|
||||||
|
ENGINE = InnoDB
|
||||||
|
AUTO_INCREMENT = 45
|
||||||
|
DEFAULT CHARACTER SET = utf8mb4
|
||||||
|
COLLATE = utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
-- Table `FSST`.`notifications`
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `FSST`.`notifications` (
|
||||||
|
`notificationID` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
`userID` INT NOT NULL,
|
||||||
|
`title` VARCHAR(255) NOT NULL,
|
||||||
|
`message` TEXT NOT NULL,
|
||||||
|
`isRead` TINYINT(1) NOT NULL DEFAULT '0',
|
||||||
|
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`notificationID`),
|
||||||
|
INDEX `fk_notifications_user` (`userID` ASC) VISIBLE,
|
||||||
|
CONSTRAINT `fk_notifications_user`
|
||||||
|
FOREIGN KEY (`userID`)
|
||||||
|
REFERENCES `FSST`.`users` (`userID`)
|
||||||
|
ON DELETE CASCADE)
|
||||||
|
ENGINE = InnoDB
|
||||||
|
AUTO_INCREMENT = 4
|
||||||
|
DEFAULT CHARACTER SET = utf8mb4
|
||||||
|
COLLATE = utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
-- Table `FSST`.`products`
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `FSST`.`products` (
|
||||||
|
`productID` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
`categoryID` INT NOT NULL,
|
||||||
|
`brandID` INT NOT NULL,
|
||||||
|
`model` VARCHAR(255) NOT NULL,
|
||||||
|
`description` TEXT NULL DEFAULT NULL,
|
||||||
|
`imagePath` VARCHAR(255) NULL DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`productID`),
|
||||||
|
INDEX `categoryID` (`categoryID` ASC) VISIBLE,
|
||||||
|
INDEX `brandID` (`brandID` ASC) VISIBLE,
|
||||||
|
CONSTRAINT `products_ibfk_1`
|
||||||
|
FOREIGN KEY (`categoryID`)
|
||||||
|
REFERENCES `FSST`.`categories` (`categoryID`),
|
||||||
|
CONSTRAINT `products_ibfk_2`
|
||||||
|
FOREIGN KEY (`brandID`)
|
||||||
|
REFERENCES `FSST`.`brands` (`brandID`))
|
||||||
|
ENGINE = InnoDB
|
||||||
|
AUTO_INCREMENT = 1347
|
||||||
|
DEFAULT CHARACTER SET = utf8mb4
|
||||||
|
COLLATE = utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
-- Table `FSST`.`shops`
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `FSST`.`shops` (
|
||||||
|
`shopID` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` VARCHAR(255) NOT NULL,
|
||||||
|
`website` VARCHAR(255) NULL DEFAULT NULL,
|
||||||
|
`logoPath` VARCHAR(255) NULL DEFAULT NULL,
|
||||||
|
`shippingTime` VARCHAR(255) NULL DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`shopID`))
|
||||||
|
ENGINE = InnoDB
|
||||||
|
AUTO_INCREMENT = 7
|
||||||
|
DEFAULT CHARACTER SET = utf8mb4
|
||||||
|
COLLATE = utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
-- Table `FSST`.`offers`
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `FSST`.`offers` (
|
||||||
|
`offerID` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
`productID` INT NOT NULL,
|
||||||
|
`shopID` INT NOT NULL,
|
||||||
|
`price` DECIMAL(10,2) NOT NULL,
|
||||||
|
`shippingCost` DECIMAL(10,2) NULL DEFAULT NULL,
|
||||||
|
`inStock` TINYINT(1) NULL DEFAULT NULL,
|
||||||
|
`offerURL` VARCHAR(255) NULL DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`offerID`),
|
||||||
|
INDEX `productID` (`productID` ASC) VISIBLE,
|
||||||
|
INDEX `shopID` (`shopID` ASC) VISIBLE,
|
||||||
|
CONSTRAINT `offers_ibfk_1`
|
||||||
|
FOREIGN KEY (`productID`)
|
||||||
|
REFERENCES `FSST`.`products` (`productID`),
|
||||||
|
CONSTRAINT `offers_ibfk_2`
|
||||||
|
FOREIGN KEY (`shopID`)
|
||||||
|
REFERENCES `FSST`.`shops` (`shopID`))
|
||||||
|
ENGINE = InnoDB
|
||||||
|
AUTO_INCREMENT = 10
|
||||||
|
DEFAULT CHARACTER SET = utf8mb4
|
||||||
|
COLLATE = utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
-- Table `FSST`.`priceAlerts`
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `FSST`.`priceAlerts` (
|
||||||
|
`alertID` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
`userID` INT NOT NULL,
|
||||||
|
`productID` INT NOT NULL,
|
||||||
|
`targetPrice` DECIMAL(10,2) NOT NULL,
|
||||||
|
`isActive` TINYINT(1) NOT NULL DEFAULT '1',
|
||||||
|
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`alertID`),
|
||||||
|
INDEX `fk_pricealerts_user` (`userID` ASC) VISIBLE,
|
||||||
|
INDEX `fk_pricealerts_product` (`productID` ASC) VISIBLE,
|
||||||
|
CONSTRAINT `fk_pricealerts_product`
|
||||||
|
FOREIGN KEY (`productID`)
|
||||||
|
REFERENCES `FSST`.`products` (`productID`)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_pricealerts_user`
|
||||||
|
FOREIGN KEY (`userID`)
|
||||||
|
REFERENCES `FSST`.`users` (`userID`)
|
||||||
|
ON DELETE CASCADE)
|
||||||
|
ENGINE = InnoDB
|
||||||
|
AUTO_INCREMENT = 4
|
||||||
|
DEFAULT CHARACTER SET = utf8mb4
|
||||||
|
COLLATE = utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
-- Table `FSST`.`productAttributes`
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `FSST`.`productAttributes` (
|
||||||
|
`productID` INT NOT NULL,
|
||||||
|
`attributeID` INT NOT NULL,
|
||||||
|
`valueString` VARCHAR(255) NULL DEFAULT NULL,
|
||||||
|
`valueNumber` DECIMAL(10,2) NULL DEFAULT NULL,
|
||||||
|
`valueBool` TINYINT(1) NULL DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`productID`, `attributeID`),
|
||||||
|
INDEX `attributeID` (`attributeID` ASC) VISIBLE,
|
||||||
|
CONSTRAINT `productAttributes_ibfk_1`
|
||||||
|
FOREIGN KEY (`productID`)
|
||||||
|
REFERENCES `FSST`.`products` (`productID`),
|
||||||
|
CONSTRAINT `productAttributes_ibfk_2`
|
||||||
|
FOREIGN KEY (`attributeID`)
|
||||||
|
REFERENCES `FSST`.`attributes` (`attributeID`))
|
||||||
|
ENGINE = InnoDB
|
||||||
|
DEFAULT CHARACTER SET = utf8mb4
|
||||||
|
COLLATE = utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
-- Table `FSST`.`reviews`
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `FSST`.`reviews` (
|
||||||
|
`reviewID` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
`userID` INT NOT NULL,
|
||||||
|
`productID` INT NOT NULL,
|
||||||
|
`rating` INT NOT NULL,
|
||||||
|
`comment` TEXT NULL DEFAULT NULL,
|
||||||
|
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`reviewID`),
|
||||||
|
INDEX `fk_reviews_user` (`userID` ASC) VISIBLE,
|
||||||
|
INDEX `fk_reviews_product` (`productID` ASC) VISIBLE,
|
||||||
|
CONSTRAINT `fk_reviews_product`
|
||||||
|
FOREIGN KEY (`productID`)
|
||||||
|
REFERENCES `FSST`.`products` (`productID`)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_reviews_user`
|
||||||
|
FOREIGN KEY (`userID`)
|
||||||
|
REFERENCES `FSST`.`users` (`userID`)
|
||||||
|
ON DELETE CASCADE)
|
||||||
|
ENGINE = InnoDB
|
||||||
|
AUTO_INCREMENT = 42
|
||||||
|
DEFAULT CHARACTER SET = utf8mb4
|
||||||
|
COLLATE = utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
-- Table `FSST`.`roles`
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `FSST`.`roles` (
|
||||||
|
`roleID` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` VARCHAR(50) NOT NULL,
|
||||||
|
PRIMARY KEY (`roleID`),
|
||||||
|
UNIQUE INDEX `name` (`name` ASC) VISIBLE)
|
||||||
|
ENGINE = InnoDB
|
||||||
|
AUTO_INCREMENT = 4
|
||||||
|
DEFAULT CHARACTER SET = utf8mb4
|
||||||
|
COLLATE = utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
-- Table `FSST`.`userFavorites`
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `FSST`.`userFavorites` (
|
||||||
|
`userID` INT NOT NULL,
|
||||||
|
`productID` INT NOT NULL,
|
||||||
|
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`userID`, `productID`),
|
||||||
|
INDEX `fk_favorites_product` (`productID` ASC) VISIBLE,
|
||||||
|
CONSTRAINT `fk_favorites_product`
|
||||||
|
FOREIGN KEY (`productID`)
|
||||||
|
REFERENCES `FSST`.`products` (`productID`)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_favorites_user`
|
||||||
|
FOREIGN KEY (`userID`)
|
||||||
|
REFERENCES `FSST`.`users` (`userID`)
|
||||||
|
ON DELETE CASCADE)
|
||||||
|
ENGINE = InnoDB
|
||||||
|
DEFAULT CHARACTER SET = utf8mb4
|
||||||
|
COLLATE = utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
-- Table `FSST`.`userRoles`
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `FSST`.`userRoles` (
|
||||||
|
`userID` INT NOT NULL,
|
||||||
|
`roleID` INT NOT NULL,
|
||||||
|
PRIMARY KEY (`userID`, `roleID`),
|
||||||
|
INDEX `fk_userroles_role` (`roleID` ASC) VISIBLE,
|
||||||
|
CONSTRAINT `fk_userroles_role`
|
||||||
|
FOREIGN KEY (`roleID`)
|
||||||
|
REFERENCES `FSST`.`roles` (`roleID`)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_userroles_user`
|
||||||
|
FOREIGN KEY (`userID`)
|
||||||
|
REFERENCES `FSST`.`users` (`userID`)
|
||||||
|
ON DELETE CASCADE)
|
||||||
|
ENGINE = InnoDB
|
||||||
|
DEFAULT CHARACTER SET = utf8mb4
|
||||||
|
COLLATE = utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
-- Table `FSST`.`userSessions`
|
||||||
|
-- -----------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `FSST`.`userSessions` (
|
||||||
|
`sessionID` VARCHAR(128) NOT NULL,
|
||||||
|
`userID` INT NOT NULL,
|
||||||
|
`expiresAt` TIMESTAMP NOT NULL,
|
||||||
|
PRIMARY KEY (`sessionID`),
|
||||||
|
INDEX `fk_sessions_user` (`userID` ASC) VISIBLE,
|
||||||
|
CONSTRAINT `fk_sessions_user`
|
||||||
|
FOREIGN KEY (`userID`)
|
||||||
|
REFERENCES `FSST`.`users` (`userID`)
|
||||||
|
ON DELETE CASCADE)
|
||||||
|
ENGINE = InnoDB
|
||||||
|
DEFAULT CHARACTER SET = utf8mb4
|
||||||
|
COLLATE = utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
|
||||||
|
SET SQL_MODE=@OLD_SQL_MODE;
|
||||||
|
SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS;
|
||||||
|
SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS;
|
||||||
@ -1,19 +1,48 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @file bootstrap.php
|
* @file bootstrap.php
|
||||||
* @brief Zentrale Initialisierung der Anwendung
|
* @brief Zentrale Initialisierungsdatei der Anwendung.
|
||||||
*
|
*
|
||||||
* Startet die Session, lädt die Datenbankverbindung
|
* @details Diese Datei dient als Bootstrap-Skript für die gesamte Webanwendung.
|
||||||
* und aktualisiert die Benutzerrollen.
|
* Sie ist dafür verantwortlich, grundlegende Konfigurationen vorzunehmen,
|
||||||
|
* die Session-Verwaltung zu initiieren und die Datenbankverbindung herzustellen.
|
||||||
|
* Zudem werden hier die Rollen eines aktuell angemeldeten Benutzers bei jedem
|
||||||
|
* Request frisch aus der Datenbank geladen und in der Session gespeichert,
|
||||||
|
* um eine konsistente und sichere Rechteverwaltung zu gewährleisten.
|
||||||
|
*
|
||||||
|
* @author Automatisch generierter Doxygen-Header / Erweitert durch Entwickler
|
||||||
|
* @date 2026-04-04
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einbinden der grundlegenden Datenbankfunktionen.
|
||||||
|
*
|
||||||
|
* Beinhaltet die Funktion db_connect() und weitere für den Datenbankzugriff
|
||||||
|
* notwendige Konfigurationen.
|
||||||
|
*/
|
||||||
require_once __DIR__ . '/db.php';
|
require_once __DIR__ . '/db.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktivierung der Fehlerausgabe für Entwicklungszwecke.
|
||||||
|
*
|
||||||
|
* Diese Einstellungen sorgen dafür, dass alle Arten von Fehlern, Warnungen
|
||||||
|
* und Parse-Fehlern direkt im Browser angezeigt werden. Dies ist besonders
|
||||||
|
* nützlich während der Entwicklung, sollte aber in einer Produktionsumgebung
|
||||||
|
* idealerweise deaktiviert oder durch ein entsprechendes Logging ersetzt werden.
|
||||||
|
*/
|
||||||
ini_set('display_errors', '1');
|
ini_set('display_errors', '1');
|
||||||
ini_set('display_startup_errors', '1');
|
ini_set('display_startup_errors', '1');
|
||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialisierung der PHP-Session.
|
||||||
|
*
|
||||||
|
* Es wird geprüft, ob bereits eine aktive Session existiert. Falls nicht,
|
||||||
|
* werden spezifische Cookie-Parameter gesetzt (z.B. eine Lebensdauer von 30 Tagen)
|
||||||
|
* und die Session wird gestartet. Diese Konfiguration erhöht die Sicherheit
|
||||||
|
* (mittels httponly und samesite=Lax) und verbessert das Benutzererlebnis
|
||||||
|
* durch ein langlebiges Session-Cookie.
|
||||||
|
*/
|
||||||
if (session_status() !== PHP_SESSION_ACTIVE)
|
if (session_status() !== PHP_SESSION_ACTIVE)
|
||||||
{
|
{
|
||||||
// Session-Cookie Lifetime auf 30 Tage setzen
|
// Session-Cookie Lifetime auf 30 Tage setzen
|
||||||
@ -29,32 +58,76 @@ if (session_status() !== PHP_SESSION_ACTIVE)
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Rollen bei jedem Request aus der DB aktualisieren
|
/**
|
||||||
|
* @brief Aktualisierung der Benutzerrollen aus der Datenbank.
|
||||||
|
*
|
||||||
|
* @details Dieser Block wird ausgeführt, um zu Beginn jedes HTTP-Requests
|
||||||
|
* die aktuellen Rechte/Rollen des eingeloggten Nutzers zu evaluieren.
|
||||||
|
* Ist eine `user_id` in der Session vorhanden, wird eine Datenbankabfrage
|
||||||
|
* durchgeführt, um alle verknüpften Rollen des entsprechenden Nutzers zu ermitteln.
|
||||||
|
* Die gefundenen Rollen werden danach als Array in `$_SESSION['user_roles']` gespeichert.
|
||||||
|
* Ist kein Benutzer eingeloggt, wird das Array leer gelassen.
|
||||||
|
*/
|
||||||
if (!empty($_SESSION['user_id']))
|
if (!empty($_SESSION['user_id']))
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @var mysqli $__bsConn
|
||||||
|
* @brief Die aktive Datenbankverbindung für den Bootstrap-Prozess.
|
||||||
|
*/
|
||||||
$__bsConn = db_connect();
|
$__bsConn = db_connect();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var mysqli_stmt|false $__bsStmt
|
||||||
|
* @brief Prepared Statement zum Abfragen der Benutzerrollen.
|
||||||
|
*
|
||||||
|
* Hierbei werden die Tabellen `userRoles` (ur) und `roles` (r) miteinander verknüpft,
|
||||||
|
* um anhand der `userID` die textuellen Namen der Rollen zu selektieren.
|
||||||
|
*/
|
||||||
$__bsStmt = $__bsConn->prepare('SELECT r.name FROM userRoles ur JOIN roles r ON r.roleID = ur.roleID WHERE ur.userID = ?');
|
$__bsStmt = $__bsConn->prepare('SELECT r.name FROM userRoles ur JOIN roles r ON r.roleID = ur.roleID WHERE ur.userID = ?');
|
||||||
|
|
||||||
if ($__bsStmt)
|
if ($__bsStmt)
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @var int $__bsUid
|
||||||
|
* @brief Typisierte Benutzer-ID, gecastet aus der Session.
|
||||||
|
*/
|
||||||
$__bsUid = (int)$_SESSION['user_id'];
|
$__bsUid = (int)$_SESSION['user_id'];
|
||||||
|
|
||||||
|
// Parameterbindung (Interger) an das Prepared Statement
|
||||||
$__bsStmt->bind_param('i', $__bsUid);
|
$__bsStmt->bind_param('i', $__bsUid);
|
||||||
|
|
||||||
|
// Ausführung der Abfrage
|
||||||
$__bsStmt->execute();
|
$__bsStmt->execute();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var mysqli_result $__bsResult
|
||||||
|
* @brief Das Resultset der ausgeführten Datenbankabfrage.
|
||||||
|
*/
|
||||||
$__bsResult = $__bsStmt->get_result();
|
$__bsResult = $__bsStmt->get_result();
|
||||||
|
|
||||||
|
// Reset des Rollen-Arrays in der Session
|
||||||
$_SESSION['user_roles'] = [];
|
$_SESSION['user_roles'] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iteration durch die zurückgegebenen Datensätze und Speicherung in der Session.
|
||||||
|
*/
|
||||||
while ($__bsRow = $__bsResult->fetch_assoc())
|
while ($__bsRow = $__bsResult->fetch_assoc())
|
||||||
{
|
{
|
||||||
$_SESSION['user_roles'][] = $__bsRow['name'];
|
$_SESSION['user_roles'][] = $__bsRow['name'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Schließen des Statements, um Ressourcen freizugeben
|
||||||
$__bsStmt->close();
|
$__bsStmt->close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Schließen der Datenbankverbindung nach erfolgreicher Abfrage
|
||||||
$__bsConn->close();
|
$__bsConn->close();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Für Gäste (nicht eingeloggte Benutzer) wird das Rollen-Array explizit geleert.
|
||||||
|
*/
|
||||||
$_SESSION['user_roles'] = [];
|
$_SESSION['user_roles'] = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,16 +1,72 @@
|
|||||||
<?php
|
<?php
|
||||||
// Zentrale Konfiguration
|
/**
|
||||||
|
* @file config.php
|
||||||
|
* @brief Zentrale Konfigurationsdatei für das Geizkragen-Projekt.
|
||||||
|
*
|
||||||
|
* @details Diese Datei liefert die Basis-Konfigurationseinstellungen für die gesamte Anwendung,
|
||||||
|
* insbesondere die erforderlichen Verbindungsdaten für die Datenbank. Die Werte werden primär
|
||||||
|
* über Umgebungsvariablen (Environment Variables) dynamisch ausgelesen. Sind diese Variablen
|
||||||
|
* auf dem jeweiligen System nicht gesetzt, wird auf standardisierte Fallback-Werte (Defaults)
|
||||||
|
* zurückgegriffen. Dies ermöglicht eine sichere und flexible Trennung von Quellcode und
|
||||||
|
* umgebungsspezifischen Konfigurationen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Zentrale Konfiguration der Anwendung
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Konfigurations-Array der Anwendung.
|
||||||
|
*
|
||||||
|
* @details Gibt ein mehrdimensionales, assoziatives Array zurück, welches alle
|
||||||
|
* betriebsrelevanten Konfigurationsparameter der Anwendung kapselt. Aktuell umfasst
|
||||||
|
* dies im Wesentlichen die Datenbank-Konfiguration unter dem Schlüssel 'db'.
|
||||||
|
*
|
||||||
|
* @return array Assoziatives Array mit den Systemeinstellungen.
|
||||||
|
*/
|
||||||
return [
|
return [
|
||||||
|
/**
|
||||||
|
* @brief Datenbank-Konfigurationseinstellungen.
|
||||||
|
* @details Enthält alle Parameter, die zum Aufbau einer PDO- oder MySQLi-Verbindung notwendig sind.
|
||||||
|
*/
|
||||||
'db' => [
|
'db' => [
|
||||||
'host' => getenv('GEIZKRAGEN_DB_HOST') ?: 'HOST',
|
/**
|
||||||
|
* @brief Der Hostname oder die IP-Adresse des Datenbankservers.
|
||||||
|
* @details Ausgelesen aus der Umgebungsvariable 'GEIZKRAGEN_DB_HOST'.
|
||||||
|
* Fallback ist 'localhost'.
|
||||||
|
*/
|
||||||
|
'host' => getenv('GEIZKRAGEN_DB_HOST') ?: 'localhost',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Der TCP-Port des Datenbankservers.
|
||||||
|
* @details Ausgelesen aus 'GEIZKRAGEN_DB_PORT'. Der Wert wird für Typsicherheit strikt in
|
||||||
|
* einen Integer umgewandelt. Fallback ist der Standard-MySQL/MariaDB Port 3306.
|
||||||
|
*/
|
||||||
'port' => (int)(getenv('GEIZKRAGEN_DB_PORT') ?: 3306),
|
'port' => (int)(getenv('GEIZKRAGEN_DB_PORT') ?: 3306),
|
||||||
'user' => getenv('GEIZKRAGEN_DB_USER') ?: 'USER',
|
|
||||||
'pass' => getenv('GEIZKRAGEN_DB_PASS') ?: 'PASSWORD',
|
/**
|
||||||
'name' => getenv('GEIZKRAGEN_DB_NAME') ?: 'DATABASE',
|
* @brief Der Benutzername zur Authentifizierung an der Datenbank.
|
||||||
|
* @details Ausgelesen aus 'GEIZKRAGEN_DB_USER'. Fallback ist 'FSST'.
|
||||||
|
*/
|
||||||
|
'user' => getenv('GEIZKRAGEN_DB_USER') ?: 'FSST',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Das Passwort zur Authentifizierung an der Datenbank.
|
||||||
|
* @details Ausgelesen aus 'GEIZKRAGEN_DB_PASS'. Das Fallback-Passwort dient nur für lokale
|
||||||
|
* oder vordefinierte Entwicklungsumgebungen.
|
||||||
|
*/
|
||||||
|
'pass' => getenv('GEIZKRAGEN_DB_PASS') ?: 'L9wUNZZ9Qkbt',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Der Name der verwendeten Datenbankstruktur/Schema.
|
||||||
|
* @details Ausgelesen aus 'GEIZKRAGEN_DB_NAME'. Fallback ist 'FSST'.
|
||||||
|
*/
|
||||||
|
'name' => getenv('GEIZKRAGEN_DB_NAME') ?: 'FSST',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Der verwendete Zeichensatz (Charset) für die Datenbankverbindung.
|
||||||
|
* @details Ausgelesen aus 'GEIZKRAGEN_DB_CHARSET'. Fallback ist 'utf8mb4',
|
||||||
|
* was empfohlen wird, um vollständige Unicode-Unterstützung (inkl. Emojis) zu garantieren.
|
||||||
|
*/
|
||||||
'charset' => getenv('GEIZKRAGEN_DB_CHARSET') ?: 'utf8mb4',
|
'charset' => getenv('GEIZKRAGEN_DB_CHARSET') ?: 'utf8mb4',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
83
lib/db.php
83
lib/db.php
@ -1,36 +1,107 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1); // Erzwingt strikte Typenprüfung für Parameter und Rückgabewerte -> keine automatische Typenumwandlung
|
/**
|
||||||
|
* @file db.php
|
||||||
|
* @brief Enthält grundlegende Funktionen und Initialisierungen für die Datenbankinteraktion.
|
||||||
|
*
|
||||||
|
* @details Diese Datei kapselt den gesamten Prozess des Verbindungsaufbaus zur MySQL/MariaDB-Datenbank.
|
||||||
|
* Sie nutzt die zentralen Konfigurationseinstellungen (wie Host, Benutzer, Passwort, Datenbankname und Port),
|
||||||
|
* die in der Datei config.php hinterlegt sind. Durch die Auslagerung in diese Datei wird eine einheitliche
|
||||||
|
* und zentrale Stelle für das Verbindungsmanagement geschaffen, was die Wartbarkeit, Sicherheit und
|
||||||
|
* Erweiterbarkeit des Gesamtsystems signifikant verbessert.
|
||||||
|
*
|
||||||
|
* Besonderes Augenmerk liegt auf der Fehlerbehandlung beim Verbindungsaufbau: Schlägt dieser fehl,
|
||||||
|
* so wird der Ausführungskontext sicher terminiert und ein HTTP-Statuscode 500 generiert. Zudem wird
|
||||||
|
* der Zeichensatz streng konfiguriert (meist utf8mb4), was für die fehlerfreie Verarbeitung von
|
||||||
|
* Sonderzeichen und Emojis essenziell ist und Schutz vor bestimmten SQL-Injection-Vektoren bietet.
|
||||||
|
*
|
||||||
|
* @author Geizkragen Entwicklerteam
|
||||||
|
* @version 1.1
|
||||||
|
* @date 2026-04-04
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1); ///< Erzwingt auf Sprachebene strikte Typenprüfung für Parameter und Rückgabewerte dieses Skripts. Dies verhindert fehleranfällige, implizite automatische Typenumwandlungen.
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liefert eine MySQLi-Verbindung anhand der zentralen Konfiguration.
|
* @brief Baut eine MySQLi-Datenbankverbindung anhand der zentralen Konfiguration auf und liefert diese zurück.
|
||||||
*
|
*
|
||||||
* Nutzung:
|
* @details Diese essenzielle Funktion greift auf die zentrale Konfigurationsdatei (config.php) zu,
|
||||||
* $conn = db_connect();
|
* lädt deren Parameterstruktur und initialisiert eine neue Instanz der Klasse mysqli, sofern dies
|
||||||
|
* für den aktuellen Ausführungskontext notwendig ist. Um Performance zu optimieren, nutzt sie
|
||||||
|
* das Singleton-ähnliche Konzept mittels einer statischen Variablen, sodass die Konfiguration
|
||||||
|
* (und die Datei-Lese-Operation) nur genau einmal pro Skriptlauf ausgeführt wird.
|
||||||
|
*
|
||||||
|
* @attention Bei einem Verbindungsfehler bricht das System das Skript mittels die() hart ab.
|
||||||
|
* Sensible Datenbankfehler (wie falsche Zugangsdaten) werden dabei absichtlich nicht ausgegeben,
|
||||||
|
* um so genannte Information Disclosure-Vulnerabilities zu vermeiden. Stattdessen wird lediglich
|
||||||
|
* der neutrale Text "Datenbankfehler" gezeigt und der HTTP Header auf Status 500 gesetzt.
|
||||||
|
*
|
||||||
|
* @b Anwendungsbeispiel:
|
||||||
|
* @code
|
||||||
|
* // Verbindung zur Datenbank aufbauen
|
||||||
|
* $mysqli_connection = db_connect();
|
||||||
|
*
|
||||||
|
* // Abfrage ausführen
|
||||||
|
* $result = $mysqli_connection->query("SELECT * FROM users");
|
||||||
|
* @endcode
|
||||||
|
*
|
||||||
|
* @return mysqli Das erfolgreich aufgebaute und vollständig konfigurierte Datenbankverbindungs-Objekt.
|
||||||
|
*
|
||||||
|
* @throws Exception Löst im Hintergrund potenziell Ausnahmen aus, fängt diese durch manuelle Fehlerabfragen ($conn->connect_error) aber sauber ab.
|
||||||
*/
|
*/
|
||||||
function db_connect(): mysqli
|
function db_connect(): mysqli
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @var array|null $cfg Statische Variable zur Wiederverwendung der Systemkonfiguration.
|
||||||
|
* Durch das Schlüsselwort 'static' behält diese Variable ihren Wert auch über
|
||||||
|
* multiple Funktionsaufrufe hinweg und verhindert mehrfaches Einlesen der config.php.
|
||||||
|
*/
|
||||||
static $cfg;
|
static $cfg;
|
||||||
|
|
||||||
|
/// Prüft, ob die Konfiguration im aktuellen Skriptlauf bereits geladen und im Speicher abgelegt wurde.
|
||||||
if ($cfg === null)
|
if ($cfg === null)
|
||||||
{
|
{
|
||||||
/** @var array{db: array{host:string,port:int,user:string,pass:string,name:string,charset:string}} $cfg */
|
/**
|
||||||
|
* @var array{db: array{host:string,port:int,user:string,pass:string,name:string,charset:string}} $cfg
|
||||||
|
* @brief Bindet die Konfigurationsdaten ein und lädt das rückgegebene Array in die statische Variable $cfg.
|
||||||
|
* Der Dateipfad wird hierbei relativ und sicher über die magische Konstante __DIR__ dynamisch aufgelöst.
|
||||||
|
*/
|
||||||
$cfg = require __DIR__ . '/config.php';
|
$cfg = require __DIR__ . '/config.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extrahiert aus Leistungs- und Lesbarkeitsgründen den spezifischen Datenbankbereich der geladenen Gesamt-Konfiguration in die lokale Variable $db.
|
||||||
$db = $cfg['db'];
|
$db = $cfg['db'];
|
||||||
|
|
||||||
|
/// Initialisiert direkt eine neue, native mysqli-Verbindung mit den extrahierten Zugangsdaten (Host, Benutzer, Passwort, DB-Name, Port).
|
||||||
$conn = new mysqli($db['host'], $db['user'], $db['pass'], $db['name'], $db['port']);
|
$conn = new mysqli($db['host'], $db['user'], $db['pass'], $db['name'], $db['port']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Überprüft unmittelbar, ob beim Versuch des Verbindungsaufbaus durch die MySQLi-Extension ein Fehler aufgetreten ist.
|
||||||
|
* Wenn der Wert von $conn->connect_error einen String enthält (und somit nicht null ist), war der Verbindungsversuch erfolglos.
|
||||||
|
*/
|
||||||
if ($conn->connect_error)
|
if ($conn->connect_error)
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @brief Ein fataler Fehler ist aufgetreten: Setzt den HTTP-Response-Statuscode auf 500 (Internal Server Error).
|
||||||
|
* Dies teilt dem Browser oder API-Konsumenten strukturiert mit, dass der Server ein internes Problem hat.
|
||||||
|
*/
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Bricht die Ausführung des gesamten PHP-Skripts unwiderruflich ab.
|
||||||
|
* Aus Sicherheitsgründen wird dem Client lediglich eine maskierte, generelle String-Fehlermeldung ("Datenbankfehler") ausgegeben.
|
||||||
|
*/
|
||||||
die('Datenbankfehler');
|
die('Datenbankfehler');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Einheitliches Charset (wichtig für Umlaute/Emojis & Sicherheit)
|
/**
|
||||||
|
* @brief Setzt für die nun erfolgreich initiierte Verbindung ein einheitliches, konfiguriertes Charset.
|
||||||
|
* Dies ist (insbesondere bei utf8mb4) aus Sicherheits- und Stabilitätsgründen äußerst wichtig, um Encoding-Probleme,
|
||||||
|
* abgeschnittene Texte und potenzielle SQL-Sicherheitslücken beim Escaping vollständig auszuschließen.
|
||||||
|
*/
|
||||||
$conn->set_charset($db['charset']);
|
$conn->set_charset($db['charset']);
|
||||||
|
|
||||||
|
/// Der Aufbau war erfolgreich. Gibt die einsatzbereite und abgesicherte MySQLi-Klasseninstanz an den aufrufenden Kontext zurück.
|
||||||
return $conn;
|
return $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,45 +1,83 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @file strings.php
|
||||||
|
* @brief Enthält String-Hilfsfunktionen ohne harte Abhängigkeit von Erweiterungen wie mbstring.
|
||||||
|
*
|
||||||
|
* @details Diese Datei stellt nützliche Helfer-Funktionen für die Zeichenkettenverarbeitung bereit,
|
||||||
|
* insbesondere zur Ermittlung der tatsächlichen Zeichenlänge in UTF-8. Das Hauptziel ist es,
|
||||||
|
* Längenvalidierungen zeichenbasiert anstatt bytebasiert durchzuführen, um korrekte Ergebnisse
|
||||||
|
* in einer Multibyte-Umgebung wie UTF-8 zu gewährleisten, selbst wenn bestimmte PHP-Extensions fehlen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
// lib/strings.php
|
// lib/strings.php
|
||||||
// Kleine String-Helper ohne harte Abhängigkeit von mbstring.
|
// Kleine String-Helper ohne harte Abhängigkeit von mbstring.
|
||||||
// Ziel: Längenvalidierung möglichst „zeichenbasiert“ und UTF-8-tauglich.
|
// Ziel: Längenvalidierung möglichst „zeichenbasiert“ und UTF-8-tauglich.
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liefert die Länge eines Strings in Zeichen (nicht Bytes), wenn möglich.
|
* @brief Ermittelt die Länge eines Strings in tatsächlichen Zeichen (nicht Bytes), wenn möglich.
|
||||||
*
|
*
|
||||||
* Priorität:
|
* @details Diese Funktion versucht, die genaueste und sicherste Methode zur Bestimmung der
|
||||||
* 1) mb_strlen (mbstring)
|
* Zeichenanzahl eines Strings in einer UTF-8-codierten Multibyte-Zeichenkette zu verwenden.
|
||||||
* 2) grapheme_strlen (intl)
|
* Dabei bedient sie sich mehrerer Mechanismen in einer festgelegten Prioritätenfolge:
|
||||||
* 3) UTF-8 Codepoint-Zählung via PCRE
|
* - Priorität 1: Nutzung von mb_strlen() (sofern die mbstring-Erweiterung aktiv ist).
|
||||||
* 4) Fallback: strlen (Bytes)
|
* - Priorität 2: Nutzung von grapheme_strlen() (sofern die intl-Erweiterung aktiv ist).
|
||||||
|
* - Priorität 3: Nutzung regulärer Ausdrücke zur UTF-8 Codepoint-Zählung via PCRE.
|
||||||
|
* - Priorität 4: Fallback auf die native strlen()-Funktion (welche lediglich Bytes zählt, was
|
||||||
|
* bei Multibyte-Zeichen ungenau ist, aber sicher nicht fehlschlägt).
|
||||||
|
*
|
||||||
|
* @param string $s Die Eingabe-Zeichenkette, deren Länge bestimmt werden soll.
|
||||||
|
*
|
||||||
|
* @return int Die ermittelte Länge der Zeichenkette. Im Idealfall entspricht dies der Anzahl der Zeichen.
|
||||||
|
* Im Fallback-Szenario entspricht dies der Anzahl der Bytes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function str_length(string $s): int
|
function str_length(string $s): int
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Prüfen, ob die Funktion mb_strlen verfügbar ist.
|
||||||
|
* Dies ist die sicherste und schnellste Methode, da sie für Multibyte-Strings gemacht ist.
|
||||||
|
*/
|
||||||
if (function_exists('mb_strlen'))
|
if (function_exists('mb_strlen'))
|
||||||
{
|
{
|
||||||
|
// Rückgabe der Zeichenlänge unter expliziter Angabe des Encodings UTF-8.
|
||||||
return (int)mb_strlen($s, 'UTF-8');
|
return (int)mb_strlen($s, 'UTF-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Falls mb_strlen nicht verfügbar ist, prüfen wir, ob die intl-Extension aktiviert ist
|
||||||
|
* und grapheme_strlen zur Verfügung stellt.
|
||||||
|
*/
|
||||||
if (function_exists('grapheme_strlen'))
|
if (function_exists('grapheme_strlen'))
|
||||||
{
|
{
|
||||||
|
// Ermittlung der Anzahl der Grapheme in der Zeichenfolge.
|
||||||
$len = grapheme_strlen($s);
|
$len = grapheme_strlen($s);
|
||||||
|
|
||||||
|
// Überprüfung, ob ein gültiges Ergebnis zurückgeliefert wurde (nicht false).
|
||||||
if ($len !== false)
|
if ($len !== false)
|
||||||
{
|
{
|
||||||
return (int)$len;
|
return (int)$len;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UTF-8 Codepoints zählen (best-effort ohne Extensions)
|
/**
|
||||||
|
* Falls weder mb_strlen noch grapheme_strlen nutzbar sind,
|
||||||
|
* versuchen wir die UTF-8 Codepoints manuell via Regex (PCRE) zu zählen.
|
||||||
|
*/
|
||||||
$m = [];
|
$m = [];
|
||||||
|
// Der Modifier 'u' steht für UTF-8-Behandlung, 's' sorgt dafür, dass der Punkt (.) auch Zeilenumbrüche matched.
|
||||||
$ok = @preg_match_all('/./us', $s, $m);
|
$ok = @preg_match_all('/./us', $s, $m);
|
||||||
|
|
||||||
|
// Wenn preg_match_all erfolgreich war, enthält $ok die Anzahl der Matches (also die Zeichenlänge).
|
||||||
if ($ok !== false)
|
if ($ok !== false)
|
||||||
{
|
{
|
||||||
return (int)$ok;
|
return (int)$ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Letzter Fallback (Bytes)
|
/**
|
||||||
|
* Letzter Fallback-Mechanismus:
|
||||||
|
* Wenn alle UTF-8 spezifischen Ansätze fehlschlagen, zählen wir einfach die Bytes.
|
||||||
|
* Dies kann bei Zeichenketten mit Sonderzeichen zu längeren Ergebnissen führen,
|
||||||
|
* stellt aber sicher, dass in jedem Fall eine Ganzzahl (int) zurückgegeben wird.
|
||||||
|
*/
|
||||||
return strlen($s);
|
return strlen($s);
|
||||||
}
|
}
|
||||||
|
|||||||
108
login.php
108
login.php
@ -1,103 +1,180 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @file login.php
|
||||||
|
* @brief Steuert den Login-Vorgang und baut eine Benutzersitzung auf.
|
||||||
|
*
|
||||||
|
* @details Enthält das Formular und die Verarbeitung des POST-Requests,
|
||||||
|
* verifiziert Passwort-Hashes, veranlasst ggf. einen Re-Hash und speichert Attribute in der Session.
|
||||||
|
*/
|
||||||
|
|
||||||
// login.php
|
// login.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Bindet die Bootstrap-Datei ein, um Session und Basiskonfigurationen zu gewährleisten.
|
||||||
|
*/
|
||||||
require_once __DIR__ . '/lib/bootstrap.php';
|
require_once __DIR__ . '/lib/bootstrap.php';
|
||||||
|
|
||||||
// 1) DB-Verbindung (einmal)
|
/**
|
||||||
|
* 1) DB-Verbindung (einmal)
|
||||||
|
* @brief Stellt eine Datenbankverbindung her.
|
||||||
|
* @var mysqli $conn Die etablierte Datenbankverbindung.
|
||||||
|
*/
|
||||||
$conn = db_connect();
|
$conn = db_connect();
|
||||||
|
|
||||||
// 2) POST-Verarbeitung VOR jeglicher Ausgabe
|
/**
|
||||||
|
* 2) POST-Verarbeitung VOR jeglicher Ausgabe
|
||||||
|
* @brief Initialisierung von Fehler- und Infomeldungen.
|
||||||
|
*/
|
||||||
|
/** @var string|null $loginError Speichert mögliche Fehlermeldungen während des Login-Prozesses. */
|
||||||
$loginError = null;
|
$loginError = null;
|
||||||
|
/** @var string|null $loginInfo Speichert mögliche Informationsmeldungen für den Benutzer aus. */
|
||||||
$loginInfo = null;
|
$loginInfo = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Prüft, ob der Nutzer frisch von der Registrierungsseite kommt.
|
||||||
|
* @details Die Variable $_GET['registered'] wird gesetzt, wenn eine erfolgreiche Weiterleitung von register.php stattfand.
|
||||||
|
*/
|
||||||
if (isset($_GET['registered']) && $_GET['registered'] === '1')
|
if (isset($_GET['registered']) && $_GET['registered'] === '1')
|
||||||
{
|
{
|
||||||
|
/** @brief Setzt eine Info-Nachricht für die UI, dass die Registrierung erfolgreich war. */
|
||||||
$loginInfo = 'Registrierung erfolgreich. Du kannst dich jetzt einloggen.';
|
$loginInfo = 'Registrierung erfolgreich. Du kannst dich jetzt einloggen.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Fängt das Formular-Submit ab, wenn die Anfrage-Methode POST ist.
|
||||||
|
* @details Überprüft, ob Daten aus dem Formular abgesendet wurden.
|
||||||
|
*/
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST')
|
if ($_SERVER['REQUEST_METHOD'] === 'POST')
|
||||||
{
|
{
|
||||||
|
/** @var string $uname Speichert den eingegebenen Nutzernamen vor der Validierung. */
|
||||||
$uname = '';
|
$uname = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Prüft, ob das Feld `uname` übermittelt wurde und weist es zu.
|
||||||
|
*/
|
||||||
if (isset($_POST['uname']))
|
if (isset($_POST['uname']))
|
||||||
{
|
{
|
||||||
|
/** @details Führt zudem ein trim() aus, um überschüssige Leerzeichen am Anfang und Ende zu entfernen. */
|
||||||
$uname = trim($_POST['uname']);
|
$uname = trim($_POST['uname']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Prüft, ob das Feld `pw` übermittelt wurde und weist es zu.
|
||||||
|
*/
|
||||||
if (isset($_POST['pw']))
|
if (isset($_POST['pw']))
|
||||||
{
|
{
|
||||||
|
/** @var string $pw Speichert das eingegebene Passwort im Klartext. */
|
||||||
$pw = $_POST['pw'];
|
$pw = $_POST['pw'];
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
/** @details Falls kein Passwort übergeben wurde, wird ein leerer String zugewiesen. */
|
||||||
$pw = '';
|
$pw = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
// Basic Validierung
|
* @brief Basic Validierung vor dem Datenbank-Look-Up.
|
||||||
|
* @details Prüft, ob Nutzername oder Passwort komplett leer sind.
|
||||||
|
*/
|
||||||
if ($uname === '' || $pw === '')
|
if ($uname === '' || $pw === '')
|
||||||
{
|
{
|
||||||
|
/** @brief Fehler-Status setzen, wenn mindestens eins der benötigten Felder leer ist. */
|
||||||
$loginError = "Bitte Username und Passwort eingeben.";
|
$loginError = "Bitte Username und Passwort eingeben.";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Login ist SELECT, mit Prepared Statement (sicher) und ?-Platzhalter
|
/**
|
||||||
|
* @brief Login ist SELECT, mit Prepared Statement (sicher) und ?-Platzhalter
|
||||||
|
* @details Es werden die Felder userID, displayName und passwordHash gesucht, um den Nutzer später authentifizieren zu können.
|
||||||
|
* @var mysqli_stmt|false $stmt Das vorbereitete Query-Statement.
|
||||||
|
*/
|
||||||
$stmt = mysqli_prepare(
|
$stmt = mysqli_prepare(
|
||||||
$conn,
|
$conn,
|
||||||
"SELECT userID, displayName, passwordHash FROM users WHERE displayName = ? LIMIT 1"
|
"SELECT userID, displayName, passwordHash FROM users WHERE displayName = ? LIMIT 1"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Prüft, ob beim Vorbereiten des Statements ein Fehler geschah.
|
||||||
if (!$stmt)
|
if (!$stmt)
|
||||||
{
|
{
|
||||||
$loginError = "Datenbankfehler.";
|
$loginError = "Datenbankfehler.";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
/// Bindet den gesuchten Nutzernamen als String (`s`) an das SQL-Statement.
|
||||||
mysqli_stmt_bind_param($stmt, "s", $uname);
|
mysqli_stmt_bind_param($stmt, "s", $uname);
|
||||||
|
/// Führt die Datenbankabfrage aus.
|
||||||
mysqli_stmt_execute($stmt);
|
mysqli_stmt_execute($stmt);
|
||||||
|
|
||||||
|
/// @var mysqli_result|false $result Das ResultSet der Select-Abfrage.
|
||||||
$result = mysqli_stmt_get_result($stmt);
|
$result = mysqli_stmt_get_result($stmt);
|
||||||
|
|
||||||
|
/** @var array|null $user Enthält die abgerufenen Datensätze aus der DB. */
|
||||||
$user = null;
|
$user = null;
|
||||||
|
|
||||||
|
/// Sofern ein Resultat gefunden wurde, wird die erste Zeile assoziativ in `$user` geschrieben.
|
||||||
if ($result)
|
if ($result)
|
||||||
{
|
{
|
||||||
$user = mysqli_fetch_assoc($result);
|
$user = mysqli_fetch_assoc($result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Passwort prüfen: Eingabe gegen gespeicherten Hash (password_hash/password_verify)
|
/**
|
||||||
|
* @brief Passwort prüfen: Eingabe gegen gespeicherten Hash (password_hash/password_verify)
|
||||||
|
*/
|
||||||
if ($user && password_verify($pw, $user['passwordHash']))
|
if ($user && password_verify($pw, $user['passwordHash']))
|
||||||
{
|
{
|
||||||
// Optional: Rehash, falls Algorithmus/Cost geändert wurde
|
/**
|
||||||
|
* @brief Optional: Rehash, falls Algorithmus/Cost geändert wurde.
|
||||||
|
* @details Überprüft, ob der bestehende Hash nicht mehr den aktuellen Anforderungen von PASSWORD_DEFAULT entspricht.
|
||||||
|
*/
|
||||||
if (password_needs_rehash($user['passwordHash'], PASSWORD_DEFAULT))
|
if (password_needs_rehash($user['passwordHash'], PASSWORD_DEFAULT))
|
||||||
{
|
{
|
||||||
|
/** @var string $newHash Erzeugt einen neuen Hash mit dem aktuellen Standard-Algorithmus. */
|
||||||
$newHash = password_hash($pw, PASSWORD_DEFAULT);
|
$newHash = password_hash($pw, PASSWORD_DEFAULT);
|
||||||
|
/** @var mysqli_stmt|false $upd Bereitet das Statement für das Update in der DB vor. */
|
||||||
$upd = mysqli_prepare($conn, "UPDATE users SET passwordHash = ? WHERE userID = ?");
|
$upd = mysqli_prepare($conn, "UPDATE users SET passwordHash = ? WHERE userID = ?");
|
||||||
if ($upd)
|
if ($upd)
|
||||||
{
|
{
|
||||||
|
/// @var int $userID Konvertiert die ID sicher in einen Integer.
|
||||||
$userID = (int)$user['userID'];
|
$userID = (int)$user['userID'];
|
||||||
|
/// Bindet Parameter für das Update-Query (s = string, i = int).
|
||||||
mysqli_stmt_bind_param($upd, "si", $newHash, $userID);
|
mysqli_stmt_bind_param($upd, "si", $newHash, $userID);
|
||||||
|
/// Führt das Update auf den Passworthash durch.
|
||||||
mysqli_stmt_execute($upd);
|
mysqli_stmt_execute($upd);
|
||||||
|
/// Schließt das Update-Statement.
|
||||||
mysqli_stmt_close($upd);
|
mysqli_stmt_close($upd);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Setzt die Session-Werte zur Authentifizierung des Nutzers.
|
||||||
|
*/
|
||||||
$_SESSION['user_id'] = (int)$user['userID'];
|
$_SESSION['user_id'] = (int)$user['userID'];
|
||||||
$_SESSION['displayName'] = $user['displayName'];
|
$_SESSION['displayName'] = $user['displayName'];
|
||||||
|
|
||||||
|
/** @brief Schließt das Query-Statement und die DB-Verbindung (falls nicht gepoolt) sauber ab. */
|
||||||
mysqli_stmt_close($stmt);
|
mysqli_stmt_close($stmt);
|
||||||
mysqli_close($conn);
|
mysqli_close($conn);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Leitet den Benutzer ins Profil/Konto weiter.
|
||||||
|
* @details Bricht zudem die Skriptausführung mittels exit ab, nachdem der Header gesendet wurde.
|
||||||
|
*/
|
||||||
header("Location: account.php");
|
header("Location: account.php");
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$loginError = "Ungültige Zugangsdaten.";
|
/**
|
||||||
|
* @brief Fallback, falls Authentifizierung am PW oder fehlendem Nutzer scheitert.
|
||||||
|
*/
|
||||||
|
$loginError = "Ungltige Zugangsdaten.";
|
||||||
mysqli_stmt_close($stmt);
|
mysqli_stmt_close($stmt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Bindet die Header-HTML-Komponente ein.
|
||||||
|
*/
|
||||||
include 'header.php';
|
include 'header.php';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
@ -108,12 +185,16 @@ include 'header.php';
|
|||||||
<h2 class="auth__title">Login</h2>
|
<h2 class="auth__title">Login</h2>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<?php if ($loginInfo): ?>
|
<?php
|
||||||
|
/** @brief Rendert optional Erfolgsnachrichten (z.B. nach Registrierung). */
|
||||||
|
if ($loginInfo): ?>
|
||||||
<p class="auth__alert__sucess"
|
<p class="auth__alert__sucess"
|
||||||
role="status"><?php echo htmlspecialchars($loginInfo, ENT_QUOTES, 'UTF-8'); ?></p>
|
role="status"><?php echo htmlspecialchars($loginInfo, ENT_QUOTES, 'UTF-8'); ?></p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($loginError): ?>
|
<?php
|
||||||
|
/** @brief Rendert optional aufgetretene Fehler beim Login-Versuch. */
|
||||||
|
if ($loginError): ?>
|
||||||
<p class="auth__alert__error"
|
<p class="auth__alert__error"
|
||||||
role="alert"><?php echo htmlspecialchars($loginError, ENT_QUOTES, 'UTF-8'); ?></p>
|
role="alert"><?php echo htmlspecialchars($loginError, ENT_QUOTES, 'UTF-8'); ?></p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@ -142,6 +223,13 @@ include 'header.php';
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @brief Schließt die Datenbankbindung, bevor der Footer geladen und die Seite an den Client geschickt wird.
|
||||||
|
*/
|
||||||
mysqli_close($conn);
|
mysqli_close($conn);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Bindet die Footer-HTML-Komponente ein.
|
||||||
|
*/
|
||||||
include 'footer.php';
|
include 'footer.php';
|
||||||
?>
|
?>
|
||||||
|
|||||||
43
logout.php
43
logout.php
@ -1,12 +1,36 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @file logout.php
|
||||||
|
* @brief Beendet die Sitzung des Benutzers.
|
||||||
|
*
|
||||||
|
* @details Diese Datei setzt alle Session-Parameter zurück, löscht das Session-Cookie
|
||||||
|
* und leitet den Benutzer auf die Anmeldeseite um.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Bindet die Bootstrap-Datei ein, um Session-Start und Basiskonfigurationen zu gewährleisten.
|
||||||
|
*/
|
||||||
require_once __DIR__ . '/lib/bootstrap.php';
|
require_once __DIR__ . '/lib/bootstrap.php';
|
||||||
|
|
||||||
/* Alle Session-Variablen löschen */
|
/**
|
||||||
|
* @brief Leert das komplette Session-Array.
|
||||||
|
* @details Setzt `$_SESSION` als leeres Array, um alle nutzerbezogenen Daten der Sitzung zu entfernen.
|
||||||
|
*/
|
||||||
$_SESSION = [];
|
$_SESSION = [];
|
||||||
|
|
||||||
/* Session-Cookie löschen (wichtig!) */
|
/**
|
||||||
|
* @brief Prüft, ob Sessions über Cookies abgewickelt werden.
|
||||||
|
* @details Falls ja, wird das dazugehörige Session-Cookie aktiv im Client des Benutzers invalidiert.
|
||||||
|
*/
|
||||||
if (ini_get("session.use_cookies")) {
|
if (ini_get("session.use_cookies")) {
|
||||||
|
/**
|
||||||
|
* @var array $params Liest die aktuellen Parameter zum Verwalten der Cookies aus.
|
||||||
|
*/
|
||||||
$params = session_get_cookie_params();
|
$params = session_get_cookie_params();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Setzt ein invalides und abgelaufenes Cookie, um das bestehende Cookie des Nutzers zu verwerfen.
|
||||||
|
*/
|
||||||
setcookie(
|
setcookie(
|
||||||
session_name(),
|
session_name(),
|
||||||
'',
|
'',
|
||||||
@ -18,13 +42,22 @@ if (ini_get("session.use_cookies")) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Session zerstören */
|
/**
|
||||||
|
* @brief Zerstört die serverseitige Sitzung final.
|
||||||
|
*/
|
||||||
session_destroy();
|
session_destroy();
|
||||||
|
|
||||||
/* Optional: Remember-Me Cookie löschen (falls vorhanden)
|
/* Optional: Remember-Me Cookie lschen (falls vorhanden)
|
||||||
setcookie("remember_token", "", time() - 3600, "/");
|
setcookie("remember_token", "", time() - 3600, "/");
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Weiterleitung auf die Login-Seite.
|
||||||
|
*/
|
||||||
header("Location: login.php");
|
header("Location: login.php");
|
||||||
exit();
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Stoppt das Skript nach erfolgtem Redirect.
|
||||||
|
*/
|
||||||
|
exit();
|
||||||
|
?>
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
<html>
|
|
||||||
<body>
|
|
||||||
<h2>TestProjekt-Login</h2>
|
|
||||||
<form action="login.php" method="POST">
|
|
||||||
<label for="fname">Username:</label>
|
|
||||||
<input type="text" id="uname" name="uname"><br>
|
|
||||||
<label for="lname">Password:</label>
|
|
||||||
<input type="text" id="pw" name="pw"><br><br>
|
|
||||||
<input type="submit" value="Login">
|
|
||||||
</form>
|
|
||||||
<p><a href="register.html">Register</a></p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,8 +1,30 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @file productAdder.php
|
||||||
|
* @brief Datei productAdder.php zur Anlage neuer Produkte im System.
|
||||||
|
*
|
||||||
|
* @details Diese Datei stellt ein Formular sowie die serverseitige Logik bereit,
|
||||||
|
* um neue Produkte in die Datenbank einzufügen. Dabei werden Kategorien,
|
||||||
|
* Marken und dynamische Attribute berücksichtigt. Ein Bild-Upload oder
|
||||||
|
* die Angabe einer Bild-URL sind ebenfalls möglich.
|
||||||
|
* Automatisch generierter Doxygen-Header wurde erweitert.
|
||||||
|
*
|
||||||
|
* @author Fabian
|
||||||
|
* @date 2026-04-04
|
||||||
|
*/
|
||||||
|
|
||||||
// product_add.php
|
// product_add.php
|
||||||
|
|
||||||
require_once __DIR__ . '/lib/bootstrap.php';
|
require_once __DIR__ . '/lib/bootstrap.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @section Zugriffskontrolle
|
||||||
|
* @brief Überprüfung der Administrator-Rechte.
|
||||||
|
*
|
||||||
|
* Falls der Benutzer nicht eingeloggt ist oder die Rolle 'ADMIN' nicht besitzt,
|
||||||
|
* wird der Zugriff mit einem HTTP-Status 403 (Forbidden) verweigert und eine
|
||||||
|
* Fehlermeldung ausgegeben.
|
||||||
|
*/
|
||||||
/* =======================
|
/* =======================
|
||||||
0) Zugriffskontrolle – nur ADMIN
|
0) Zugriffskontrolle – nur ADMIN
|
||||||
======================= */
|
======================= */
|
||||||
@ -17,6 +39,13 @@ if (empty($_SESSION['user_id']) || empty($_SESSION['user_roles']) || !in_array('
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @section Kategorie-Auswahl
|
||||||
|
* @brief Ermittlung der ausgewählten Kategorie-ID.
|
||||||
|
*
|
||||||
|
* Die Kategorie-ID wird entweder aus den GET- oder POST-Parametern bezogen.
|
||||||
|
* Es wird sichergestellt, dass die ID aus Ziffern besteht (ctype_digit).
|
||||||
|
*/
|
||||||
/* =======================
|
/* =======================
|
||||||
1) Kategorie aus GET
|
1) Kategorie aus GET
|
||||||
======================= */
|
======================= */
|
||||||
@ -27,11 +56,25 @@ if (isset($_GET['categoryID']) && ctype_digit($_GET['categoryID'])) {
|
|||||||
$categoryID = (int)$_POST['categoryID'];
|
$categoryID = (int)$_POST['categoryID'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @section Datenbankverbindung
|
||||||
|
* @brief Initialisierung der Datenbankverbindung.
|
||||||
|
*
|
||||||
|
* Nutzt die globale Funktion db_connect() aus bootstrap.php / db.php,
|
||||||
|
* um eine Verbindung zur MySQL-Datenbank herzustellen.
|
||||||
|
*/
|
||||||
/* =======================
|
/* =======================
|
||||||
2) DB-Verbindung
|
2) DB-Verbindung
|
||||||
======================= */
|
======================= */
|
||||||
$conn = db_connect();
|
$conn = db_connect();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @section Kategorien-Laden
|
||||||
|
* @brief Abrufen aller verfügbaren Kategorien.
|
||||||
|
*
|
||||||
|
* Die Kategorien werden für das Dropdown-Menü im Formular benötigt
|
||||||
|
* und alphabetisch sortiert geladen.
|
||||||
|
*/
|
||||||
/* =======================
|
/* =======================
|
||||||
3) Kategorien laden
|
3) Kategorien laden
|
||||||
======================= */
|
======================= */
|
||||||
@ -45,6 +88,13 @@ while ($row = $result->fetch_assoc()) {
|
|||||||
$categories[] = $row;
|
$categories[] = $row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @section Marken-Laden
|
||||||
|
* @brief Abrufen aller verfügbaren Marken.
|
||||||
|
*
|
||||||
|
* Lädt die Marken alphabetisch sortiert aus der Datenbank,
|
||||||
|
* damit sie bei der Produkterstellung ausgewählt werden können.
|
||||||
|
*/
|
||||||
/* =======================
|
/* =======================
|
||||||
3b) Marken laden
|
3b) Marken laden
|
||||||
======================= */
|
======================= */
|
||||||
@ -58,6 +108,13 @@ while ($row = $result->fetch_assoc()) {
|
|||||||
$brands[] = $row;
|
$brands[] = $row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @section Attribute-Laden
|
||||||
|
* @brief Abrufen der kategoriespezifischen Attribute.
|
||||||
|
*
|
||||||
|
* Falls eine Kategorie ausgewählt wurde ($categoryID > 0), werden
|
||||||
|
* die zugehörigen Attribute (inklusive Einheit und Datentyp) geladen.
|
||||||
|
*/
|
||||||
/* =======================
|
/* =======================
|
||||||
4) Attribute zur Kategorie
|
4) Attribute zur Kategorie
|
||||||
======================= */
|
======================= */
|
||||||
@ -78,21 +135,35 @@ if ($categoryID > 0) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @section Produkt-Speicherung
|
||||||
|
* @brief Verarbeitung des abgesendeten Formulars zum Speichern eines Produkts.
|
||||||
|
*
|
||||||
|
* Diese Sektion verarbeitet alle übergebenen Formulardaten (Modell, Beschreibung,
|
||||||
|
* Kategorie, Marke, Bild-Upload/URL, Attribute), validiert sie und fügt das
|
||||||
|
* neue Produkt in die Datenbank ein.
|
||||||
|
*/
|
||||||
/* =======================
|
/* =======================
|
||||||
5) Produkt speichern
|
5) Produkt speichern
|
||||||
======================= */
|
======================= */
|
||||||
$saveError = null;
|
$saveError = null;
|
||||||
|
/** @var bool $debugMode Aktiviert den Debug-Modus für zusätzliche Fehlerausgaben, wenn ?debug=1 übergeben wird. */
|
||||||
$debugMode = isset($_GET['debug']) && $_GET['debug'] === '1';
|
$debugMode = isset($_GET['debug']) && $_GET['debug'] === '1';
|
||||||
$debugDetails = [];
|
$debugDetails = [];
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['saveProduct'])) {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['saveProduct'])) {
|
||||||
|
|
||||||
|
/** @var string $model Der Name bzw. das Modell des Produkts */
|
||||||
$model = trim($_POST['model']);
|
$model = trim($_POST['model']);
|
||||||
|
/** @var string|null $description Optionale Produktbeschreibung */
|
||||||
$description = $_POST['description'] ?? null;
|
$description = $_POST['description'] ?? null;
|
||||||
$categoryID = (int)$_POST['categoryID'];
|
$categoryID = (int)$_POST['categoryID'];
|
||||||
$brandID = (int)($_POST['brandID'] ?? 0);
|
$brandID = (int)($_POST['brandID'] ?? 0);
|
||||||
|
|
||||||
|
/** @var string $imageUrl Optionale Bild-URL */
|
||||||
$imageUrl = trim((string)($_POST['imageUrl'] ?? ''));
|
$imageUrl = trim((string)($_POST['imageUrl'] ?? ''));
|
||||||
|
/** @var array|null $imageFile Das hochgeladene Dateiobjekt aus $_FILES */
|
||||||
$imageFile = (isset($_FILES['productImage']) && is_array($_FILES['productImage'])) ? $_FILES['productImage'] : null;
|
$imageFile = (isset($_FILES['productImage']) && is_array($_FILES['productImage'])) ? $_FILES['productImage'] : null;
|
||||||
|
/** @var bool $hasUpload Wahr, wenn eine Datei hochgeladen wurde und kein Fehler UPLOAD_ERR_NO_FILE vorliegt */
|
||||||
$hasUpload = $imageFile && isset($imageFile['error']) && (int)$imageFile['error'] !== UPLOAD_ERR_NO_FILE;
|
$hasUpload = $imageFile && isset($imageFile['error']) && (int)$imageFile['error'] !== UPLOAD_ERR_NO_FILE;
|
||||||
$uploadMime = null;
|
$uploadMime = null;
|
||||||
|
|
||||||
@ -109,6 +180,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['saveProduct'])) {
|
|||||||
$debugDetails['post_max_size'] = ini_get('post_max_size');
|
$debugDetails['post_max_size'] = ini_get('post_max_size');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validierung der Pflichtfelder und hochgeladenen Dateien
|
||||||
if ($categoryID <= 0) {
|
if ($categoryID <= 0) {
|
||||||
$saveError = 'Bitte eine Kategorie auswählen.';
|
$saveError = 'Bitte eine Kategorie auswählen.';
|
||||||
} elseif ($brandID <= 0) {
|
} elseif ($brandID <= 0) {
|
||||||
@ -168,6 +240,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['saveProduct'])) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($saveError === null) {
|
if ($saveError === null) {
|
||||||
|
/**
|
||||||
|
* @brief Produkt anlegen
|
||||||
|
* Speichert die grundlegenden Produktdaten in der Tabelle 'products'.
|
||||||
|
*/
|
||||||
// --- Produkt anlegen ---
|
// --- Produkt anlegen ---
|
||||||
$stmt = $conn->prepare("
|
$stmt = $conn->prepare("
|
||||||
INSERT INTO products (categoryID, brandID, model, description)
|
INSERT INTO products (categoryID, brandID, model, description)
|
||||||
@ -186,11 +262,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['saveProduct'])) {
|
|||||||
$debugDetails['db_error'] = $stmt->error;
|
$debugDetails['db_error'] = $stmt->error;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
/** @var int $productID Die ID des neu erstellten Produkts (Last Insert ID) */
|
||||||
$productID = $stmt->insert_id;
|
$productID = $stmt->insert_id;
|
||||||
|
|
||||||
|
/** @var string|null $publicImagePath Der öffentliche Pfad zum gespeicherten Produktbild */
|
||||||
$publicImagePath = null;
|
$publicImagePath = null;
|
||||||
|
|
||||||
if ($hasUpload) {
|
if ($hasUpload) {
|
||||||
|
// Verarbeitung des hochgeladenen Bildes und Generierung des Zielpfades
|
||||||
$relativeTargetDir = 'assets/images/products';
|
$relativeTargetDir = 'assets/images/products';
|
||||||
$dirTargetDir = rtrim(__DIR__, "\\/") . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativeTargetDir);
|
$dirTargetDir = rtrim(__DIR__, "\\/") . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativeTargetDir);
|
||||||
$documentRoot = isset($_SERVER['DOCUMENT_ROOT']) ? (string)$_SERVER['DOCUMENT_ROOT'] : '';
|
$documentRoot = isset($_SERVER['DOCUMENT_ROOT']) ? (string)$_SERVER['DOCUMENT_ROOT'] : '';
|
||||||
@ -241,6 +320,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['saveProduct'])) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($saveError === null && $publicImagePath !== null) {
|
if ($saveError === null && $publicImagePath !== null) {
|
||||||
|
/**
|
||||||
|
* @brief Bildpfad aktualisieren
|
||||||
|
* Aktualisiert den Datensatz des Produkts mit dem korrekten Bildpfad.
|
||||||
|
*/
|
||||||
$stmtImg = $conn->prepare("UPDATE products SET imagePath = ? WHERE productID = ?");
|
$stmtImg = $conn->prepare("UPDATE products SET imagePath = ? WHERE productID = ?");
|
||||||
if ($stmtImg) {
|
if ($stmtImg) {
|
||||||
$stmtImg->bind_param("si", $publicImagePath, $productID);
|
$stmtImg->bind_param("si", $publicImagePath, $productID);
|
||||||
@ -248,6 +331,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['saveProduct'])) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Dynamische Attribute speichern
|
||||||
|
* Iteriert über die übergebenen Attribute und speichert sie
|
||||||
|
* typgerecht in der Tabelle 'productAttributes'.
|
||||||
|
*/
|
||||||
// --- Attribute speichern ---
|
// --- Attribute speichern ---
|
||||||
if (!empty($_POST['attributes'])) {
|
if (!empty($_POST['attributes'])) {
|
||||||
|
|
||||||
@ -288,6 +376,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['saveProduct'])) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($saveError === null) {
|
if ($saveError === null) {
|
||||||
|
// Nach erfolgreichem Speichern wird die Seite neu geladen (Post/Redirect/Get-Pattern)
|
||||||
header("Location: productAdder.php?categoryID=" . $categoryID);
|
header("Location: productAdder.php?categoryID=" . $categoryID);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@ -296,6 +385,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['saveProduct'])) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inkludiert den Header-Bereich des HTML-Dokuments
|
||||||
include 'header.php';
|
include 'header.php';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
@ -398,6 +488,9 @@ include 'header.php';
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @brief Datenbankverbindung schließen und Footer inkludieren.
|
||||||
|
*/
|
||||||
$conn->close();
|
$conn->close();
|
||||||
include 'footer.php';
|
include 'footer.php';
|
||||||
?>
|
?>
|
||||||
|
|||||||
@ -1,11 +1,31 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @file productpage.php
|
||||||
|
* @brief Darstellung der Detailseite eines spezifischen Produkts.
|
||||||
|
*
|
||||||
|
* @details Diese Datei generiert die Produktdetailseite. Sie umfasst das Laden der Produktdaten
|
||||||
|
* anhand der übergebenen ID, Behandeln von POST-Anfragen (Bewertung löschen, Wunschliste bearbeiten,
|
||||||
|
* Vergleiche verwalten) und die Darstellung der zugehörigen Shops, Attribute sowie Bewertungen.
|
||||||
|
*
|
||||||
|
* @author System
|
||||||
|
* @version 1.0
|
||||||
|
*/
|
||||||
|
|
||||||
// productpage.php
|
// productpage.php
|
||||||
|
|
||||||
require_once __DIR__ . '/lib/bootstrap.php';
|
require_once __DIR__ . '/lib/bootstrap.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Initiiert die Datenbankverbindung.
|
||||||
|
* @details Stellt eine globale Verbindung zur Datenbank her, um die nachfolgenden Queries ausführen zu können.
|
||||||
|
*/
|
||||||
// 1) DB-Verbindung (einmal)
|
// 1) DB-Verbindung (einmal)
|
||||||
$conn = db_connect();
|
$conn = db_connect();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Holt die Produkt-ID aus dem GET-Parameter.
|
||||||
|
* @details Validierung der ID, Rückfall auf 0, wenn kein valider Wert übergeben wurde.
|
||||||
|
*/
|
||||||
$productId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
$productId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||||
|
|
||||||
if ($productId <= 0) {
|
if ($productId <= 0) {
|
||||||
@ -13,6 +33,10 @@ if ($productId <= 0) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Überprüft, ob das Produkt in der Datenbank existiert.
|
||||||
|
* @details Falls kein Datensatz für die gegebene productID gefunden wird, wird der Nutzer auf eine 404-Seite umgeleitet.
|
||||||
|
*/
|
||||||
$checkStmt = $conn->prepare("SELECT productID FROM products WHERE productID = ?");
|
$checkStmt = $conn->prepare("SELECT productID FROM products WHERE productID = ?");
|
||||||
$checkStmt->bind_param("i", $productId);
|
$checkStmt->bind_param("i", $productId);
|
||||||
$checkStmt->execute();
|
$checkStmt->execute();
|
||||||
@ -22,6 +46,11 @@ if ($checkResult->num_rows === 0) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Behandelt das Löschen von Bewertungen.
|
||||||
|
* @details Administrator-Nutzer können Bewertungen über einen POST-Request löschen.
|
||||||
|
* Überprüft die Nutzerrolle in der Session und führt das DELETE-Statement aus.
|
||||||
|
*/
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_review']) && isset($_POST['delete_review_id'])) {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_review']) && isset($_POST['delete_review_id'])) {
|
||||||
if (!empty($_SESSION['user_roles']) && in_array('ADMIN', $_SESSION['user_roles'], true)) {
|
if (!empty($_SESSION['user_roles']) && in_array('ADMIN', $_SESSION['user_roles'], true)) {
|
||||||
$deleteId = (int)$_POST['delete_review_id'];
|
$deleteId = (int)$_POST['delete_review_id'];
|
||||||
@ -40,6 +69,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_review']) && i
|
|||||||
<?php include 'header.php'; ?>
|
<?php include 'header.php'; ?>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @brief Holt die detaillierten Produktdaten und -attribute.
|
||||||
|
* @details Führt einen JOIN über products, categoryAttributes, attributes und productAttributes aus,
|
||||||
|
* um alle Eigenschaften für die Anzeige abzurufen.
|
||||||
|
*/
|
||||||
$stmt = $conn->prepare("
|
$stmt = $conn->prepare("
|
||||||
SELECT
|
SELECT
|
||||||
a.name,
|
a.name,
|
||||||
@ -77,8 +111,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_review']) && i
|
|||||||
$product = $result->fetch_assoc();
|
$product = $result->fetch_assoc();
|
||||||
$categoryId = $product['categoryID'];
|
$categoryId = $product['categoryID'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Initialer Status für die Wunschliste.
|
||||||
|
*/
|
||||||
$alreadyInWishlist = false;
|
$alreadyInWishlist = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Prüft, ob sich das Produkt bereits auf der Wunschliste des angemeldeten Nutzers befindet.
|
||||||
|
* @details Falls der Nutzer angemeldet ist (`user_id` ist gesetzt), wird in `userFavorites` gesucht.
|
||||||
|
*/
|
||||||
if (isset($_SESSION['user_id'])) {
|
if (isset($_SESSION['user_id'])) {
|
||||||
|
|
||||||
$stmtCheck = mysqli_prepare(
|
$stmtCheck = mysqli_prepare(
|
||||||
@ -109,6 +150,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_review']) && i
|
|||||||
?>
|
?>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @brief Verarbeitet POST-Requests für die Wunschliste.
|
||||||
|
* @details Benutzer können das Produkt der Wunschliste hinzufügen oder daraus entfernen.
|
||||||
|
* Benötigt eine aktive Session mit gültiger `user_id`.
|
||||||
|
*/
|
||||||
// PRÜFEN: POST-Request und Nutzer ist eingeloggt
|
// PRÜFEN: POST-Request und Nutzer ist eingeloggt
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_SESSION['user_id'])) {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_SESSION['user_id'])) {
|
||||||
|
|
||||||
@ -138,6 +184,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_SESSION['user_id'])) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Logik für das Produktvergleichs-Feature.
|
||||||
|
* @details Speichert Produkte nach `categoryID` gruppiert in der Session-Variable `compare`,
|
||||||
|
* um später mehrere Produkte vergleichen zu können. POST-Requests updaten dieses Array.
|
||||||
|
*/
|
||||||
// Vergleichslogik
|
// Vergleichslogik
|
||||||
if (!isset($_SESSION['compare'])) {
|
if (!isset($_SESSION['compare'])) {
|
||||||
$_SESSION['compare'] = [];
|
$_SESSION['compare'] = [];
|
||||||
@ -160,6 +211,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
|
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @brief Berechnet Bewertungs-Statistiken für dieses Produkt.
|
||||||
|
* @details Holt den Durchschnitt und zählt, wie viele Bewertungen je Sterne-Kategorie existieren,
|
||||||
|
* um die Bewertungsübersicht (Balkendiagramm) darzustellen.
|
||||||
|
*/
|
||||||
// SQL korrigiert: SUM statt COUNT für die bedingten Zählungen
|
// SQL korrigiert: SUM statt COUNT für die bedingten Zählungen
|
||||||
$stmtRevOv = mysqli_prepare($conn,
|
$stmtRevOv = mysqli_prepare($conn,
|
||||||
"SELECT ROUND(AVG(rating), 1) as avgRating,
|
"SELECT ROUND(AVG(rating), 1) as avgRating,
|
||||||
@ -295,6 +351,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @brief Gibt die Produktattribute (Specs) dynamisch aus.
|
||||||
|
* @details Iteriert durch die vorab geladenen Attribute und formatiert die Ausgabe
|
||||||
|
* entsprechend den Datentypen (String, Number, Boolean).
|
||||||
|
*/
|
||||||
while ($row = $result->fetch_assoc()) {
|
while ($row = $result->fetch_assoc()) {
|
||||||
echo "<p><strong>{$row['name']}:</strong> ";
|
echo "<p><strong>{$row['name']}:</strong> ";
|
||||||
if (!empty($row['valueString'])) echo $row['valueString'];
|
if (!empty($row['valueString'])) echo $row['valueString'];
|
||||||
@ -308,6 +369,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @brief Holt die anbietenden Shops inkl. Preisen und Links zum Produkt.
|
||||||
|
* @details Abhängig von der bestehenden Datenbankstruktur ('productURL' vs 'offerURL') wird
|
||||||
|
* die korrekte Spalte per Fallback ermittelt und abgefragt.
|
||||||
|
*/
|
||||||
// Unterschiedliche DB-Stände: URL-Spalte heißt je nach Schema z.B. productURL oder offerURL.
|
// Unterschiedliche DB-Stände: URL-Spalte heißt je nach Schema z.B. productURL oder offerURL.
|
||||||
// Wir ermitteln die existierende Spalte dynamisch, damit die Seite nicht mit "Unknown column" crasht.
|
// Wir ermitteln die existierende Spalte dynamisch, damit die Seite nicht mit "Unknown column" crasht.
|
||||||
$urlColumn = '';
|
$urlColumn = '';
|
||||||
@ -335,6 +401,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
|
|
||||||
$shopInfo = [];
|
$shopInfo = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Speichert die Shop-Ergebnisseingebungen in einem Array für die spätere Iteration.
|
||||||
|
*/
|
||||||
while ($row = $result->fetch_assoc()) {
|
while ($row = $result->fetch_assoc()) {
|
||||||
$shopInfo[] = $row;
|
$shopInfo[] = $row;
|
||||||
}
|
}
|
||||||
@ -389,6 +458,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
|
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @brief Holt alle Nutzer-Bewertungen zu diesem Produkt.
|
||||||
|
* @details Liefert Bewertungstexte, Anzahl vergebener Sterne und die nutzerspezifischen Profileigenschaften.
|
||||||
|
*/
|
||||||
// HIER ANGEPASST: profilePicture und createdAt zum SELECT hinzugefügt
|
// HIER ANGEPASST: profilePicture und createdAt zum SELECT hinzugefügt
|
||||||
$stmt = mysqli_prepare($conn,
|
$stmt = mysqli_prepare($conn,
|
||||||
" SELECT reviews.reviewID, rating, comment, users.displayname, users.profilePicture, reviews.createdAt
|
" SELECT reviews.reviewID, rating, comment, users.displayname, users.profilePicture, reviews.createdAt
|
||||||
@ -402,6 +475,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
|
|
||||||
$reviews = [];
|
$reviews = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Speichert alle ermittelten Bewertungen im Array für die Ansicht.
|
||||||
|
*/
|
||||||
while ($row = $result->fetch_assoc()) {
|
while ($row = $result->fetch_assoc()) {
|
||||||
$reviews[] = $row;
|
$reviews[] = $row;
|
||||||
}
|
}
|
||||||
@ -471,6 +547,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
<h2 class="reviews-title">Füge deine Bewertung hinzu!</h2>
|
<h2 class="reviews-title">Füge deine Bewertung hinzu!</h2>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @brief Steuert den Logik-Fluss zum Hinzufügen einer eigenen Bewertung.
|
||||||
|
* @details Prüft zunächst, ob der momentan eingeloggte Nutzer das Produkt bereits
|
||||||
|
* bewertet hat. Falls nicht, kann eine neue Bewertung über einen POST-Request verarbeitet werden.
|
||||||
|
*/
|
||||||
$userHasReviewed = false;
|
$userHasReviewed = false;
|
||||||
|
|
||||||
// 1. Prüfen, ob der eingeloggte Nutzer schon bewertet hat
|
// 1. Prüfen, ob der eingeloggte Nutzer schon bewertet hat
|
||||||
@ -517,7 +598,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
mysqli_stmt_execute($stmtInsertRev);
|
mysqli_stmt_execute($stmtInsertRev);
|
||||||
mysqli_stmt_close($stmtInsertRev);
|
mysqli_stmt_close($stmtInsertRev);
|
||||||
|
|
||||||
// JS Weiterleitung
|
// JS Weiterleitung nach dem erfolgreichen Anlegen der Bewertung
|
||||||
echo "<script>window.location.href = 'productpage.php?id=" . $productId . "';</script>";
|
echo "<script>window.location.href = 'productpage.php?id=" . $productId . "';</script>";
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@ -540,6 +621,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
|
<!--
|
||||||
|
* @brief Eingabeformular für Bewertungen.
|
||||||
|
* @details Bietet ein Feld für den Kommentartext sowie Radio-Buttons für das Sterne-Rating (1-5).
|
||||||
|
-->
|
||||||
<form class="review-input-form" method="post" autocomplete="off">
|
<form class="review-input-form" method="post" autocomplete="off">
|
||||||
<input type="hidden" name="submit_review" value="1">
|
<input type="hidden" name="submit_review" value="1">
|
||||||
|
|
||||||
|
|||||||
107
register.php
107
register.php
@ -1,50 +1,109 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @file register.php
|
||||||
|
* @brief Behandelt die Registrierung neuer Benutzer.
|
||||||
|
*
|
||||||
|
* @details Diese Datei stellt das Registrierungsformular bereit und verarbeitet die
|
||||||
|
* abgesendeten POST-Daten. Sie validiert die Eingaben (E-Mail, Benutzername, Passwort),
|
||||||
|
* prüft auf Duplikate in der Datenbank und legt bei erfolgreicher Validierung einen neuen
|
||||||
|
* Benutzer samt zugehöriger Standard-Rolle ("USER") in der Datenbank an.
|
||||||
|
*
|
||||||
|
* @author System
|
||||||
|
* @version 1.0
|
||||||
|
*/
|
||||||
|
|
||||||
// register.php
|
// register.php
|
||||||
|
|
||||||
require_once __DIR__ . '/lib/bootstrap.php';
|
require_once __DIR__ . '/lib/bootstrap.php';
|
||||||
|
|
||||||
require_once __DIR__ . '/lib/strings.php';
|
require_once __DIR__ . '/lib/strings.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Initiiert die globale Datenbankverbindung.
|
||||||
|
* @details Ruft die Funktion db_connect() auf, um eine Verbindung zur konfigurierten
|
||||||
|
* Datenbank herzustellen. Diese Verbindung wird für alle folgenden Queries verwendet.
|
||||||
|
*/
|
||||||
// 1) DB-Verbindung (einmal)
|
// 1) DB-Verbindung (einmal)
|
||||||
$conn = db_connect();
|
$conn = db_connect();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Array zur Speicherung möglicher Validierungs- oder Datenbankfehler.
|
||||||
|
* @var array $errors
|
||||||
|
*/
|
||||||
$errors = [];
|
$errors = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Array zur Zwischenspeicherung der eingegebenen Formulardaten.
|
||||||
|
* @details Dient dazu, die Eingaben des Nutzers (E-Mail und Anzeigename) nach
|
||||||
|
* einem fehlerhaften Submit-Versuch wieder im Formularfeld anzeigen zu können.
|
||||||
|
* @var array $values
|
||||||
|
*/
|
||||||
$values = [
|
$values = [
|
||||||
'email' => '',
|
'email' => '',
|
||||||
'displayName' => ''
|
'displayName' => ''
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Prüft, ob das Formular per POST-Methode abgesendet wurde.
|
||||||
|
* @details Alle Validierungs- und Registrierungsschritte werden nur ausgeführt,
|
||||||
|
* wenn Daten via POST übermittelt wurden.
|
||||||
|
*/
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST')
|
if ($_SERVER['REQUEST_METHOD'] === 'POST')
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @brief Holt und bereinigt die eingegebene E-Mail-Adresse.
|
||||||
|
* @var string $email
|
||||||
|
*/
|
||||||
$email = '';
|
$email = '';
|
||||||
if (isset($_POST['email']))
|
if (isset($_POST['email']))
|
||||||
{
|
{
|
||||||
$email = trim((string)$_POST['email']);
|
$email = trim((string)$_POST['email']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Holt und bereinigt den eingegebenen Benutzernamen.
|
||||||
|
* @var string $displayName
|
||||||
|
*/
|
||||||
$displayName = '';
|
$displayName = '';
|
||||||
if (isset($_POST['displayName']))
|
if (isset($_POST['displayName']))
|
||||||
{
|
{
|
||||||
$displayName = trim((string)$_POST['displayName']);
|
$displayName = trim((string)$_POST['displayName']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Holt das eingegebene Passwort.
|
||||||
|
* @var string $pw
|
||||||
|
*/
|
||||||
$pw = '';
|
$pw = '';
|
||||||
if (isset($_POST['pw']))
|
if (isset($_POST['pw']))
|
||||||
{
|
{
|
||||||
$pw = (string)$_POST['pw'];
|
$pw = (string)$_POST['pw'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Holt die Wiederholung des eingegebenen Passworts.
|
||||||
|
* @var string $pw2
|
||||||
|
*/
|
||||||
$pw2 = '';
|
$pw2 = '';
|
||||||
if (isset($_POST['pw2']))
|
if (isset($_POST['pw2']))
|
||||||
{
|
{
|
||||||
$pw2 = (string)$_POST['pw2'];
|
$pw2 = (string)$_POST['pw2'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Standard-Profilbild für neu registrierte Nutzer.
|
||||||
|
* @var string $profilePicture
|
||||||
|
*/
|
||||||
$profilePicture = 'assets/images/profilePictures/default.png';
|
$profilePicture = 'assets/images/profilePictures/default.png';
|
||||||
|
|
||||||
|
// Aktualisiere das $values-Array für die erneute Anzeige bei Fehlern
|
||||||
$values['email'] = $email;
|
$values['email'] = $email;
|
||||||
$values['displayName'] = $displayName;
|
$values['displayName'] = $displayName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Validierung der Eingabedaten.
|
||||||
|
* @details Prüft E-Mail-Format, Länge des Benutzernamens (3-50 Zeichen),
|
||||||
|
* Passwortlänge (min. 8 Zeichen) und Übereinstimmung der Passwörter.
|
||||||
|
*/
|
||||||
// Validierung
|
// Validierung
|
||||||
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL))
|
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL))
|
||||||
{
|
{
|
||||||
@ -66,6 +125,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST')
|
|||||||
$errors[] = 'Die Passwörter stimmen nicht überein.';
|
$errors[] = 'Die Passwörter stimmen nicht überein.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Prüfung auf bereits existierende E-Mail.
|
||||||
|
* @details Falls bisher keine Fehler aufgetreten sind, wird in der Datenbank
|
||||||
|
* geprüft, ob die angegebene E-Mail bereits von einem anderen Account genutzt wird.
|
||||||
|
*/
|
||||||
// Duplicate-Checks
|
// Duplicate-Checks
|
||||||
if (!$errors)
|
if (!$errors)
|
||||||
{
|
{
|
||||||
@ -87,6 +151,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST')
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Prüfung auf bereits existierenden Benutzernamen.
|
||||||
|
* @details Führt eine Datenbankabfrage durch, um sicherzustellen, dass
|
||||||
|
* der gewählte Anzeigename (displayName) noch verfügbar ist.
|
||||||
|
*/
|
||||||
if (!$errors)
|
if (!$errors)
|
||||||
{
|
{
|
||||||
$stmt = mysqli_prepare($conn, 'SELECT userID FROM users WHERE displayName = ? LIMIT 1');
|
$stmt = mysqli_prepare($conn, 'SELECT userID FROM users WHERE displayName = ? LIMIT 1');
|
||||||
@ -107,6 +176,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST')
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Finaler Insert-Vorgang für den neuen Benutzer.
|
||||||
|
* @details Wenn keine Fehler vorliegen, wird das Passwort gehasht und der
|
||||||
|
* Benutzer in der Tabelle `users` gespeichert. Anschließend wird ihm die
|
||||||
|
* Standard-Rolle (USER) auf der Tabelle `userRoles` zugewiesen und
|
||||||
|
* auf die Login-Seite weitergeleitet.
|
||||||
|
*/
|
||||||
// Insert
|
// Insert
|
||||||
if (!$errors)
|
if (!$errors)
|
||||||
{
|
{
|
||||||
@ -127,8 +203,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST')
|
|||||||
|
|
||||||
if ($ok)
|
if ($ok)
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @brief ID des soeben neu erstellten Benutzers.
|
||||||
|
* @var int $newUserId
|
||||||
|
*/
|
||||||
$newUserId = mysqli_insert_id($conn);
|
$newUserId = mysqli_insert_id($conn);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Zuweisung der initialen Benutzerrolle "USER".
|
||||||
|
* @details Sucht die ID der "USER"-Rolle aus der Tabelle `roles`
|
||||||
|
* und verknüpft sie mit der neuen Benutzer-ID in `userRoles`.
|
||||||
|
*/
|
||||||
// Get USER roleID
|
// Get USER roleID
|
||||||
$roleStmt = mysqli_prepare($conn, "SELECT roleID FROM roles WHERE name = 'USER' LIMIT 1");
|
$roleStmt = mysqli_prepare($conn, "SELECT roleID FROM roles WHERE name = 'USER' LIMIT 1");
|
||||||
if ($roleStmt) {
|
if ($roleStmt) {
|
||||||
@ -150,6 +235,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST')
|
|||||||
mysqli_stmt_close($stmt);
|
mysqli_stmt_close($stmt);
|
||||||
|
|
||||||
mysqli_close($conn);
|
mysqli_close($conn);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Weiterleitung nach erfolgreicher Registrierung.
|
||||||
|
* @details Leitet zurück auf login.php mit dem GET-Parameter `registered=1`,
|
||||||
|
* damit der Nutzer eine Erfolgsmeldung erhält.
|
||||||
|
*/
|
||||||
header('Location: login.php?registered=1');
|
header('Location: login.php?registered=1');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@ -160,9 +251,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST')
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Einbinden des Seiten-Headers.
|
||||||
|
* @details Lädt die globale Navigationstruktur für die visuelle Darstellung (HTML/CSS).
|
||||||
|
*/
|
||||||
include 'header.php';
|
include 'header.php';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
@brief Start des HTML-Hauptbereichs für die Registrierung.
|
||||||
|
@details Beinhaltet das Registrierungsformular, die Ausgabe etwaiger Fehler
|
||||||
|
sowie Links zum Login für bereits angemeldete Benutzer.
|
||||||
|
-->
|
||||||
<main class="auth" role="main">
|
<main class="auth" role="main">
|
||||||
<section class="auth__grid" aria-label="Registrierung Bereich">
|
<section class="auth__grid" aria-label="Registrierung Bereich">
|
||||||
<div class="auth__card">
|
<div class="auth__card">
|
||||||
@ -217,6 +317,11 @@ include 'header.php';
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @brief Schließt die Datenbankverbindung und bindet den Footer ein.
|
||||||
|
* @details Säubert die offene MySQLi-Verbindung. Der Footer schließt
|
||||||
|
* das HTML-Dokument korrekt ab.
|
||||||
|
*/
|
||||||
mysqli_close($conn);
|
mysqli_close($conn);
|
||||||
include 'footer.php';
|
include 'footer.php';
|
||||||
?>
|
?>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
136
style.css
136
style.css
@ -3,11 +3,29 @@
|
|||||||
Animated Dark Theme · Glassmorphism · Glow
|
Animated Dark Theme · Glassmorphism · Glow
|
||||||
========================================================== */
|
========================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file style.css
|
||||||
|
* @brief Main stylesheet for the Geizkragen Design System v2.
|
||||||
|
*
|
||||||
|
* This file contains all global styles, CSS variables, keyframe animations,
|
||||||
|
* layout rules, typography, and component-specific styling (such as buttons,
|
||||||
|
* cards, navigation, and footer) for the application. It utilizes a dark theme
|
||||||
|
* with glassmorphism and glow effects.
|
||||||
|
*
|
||||||
|
* @version 2.0
|
||||||
|
*/
|
||||||
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap');
|
||||||
|
|
||||||
/* ==========================================================
|
/* ==========================================================
|
||||||
RESET & BASIS
|
RESET & BASIS
|
||||||
========================================================== */
|
========================================================== */
|
||||||
|
/**
|
||||||
|
* @brief Global CSS reset.
|
||||||
|
*
|
||||||
|
* Resets margin and padding for all elements and sets box-sizing to border-box
|
||||||
|
* to ensure consistent box model calculations across the entire application.
|
||||||
|
*/
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
@ -16,6 +34,17 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Root CSS variables defining the design token system.
|
||||||
|
*
|
||||||
|
* This pseudo-class contains all the fundamental design tokens including:
|
||||||
|
* - Colors (backgrounds, semantics, text, borders)
|
||||||
|
* - Border radii for various component sizes
|
||||||
|
* - Box shadows for elevation and glow effects
|
||||||
|
* - Transition timings for smooth animations
|
||||||
|
* - Layout spacing values
|
||||||
|
* - Primary typography settings
|
||||||
|
*/
|
||||||
:root {
|
:root {
|
||||||
/* ─── Farben ─── */
|
/* ─── Farben ─── */
|
||||||
--bg-main: #151923;
|
--bg-main: #151923;
|
||||||
@ -76,47 +105,89 @@
|
|||||||
/* ==========================================================
|
/* ==========================================================
|
||||||
KEYFRAME ANIMATIONS
|
KEYFRAME ANIMATIONS
|
||||||
========================================================== */
|
========================================================== */
|
||||||
|
/**
|
||||||
|
* @brief Fade in and slide up animation.
|
||||||
|
*
|
||||||
|
* Elements transition from transparent and slightly translated down
|
||||||
|
* to fully opaque at their original position.
|
||||||
|
*/
|
||||||
@keyframes fadeInUp {
|
@keyframes fadeInUp {
|
||||||
from { opacity: 0; transform: translateY(24px); }
|
from { opacity: 0; transform: translateY(24px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Simple fade in animation.
|
||||||
|
*/
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from { opacity: 0; }
|
from { opacity: 0; }
|
||||||
to { opacity: 1; }
|
to { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Slide in from the right animation.
|
||||||
|
*
|
||||||
|
* Elements transition from transparent and translated right
|
||||||
|
* to fully opaque at their original position.
|
||||||
|
*/
|
||||||
@keyframes slideInRight {
|
@keyframes slideInRight {
|
||||||
from { opacity: 0; transform: translateX(20px); }
|
from { opacity: 0; transform: translateX(20px); }
|
||||||
to { opacity: 1; transform: translateX(0); }
|
to { opacity: 1; transform: translateX(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Scale in animation for a subtle zoom effect.
|
||||||
|
*
|
||||||
|
* Elements transition from transparent and slightly scaled down
|
||||||
|
* to fully opaque at their original size.
|
||||||
|
*/
|
||||||
@keyframes scaleIn {
|
@keyframes scaleIn {
|
||||||
from { opacity: 0; transform: scale(0.95); }
|
from { opacity: 0; transform: scale(0.95); }
|
||||||
to { opacity: 1; transform: scale(1); }
|
to { opacity: 1; transform: scale(1); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Shimmering animation for loading placeholders.
|
||||||
|
*
|
||||||
|
* Creates a moving gradient effect to simulate a shimmering light.
|
||||||
|
*/
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
0% { background-position: -200% 0; }
|
0% { background-position: -200% 0; }
|
||||||
100% { background-position: 200% 0; }
|
100% { background-position: 200% 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Pulsating glow animation for emphasis.
|
||||||
|
*
|
||||||
|
* Elements receive a glowing shadow that intensifies and fades.
|
||||||
|
*/
|
||||||
@keyframes pulse-glow {
|
@keyframes pulse-glow {
|
||||||
0%, 100% { box-shadow: 0 0 20px rgba(37, 99, 235, 0.08); }
|
0%, 100% { box-shadow: 0 0 20px rgba(37, 99, 235, 0.08); }
|
||||||
50% { box-shadow: 0 0 40px rgba(37, 99, 235, 0.18); }
|
50% { box-shadow: 0 0 40px rgba(37, 99, 235, 0.18); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Continuous float animation for ambient background elements.
|
||||||
|
*/
|
||||||
@keyframes float {
|
@keyframes float {
|
||||||
0%, 100% { transform: translateY(0); }
|
0%, 100% { transform: translateY(0); }
|
||||||
50% { transform: translateY(-8px); }
|
50% { transform: translateY(-8px); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Gradient shifting animation for vibrant backgrounds.
|
||||||
|
*/
|
||||||
@keyframes gradientShift {
|
@keyframes gradientShift {
|
||||||
0% { background-position: 0% 50%; }
|
0% { background-position: 0% 50%; }
|
||||||
50% { background-position: 100% 50%; }
|
50% { background-position: 100% 50%; }
|
||||||
100% { background-position: 0% 50%; }
|
100% { background-position: 0% 50%; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Border glowing animation for emphasis.
|
||||||
|
*
|
||||||
|
* Elements' borders transition between two glow states.
|
||||||
|
*/
|
||||||
@keyframes borderGlow {
|
@keyframes borderGlow {
|
||||||
0%, 100% { border-color: rgba(37, 99, 235, 0.15); }
|
0%, 100% { border-color: rgba(37, 99, 235, 0.15); }
|
||||||
50% { border-color: rgba(37, 99, 235, 0.35); }
|
50% { border-color: rgba(37, 99, 235, 0.35); }
|
||||||
@ -125,10 +196,21 @@
|
|||||||
/* ==========================================================
|
/* ==========================================================
|
||||||
GLOBAL
|
GLOBAL
|
||||||
========================================================== */
|
========================================================== */
|
||||||
|
/**
|
||||||
|
* @brief Global standard HTML element rules.
|
||||||
|
*
|
||||||
|
* Enables smooth scrolling behavior across the entire document.
|
||||||
|
*/
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Primary body base styles.
|
||||||
|
*
|
||||||
|
* Establishes the core typography, background color, text color,
|
||||||
|
* and standard flexbox layout structure to ensure the footer sticks to the bottom.
|
||||||
|
*/
|
||||||
body {
|
body {
|
||||||
font-family: var(--font-family);
|
font-family: var(--font-family);
|
||||||
background: var(--bg-main);
|
background: var(--bg-main);
|
||||||
@ -196,6 +278,12 @@ a:hover {
|
|||||||
/* ==========================================================
|
/* ==========================================================
|
||||||
CONTAINER
|
CONTAINER
|
||||||
========================================================== */
|
========================================================== */
|
||||||
|
/**
|
||||||
|
* @brief Standard layout container.
|
||||||
|
*
|
||||||
|
* Centers content horizontally and limits its maximum width to maintain
|
||||||
|
* readability on large screens, applying standard horizontal padding.
|
||||||
|
*/
|
||||||
.container {
|
.container {
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
@ -205,6 +293,12 @@ a:hover {
|
|||||||
/* ==========================================================
|
/* ==========================================================
|
||||||
HEADER – Glassmorphism
|
HEADER – Glassmorphism
|
||||||
========================================================== */
|
========================================================== */
|
||||||
|
/**
|
||||||
|
* @brief Primary site header with glassmorphism effect.
|
||||||
|
*
|
||||||
|
* Ensures the header is sticky at the top, applies a blur filter for the
|
||||||
|
* glassmorphic look, and controls the initial fade-in animation.
|
||||||
|
*/
|
||||||
.header {
|
.header {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
@ -261,10 +355,16 @@ a:hover {
|
|||||||
/* ==========================================================
|
/* ==========================================================
|
||||||
SEARCH AUTOCOMPLETE DROPDOWN
|
SEARCH AUTOCOMPLETE DROPDOWN
|
||||||
========================================================== */
|
========================================================== */
|
||||||
|
/**
|
||||||
|
* @brief Wrapper for the search field when it contains a dropdown.
|
||||||
|
*/
|
||||||
.nav__searchField--hasDropdown {
|
.nav__searchField--hasDropdown {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Autocomplete dropdown container styling.
|
||||||
|
*/
|
||||||
.searchDropdown {
|
.searchDropdown {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
@ -683,6 +783,11 @@ a:hover {
|
|||||||
/* ==========================================================
|
/* ==========================================================
|
||||||
LAYOUT (Grid mit Sidebar)
|
LAYOUT (Grid mit Sidebar)
|
||||||
========================================================== */
|
========================================================== */
|
||||||
|
/**
|
||||||
|
* @brief Main grid layout establishing a sidebar and main content area.
|
||||||
|
*
|
||||||
|
* Employs CSS Grid to define a 260px sidebar alongside a fluid main content area.
|
||||||
|
*/
|
||||||
.layout {
|
.layout {
|
||||||
margin: 2rem auto;
|
margin: 2rem auto;
|
||||||
padding: 0 2rem;
|
padding: 0 2rem;
|
||||||
@ -694,6 +799,11 @@ a:hover {
|
|||||||
/* ==========================================================
|
/* ==========================================================
|
||||||
FILTER / SIDEBAR
|
FILTER / SIDEBAR
|
||||||
========================================================== */
|
========================================================== */
|
||||||
|
/**
|
||||||
|
* @brief Sidebar container styles.
|
||||||
|
*
|
||||||
|
* Utilizes standard card background, subtle borders, and smooth fade-in animations.
|
||||||
|
*/
|
||||||
.sidebar {
|
.sidebar {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
@ -744,6 +854,11 @@ a:hover {
|
|||||||
/* ==========================================================
|
/* ==========================================================
|
||||||
PRODUCT GRID
|
PRODUCT GRID
|
||||||
========================================================== */
|
========================================================== */
|
||||||
|
/**
|
||||||
|
* @brief CSS Grid setup for laying out multiple product cards.
|
||||||
|
*
|
||||||
|
* Implements an auto-fill behavior for highly responsive product grids.
|
||||||
|
*/
|
||||||
.product-grid {
|
.product-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
@ -759,6 +874,12 @@ a:hover {
|
|||||||
/* ==========================================================
|
/* ==========================================================
|
||||||
PRODUCT CARD (global)
|
PRODUCT CARD (global)
|
||||||
========================================================== */
|
========================================================== */
|
||||||
|
/**
|
||||||
|
* @brief Main product card component styling.
|
||||||
|
*
|
||||||
|
* Sets the background, borders, border-radius, and shadow for product items.
|
||||||
|
* Implements hover scaling and glow transitions.
|
||||||
|
*/
|
||||||
.product-card {
|
.product-card {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
@ -841,6 +962,12 @@ a:hover {
|
|||||||
/* ==========================================================
|
/* ==========================================================
|
||||||
BUTTONS – Gradient & Glow
|
BUTTONS – Gradient & Glow
|
||||||
========================================================== */
|
========================================================== */
|
||||||
|
/**
|
||||||
|
* @brief Base styling for all button elements.
|
||||||
|
*
|
||||||
|
* Defines flexible sizing, border radius, typography, and foundational
|
||||||
|
* transition properties for standard interactive elements.
|
||||||
|
*/
|
||||||
.btn {
|
.btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0.6rem 1.1rem;
|
padding: 0.6rem 1.1rem;
|
||||||
@ -910,6 +1037,9 @@ a:hover {
|
|||||||
/* ==========================================================
|
/* ==========================================================
|
||||||
BADGES
|
BADGES
|
||||||
========================================================== */
|
========================================================== */
|
||||||
|
/**
|
||||||
|
* @brief Basic aesthetic container for miniature tags or status labels.
|
||||||
|
*/
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -960,6 +1090,12 @@ a:hover {
|
|||||||
/* ==========================================================
|
/* ==========================================================
|
||||||
FOOTER – Glassmorphism
|
FOOTER – Glassmorphism
|
||||||
========================================================== */
|
========================================================== */
|
||||||
|
/**
|
||||||
|
* @brief Global footer component.
|
||||||
|
*
|
||||||
|
* Adheres to the overall glassmorphism aesthetic with background blur,
|
||||||
|
* border separation, and sticky-to-bottom layout integration.
|
||||||
|
*/
|
||||||
.footer {
|
.footer {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
background: rgba(21, 25, 35, 0.92);
|
background: rgba(21, 25, 35, 0.92);
|
||||||
|
|||||||
150
upload.php
150
upload.php
@ -1,30 +1,77 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @file upload.php
|
||||||
|
* @brief Behandelt den Dateiupload für Profilbilder der Benutzer.
|
||||||
|
*
|
||||||
|
* @details Diese Datei nimmt ein hochgeladenes Bild als POST-Request entgegen.
|
||||||
|
* Sie validiert den Upload-Fehlercode, stellt sicher, dass es sich um ein gültiges
|
||||||
|
* Bild handelt (JPEG oder PNG) und verschiebt die Datei in das entsprechende
|
||||||
|
* Assets-Verzeichnis. Abschließend wird der Pfad zum hochgeladenen Bild in der
|
||||||
|
* Datenbank des Benutzers gespeichert. Alle Fehler und Erfolgsmeldungen leiten
|
||||||
|
* auf die account.php mit einem entsprechenden URL-Parameter zurück.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Bindet die grundlegenden Konfigurationen und Bibliotheken ein.
|
||||||
|
*/
|
||||||
require_once __DIR__ . '/lib/bootstrap.php';
|
require_once __DIR__ . '/lib/bootstrap.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Überprüft, ob der Benutzer angemeldet ist.
|
||||||
|
*
|
||||||
|
* @details Wenn keine Benutzer-ID in der Session vorhanden ist,
|
||||||
|
* wird der Benutzer sofort auf die Login-Seite umgeleitet und das Skript beendet.
|
||||||
|
*/
|
||||||
if (empty($_SESSION['user_id']))
|
if (empty($_SESSION['user_id']))
|
||||||
{
|
{
|
||||||
header('Location: login.php');
|
header('Location: login.php');
|
||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int $userId Die ID des aktuell angemeldeten Benutzers, ausgelesen aus der Session.
|
||||||
|
*/
|
||||||
$userId = (int)$_SESSION['user_id'];
|
$userId = (int)$_SESSION['user_id'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Überprüft, ob die Anfrage über die POST-Methode gesendet wurde.
|
||||||
|
*
|
||||||
|
* @details Auf diese Datei darf nur per HTTP POST zugegriffen werden (Formular-Upload).
|
||||||
|
* Andernfalls wird der Benutzer zur account.php umgeleitet.
|
||||||
|
*/
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST')
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST')
|
||||||
{
|
{
|
||||||
header('Location: account.php');
|
header('Location: account.php');
|
||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Überprüft, ob das Feld 'uploadFile' im $_FILES Array gesetzt ist.
|
||||||
|
*
|
||||||
|
* @details Es muss eine Datei über das erwartete Formularfeld übertragen worden sein.
|
||||||
|
*/
|
||||||
if (!isset($_FILES['uploadFile']) || !is_array($_FILES['uploadFile']))
|
if (!isset($_FILES['uploadFile']) || !is_array($_FILES['uploadFile']))
|
||||||
{
|
{
|
||||||
header('Location: account.php?upload=err');
|
header('Location: account.php?upload=err');
|
||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array $file Referenz auf die hochgeladene Datei im $_FILES Array.
|
||||||
|
*/
|
||||||
$file = $_FILES['uploadFile'];
|
$file = $_FILES['uploadFile'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int $fileError Ermittelt den PHP-Upload-Fehlercode (UPLOAD_ERR_OK, falls erfolgreich).
|
||||||
|
*/
|
||||||
$fileError = isset($file['error']) ? (int)$file['error'] : UPLOAD_ERR_NO_FILE;
|
$fileError = isset($file['error']) ? (int)$file['error'] : UPLOAD_ERR_NO_FILE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Überprüft, ob beim Upload ein Fehler aufgetreten ist.
|
||||||
|
*
|
||||||
|
* @details Wenn $fileError nicht UPLOAD_ERR_OK ist, wird abgebrochen, der Fehler ins
|
||||||
|
* Error-Log geschrieben und zur Account-Seite mit dem Fehlercode umgeleitet.
|
||||||
|
*/
|
||||||
if ($fileError !== UPLOAD_ERR_OK)
|
if ($fileError !== UPLOAD_ERR_OK)
|
||||||
{
|
{
|
||||||
// Serverseitiges Log ist ok, aber kein Pfad/Interna im Browser
|
// Serverseitiges Log ist ok, aber kein Pfad/Interna im Browser
|
||||||
@ -33,8 +80,17 @@ if ($fileError !== UPLOAD_ERR_OK)
|
|||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string $tmp Der temporäre Dateipfad, an dem die hochgeladene Datei abgelegt wurde.
|
||||||
|
*/
|
||||||
// Basic Validierung
|
// Basic Validierung
|
||||||
$tmp = isset($file['tmp_name']) ? (string)$file['tmp_name'] : '';
|
$tmp = isset($file['tmp_name']) ? (string)$file['tmp_name'] : '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Überprüft die Existenz und Gültigkeit der temporären Upload-Datei.
|
||||||
|
*
|
||||||
|
* @details Nutzt is_uploaded_file, um Exploits und Path-Traversal-Angriffe zu verhindern.
|
||||||
|
*/
|
||||||
if ($tmp === '' || !is_uploaded_file($tmp))
|
if ($tmp === '' || !is_uploaded_file($tmp))
|
||||||
{
|
{
|
||||||
// Debug-Detail (tmp-Pfad) nicht loggen
|
// Debug-Detail (tmp-Pfad) nicht loggen
|
||||||
@ -43,14 +99,29 @@ if ($tmp === '' || !is_uploaded_file($tmp))
|
|||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array $allowedMimeToExt Zuordnung von erlaubten MIME-Types zu deren Dateiendungen.
|
||||||
|
*/
|
||||||
$allowedMimeToExt = [
|
$allowedMimeToExt = [
|
||||||
'image/jpeg' => 'jpg',
|
'image/jpeg' => 'jpg',
|
||||||
'image/png' => 'png',
|
'image/png' => 'png',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var finfo $finfo PHP finfo-Instanz zur Bestimmung des tatsächlichen MIME-Types der Datei.
|
||||||
|
*/
|
||||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string|false $mime Der ausgelesene MIME-Type der temporären Datei.
|
||||||
|
*/
|
||||||
$mime = $finfo->file($tmp);
|
$mime = $finfo->file($tmp);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Validiert den MIME-Type.
|
||||||
|
*
|
||||||
|
* @details Erlaubt sind nur die in $allowedMimeToExt definierten Dateitypen (JPEG, PNG).
|
||||||
|
*/
|
||||||
if (!$mime || !isset($allowedMimeToExt[$mime]))
|
if (!$mime || !isset($allowedMimeToExt[$mime]))
|
||||||
{
|
{
|
||||||
// Mime loggen ist ok (kein Secret), hilft bei Support
|
// Mime loggen ist ok (kein Secret), hilft bei Support
|
||||||
@ -59,21 +130,45 @@ if (!$mime || !isset($allowedMimeToExt[$mime]))
|
|||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string $ext Die zur validierten Datei passende Dateiendung.
|
||||||
|
*/
|
||||||
$ext = $allowedMimeToExt[$mime];
|
$ext = $allowedMimeToExt[$mime];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string $relativeTargetDir Relativer Pfad zum Verzeichnis der Profilbilder.
|
||||||
|
*/
|
||||||
// Zielordner: assets/images/profilePictures relativ zum Projekt (upload.php liegt im Webroot)
|
// Zielordner: assets/images/profilePictures relativ zum Projekt (upload.php liegt im Webroot)
|
||||||
$relativeTargetDir = 'assets/images/profilePictures';
|
$relativeTargetDir = 'assets/images/profilePictures';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string $dirTargetDir Absoluter Pfad zum Zielordner, basierend auf der aktuellen Datei (__DIR__).
|
||||||
|
*/
|
||||||
// Kandidat 1: relativ zu __DIR__ (robust gegen VHost/Alias)
|
// Kandidat 1: relativ zu __DIR__ (robust gegen VHost/Alias)
|
||||||
$dirTargetDir = rtrim(__DIR__, "\\/") . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativeTargetDir);
|
$dirTargetDir = rtrim(__DIR__, "\\/") . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativeTargetDir);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string $documentRoot Dokumentenwurzel des Webservers (falls gesetzt).
|
||||||
|
*/
|
||||||
// Kandidat 2: relativ zu DOCUMENT_ROOT (nur wenn gesetzt)
|
// Kandidat 2: relativ zu DOCUMENT_ROOT (nur wenn gesetzt)
|
||||||
$documentRoot = isset($_SERVER['DOCUMENT_ROOT']) ? (string)$_SERVER['DOCUMENT_ROOT'] : '';
|
$documentRoot = isset($_SERVER['DOCUMENT_ROOT']) ? (string)$_SERVER['DOCUMENT_ROOT'] : '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string $docRootTrim Formatierter Dokumenten-Root-Pfad (ohne abschließende Slashes).
|
||||||
|
*/
|
||||||
$docRootTrim = rtrim($documentRoot, "\\/");
|
$docRootTrim = rtrim($documentRoot, "\\/");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string $docTargetDir Absoluter Zielordner aus der Server-Variablen (DOCUMENT_ROOT).
|
||||||
|
*/
|
||||||
$docTargetDir = ($docRootTrim !== '')
|
$docTargetDir = ($docRootTrim !== '')
|
||||||
? $docRootTrim . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativeTargetDir)
|
? $docRootTrim . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativeTargetDir)
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string $targetDir Finaler Pfad, in den die Datei verschoben wird.
|
||||||
|
* @details Bevorzugt wird __DIR__. Falls dieses aus Systemgründen fehlschlägt und DOCUMENT_ROOT zur Verfügung steht, wird dieser verwendet.
|
||||||
|
*/
|
||||||
// Bevorzugt __DIR__. Falls __DIR__ aus irgendeinem Grund nicht ins Projekt zeigt, und DOCUMENT_ROOT plausibel ist, nutze DOCUMENT_ROOT.
|
// Bevorzugt __DIR__. Falls __DIR__ aus irgendeinem Grund nicht ins Projekt zeigt, und DOCUMENT_ROOT plausibel ist, nutze DOCUMENT_ROOT.
|
||||||
$targetDir = $dirTargetDir;
|
$targetDir = $dirTargetDir;
|
||||||
if ($docTargetDir !== '' && !is_dir($dirTargetDir) && is_dir($docTargetDir))
|
if ($docTargetDir !== '' && !is_dir($dirTargetDir) && is_dir($docTargetDir))
|
||||||
@ -81,6 +176,11 @@ if ($docTargetDir !== '' && !is_dir($dirTargetDir) && is_dir($docTargetDir))
|
|||||||
$targetDir = $docTargetDir;
|
$targetDir = $docTargetDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Prüft, ob das Zielverzeichnis existiert und erstellt es gegebenenfalls.
|
||||||
|
*
|
||||||
|
* @details Nutzt mkdir() rekursiv, um fehlende Ordnerstrukturen zu generieren.
|
||||||
|
*/
|
||||||
if (!is_dir($targetDir))
|
if (!is_dir($targetDir))
|
||||||
{
|
{
|
||||||
$mkOk = @mkdir($targetDir, 0755, true);
|
$mkOk = @mkdir($targetDir, 0755, true);
|
||||||
@ -94,6 +194,9 @@ if (!is_dir($targetDir))
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Prüft, ob in den Zielordner geschrieben werden darf.
|
||||||
|
*/
|
||||||
if (!is_writable($targetDir))
|
if (!is_writable($targetDir))
|
||||||
{
|
{
|
||||||
error_log('Upload: targetDir not writable: ' . $targetDir);
|
error_log('Upload: targetDir not writable: ' . $targetDir);
|
||||||
@ -101,12 +204,28 @@ if (!is_writable($targetDir))
|
|||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string $timestamp Ein Zeitstempel zur Vergabe einmaliger Dateinamen.
|
||||||
|
*/
|
||||||
// Dateiname: user_<ID>_<Datum>.<ext>
|
// Dateiname: user_<ID>_<Datum>.<ext>
|
||||||
// Format ist dateisystem-sicher (keine Doppelpunkte) und eindeutig genug.
|
// Format ist dateisystem-sicher (keine Doppelpunkte) und eindeutig genug.
|
||||||
$timestamp = gmdate('Ymd-His');
|
$timestamp = gmdate('Ymd-His');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string $filename Der zu verwendende neue Name für die hochgeladene Datei.
|
||||||
|
*/
|
||||||
$filename = 'user_' . $userId . '_' . $timestamp . '.' . $ext;
|
$filename = 'user_' . $userId . '_' . $timestamp . '.' . $ext;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string $targetPath Der genaue finale Pfad (inkl. Dateiname).
|
||||||
|
*/
|
||||||
$targetPath = rtrim($targetDir, "\\/") . DIRECTORY_SEPARATOR . $filename;
|
$targetPath = rtrim($targetDir, "\\/") . DIRECTORY_SEPARATOR . $filename;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Verschiebt die hochgeladene Datei aus dem temporären Verzeichnis in den endgültigen Pfad.
|
||||||
|
*
|
||||||
|
* @details Schlägt dieser Vorgang fehl, wird ein serverseitiges Error-Log geschrieben und eine Weiterleitung durchgeführt.
|
||||||
|
*/
|
||||||
if (!move_uploaded_file($tmp, $targetPath))
|
if (!move_uploaded_file($tmp, $targetPath))
|
||||||
{
|
{
|
||||||
$lastErr = error_get_last();
|
$lastErr = error_get_last();
|
||||||
@ -116,15 +235,36 @@ if (!move_uploaded_file($tmp, $targetPath))
|
|||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string $publicPath Öffentlich zugänglicher URL-Pfad relativ zum Webroot.
|
||||||
|
* Dies ist der Pfad, der in der Datenbank gespeichert wird.
|
||||||
|
*/
|
||||||
// Pfad, der in HTML genutzt wird (URL relativ zur Webroot)
|
// Pfad, der in HTML genutzt wird (URL relativ zur Webroot)
|
||||||
$publicPath = 'assets/images/profilePictures/' . $filename;
|
$publicPath = 'assets/images/profilePictures/' . $filename;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string $servername Adresse des Datenbankservers (veraltet in diesem Skript, aber vorhanden).
|
||||||
|
*/
|
||||||
$servername = "localhost";
|
$servername = "localhost";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int $port Portadresse der Datenbank (ebenfalls ungenutzt im direkten Setup hier).
|
||||||
|
*/
|
||||||
$port = 3306;
|
$port = 3306;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var mysqli|bool $conn Die per db_connect (aus lib/db.php) aufgebaute Verbindung zur Datenbank.
|
||||||
|
*/
|
||||||
$conn = db_connect();
|
$conn = db_connect();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var mysqli_stmt|false $stmt Das vorbereitete Statement zur Aktualisierung des Benutzer-Profilbilds.
|
||||||
|
*/
|
||||||
$stmt = mysqli_prepare($conn, "UPDATE users SET profilePicture = ? WHERE userID = ?");
|
$stmt = mysqli_prepare($conn, "UPDATE users SET profilePicture = ? WHERE userID = ?");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Bricht ab, wenn das Prepared Statement nicht erstellt werden konnte.
|
||||||
|
*/
|
||||||
if (!$stmt)
|
if (!$stmt)
|
||||||
{
|
{
|
||||||
mysqli_close($conn);
|
mysqli_close($conn);
|
||||||
@ -132,16 +272,26 @@ if (!$stmt)
|
|||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Bindet Parameter, führt das Statement aus und schließt anschließend Verbindung und Statement.
|
||||||
|
*/
|
||||||
mysqli_stmt_bind_param($stmt, 'si', $publicPath, $userId);
|
mysqli_stmt_bind_param($stmt, 'si', $publicPath, $userId);
|
||||||
$ok = mysqli_stmt_execute($stmt);
|
$ok = mysqli_stmt_execute($stmt);
|
||||||
mysqli_stmt_close($stmt);
|
mysqli_stmt_close($stmt);
|
||||||
$conn->close();
|
$conn->close();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Leitet bei einem fehlgeschlagenen Datenbankupdate zur Error-Variante der account.php weiter.
|
||||||
|
*/
|
||||||
if (!$ok)
|
if (!$ok)
|
||||||
{
|
{
|
||||||
header('Location: account.php?upload=err');
|
header('Location: account.php?upload=err');
|
||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Der Upload war erfolgreich; Umleitung zur Account-Seite mit Erfolgsmeldung.
|
||||||
|
*/
|
||||||
header('Location: account.php?upload=ok');
|
header('Location: account.php?upload=ok');
|
||||||
exit();
|
exit();
|
||||||
|
|
||||||
|
|||||||
113
wunschliste.php
113
wunschliste.php
@ -1,37 +1,118 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* @file wunschliste.php
|
||||||
|
* @brief Darstellung der persönlichen Wunschliste eines Benutzers
|
||||||
|
*
|
||||||
|
* @details Diese Datei ist dafür verantwortlich, alle Produkte anzuzeigen,
|
||||||
|
* die der aktuell eingeloggte Benutzer zu seiner Wunschliste hinzugefügt hat.
|
||||||
|
* Sie greift auf die Datenbank zu, um die Verknüpfung zwischen Benutzer
|
||||||
|
* (über die aktuelle Session) und den favorisierten Produkten (aus der Tabelle userFavorites)
|
||||||
|
* herzustellen.
|
||||||
|
* @author Geizkragen-Team
|
||||||
|
* @version 1.0
|
||||||
|
* @since 1.0
|
||||||
|
*/
|
||||||
|
|
||||||
// wunschliste.php
|
// wunschliste.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Einbinden der grundlegenden Bootstrapping-Konfiguration
|
||||||
|
* @details Die Datei bootstrap.php lädt Helferfunktionen, Konfigurationsdaten
|
||||||
|
* und initialisiert die Session, wodurch Zugriffe auf $_SESSION möglich werden.
|
||||||
|
*/
|
||||||
require_once __DIR__ . '/lib/bootstrap.php';
|
require_once __DIR__ . '/lib/bootstrap.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Herstellen der Datenbankverbindung
|
||||||
|
* @details Ruft db_connect() auf, um eine Instanz zur Datenbank aufzubauen.
|
||||||
|
* Diese wird für das spätere Abrufen der Wunschlisten-Elemente benötigt.
|
||||||
|
* @var mysqli $conn Aktives Datenbankverbindungsobjekt.
|
||||||
|
*/
|
||||||
// 1) DB-Verbindung (einmal)
|
// 1) DB-Verbindung (einmal)
|
||||||
$conn = db_connect();
|
$conn = db_connect();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Einschränkung des Datenzugriffs und Weiterleitung unbefugter Benutzer
|
||||||
|
* @details Prüft, ob ein Benutzer aktuell eingeloggt ist. Die Weiterleitung
|
||||||
|
* (HTTP-Header Location) muss zwingend erfolgen, bevor jegliche HTML-Struktur
|
||||||
|
* gerendert wurde. Danach beendet exit() sofort die Ausführung dieses Skripts.
|
||||||
|
*/
|
||||||
// Login-Check + Redirect MUSS vor jeglicher HTML-Ausgabe passieren
|
// Login-Check + Redirect MUSS vor jeglicher HTML-Ausgabe passieren
|
||||||
if (!isset($_SESSION['user_id'])) {
|
if (!isset($_SESSION['user_id'])) {
|
||||||
header("Location: login.php");
|
header("Location: login.php");
|
||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Laden der Wunschlisten-Daten aus der Datenbank
|
||||||
|
* @details Ein "Prepared Statement" wird aufgebaut, um die Produktdaten (ID, Modell,
|
||||||
|
* Beschreibung, Bildpfad) sicher aus der Datenbank abzufragen. Hierbei werden die
|
||||||
|
* Tabellen 'userFavorites' und 'products' per INNER JOIN anhand der productID zusammengeführt.
|
||||||
|
* @var mysqli_stmt $stmt Das vorbereitete SQL-Statement Objekt.
|
||||||
|
*/
|
||||||
// Daten laden
|
// Daten laden
|
||||||
$stmt = $conn->prepare("
|
$stmt = $conn->prepare("
|
||||||
SELECT products.productID, products.model, products.description, products.imagePath
|
SELECT products.productID, products.model, products.description, products.imagePath
|
||||||
FROM userFavorites INNER JOIN products ON userFavorites.productID = products.productID
|
FROM userFavorites INNER JOIN products ON userFavorites.productID = products.productID
|
||||||
WHERE userID = ?
|
WHERE userID = ?
|
||||||
");
|
");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Binden der Nutzer-ID an das SQL-Statement
|
||||||
|
* @details Verbindet den Typ 'i' (Integer) mit dem tatsächlichen Wert der aktuellen Session-ID.
|
||||||
|
*/
|
||||||
$stmt->bind_param("i", $_SESSION['user_id']);
|
$stmt->bind_param("i", $_SESSION['user_id']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Ausführung der vorbereiteten Abfrage
|
||||||
|
*/
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Extrahieren der Ergebnismenge
|
||||||
|
* @details Speichert das Resulat der Abfrage, welches in der GUI anschließend durchlaufen wird.
|
||||||
|
* @var mysqli_result $result Die vom Datenbankserver zurückgelieferte Ergebnismenge.
|
||||||
|
*/
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<?php include 'header.php'; ?>
|
<?php
|
||||||
|
/**
|
||||||
|
* @brief Einbinden des HTML-Headers
|
||||||
|
* @details Lädt die globale Navigation, CSS-Dateien und nötige Meta-Informationen.
|
||||||
|
*/
|
||||||
|
include 'header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
<?php if ($result->num_rows > 0): ?>
|
<?php
|
||||||
|
/**
|
||||||
|
* @brief Bedingung zur Anzeige der Produktliste
|
||||||
|
* @details Evaluiert, ob das vorherige Statement mindestens ein favorisiertes
|
||||||
|
* Produkt für den Benutzer zurückgeliefert hat. Ist dies der Fall, wird die
|
||||||
|
* Liste gerendert. Andernfalls erscheint ein leerer Hinweisbildschirm.
|
||||||
|
*/
|
||||||
|
if ($result->num_rows > 0):
|
||||||
|
?>
|
||||||
<main>
|
<main>
|
||||||
<section class="product-section">
|
<section class="product-section">
|
||||||
<h2>Deine Wunschliste</h2>
|
<h2>Deine Wunschliste</h2>
|
||||||
<div class="wishlist-grid">
|
<div class="wishlist-grid">
|
||||||
<?php while ($product = $result->fetch_assoc()): ?>
|
<?php
|
||||||
<?php $productId = (int)$product['productID']; ?>
|
/**
|
||||||
|
* @brief Iteration über alle favorisierten Produkte
|
||||||
|
* @details In jedem Durchlauf wird ein Produkt als assoziatives Array
|
||||||
|
* zurückgegeben und direkt per HTML im Frontend abgebildet.
|
||||||
|
*/
|
||||||
|
while ($product = $result->fetch_assoc()):
|
||||||
|
?>
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @brief Casting der Produkt ID
|
||||||
|
* @var int $productId Stellt sicher, dass die Produkt-ID als strenger Integer
|
||||||
|
* zur Erzeugung der Link-URL vorliegt.
|
||||||
|
*/
|
||||||
|
$productId = (int)$product['productID'];
|
||||||
|
?>
|
||||||
<a class="product-card wishlist-card" href="productpage.php?id=<?= $productId ?>">
|
<a class="product-card wishlist-card" href="productpage.php?id=<?= $productId ?>">
|
||||||
<img
|
<img
|
||||||
src="<?= isset($product['imagePath']) ? $product['imagePath'] : 'assets/images/placeholder.png' ?>"
|
src="<?= isset($product['imagePath']) ? $product['imagePath'] : 'assets/images/placeholder.png' ?>"
|
||||||
@ -47,12 +128,30 @@ $result = $stmt->get_result();
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @brief Fallback Ansicht
|
||||||
|
* @details Dieser Abschnitt wird angezeigt, wenn der Nutzer noch
|
||||||
|
* keine Artikel als Favoriten markiert hat.
|
||||||
|
*/
|
||||||
|
?>
|
||||||
<main style="padding: 2rem 1rem; text-align: center; animation: fadeInUp 0.5s ease both;">
|
<main style="padding: 2rem 1rem; text-align: center; animation: fadeInUp 0.5s ease both;">
|
||||||
<p style="color: var(--text-secondary); font-size: 1rem;">Deine Wunschliste ist noch leer.</p>
|
<p style="color: var(--text-secondary); font-size: 1rem;">Deine Wunschliste ist noch leer.</p>
|
||||||
</main>
|
</main>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php $stmt->close(); ?>
|
<?php
|
||||||
|
/**
|
||||||
|
* @brief Freigabe der SQL-Ressourcen
|
||||||
|
* @details Schließt das PreparedStatement sicher und räumt belegten Speicher wieder auf.
|
||||||
|
*/
|
||||||
|
$stmt->close();
|
||||||
|
?>
|
||||||
|
|
||||||
|
<?php
|
||||||
<?php include 'footer.php'; ?>
|
/**
|
||||||
|
* @brief Einbinden des HTML-Footers
|
||||||
|
* @details Rendert rechtliche Links sowie abschließende Skripte für die Seite.
|
||||||
|
*/
|
||||||
|
include 'footer.php';
|
||||||
|
?>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user