Initial commit: Clean portfolio codebase with security hardening

This commit is contained in:
Philipp Urbschat 2026-09-12 21:10:04 +02:00
commit aafabba595
206 changed files with 24947 additions and 0 deletions

38
.gitignore vendored Normal file
View file

@ -0,0 +1,38 @@
# Environment & Secrets
.env
*.env
**/.env
!.env.example
!*.env.example
!**/.env.example
# OAuth & Sensitive Data
client_secret.json
**/client_secret.json
tokens.json
**/tokens.json
usage_stats.json
**/usage_stats.json
*.sql
*.sqlite
# Dependencies
node_modules/
**/node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# OS & IDE Files
.DS_Store
**/.DS_Store
Thumbs.db
.vscode/
.idea/
*.sublime-project
*.sublime-workspace
# Temporary & Export Files
*export*
*.tmp
*.log

30
.htaccess Normal file
View file

@ -0,0 +1,30 @@
# DATEI: ./.htaccess
Options -Indexes
RewriteEngine On
# Interne PHP- und Template-Verzeichnisse vor direktem Webzugriff sperren
RewriteRule ^(home|philcore)/(app|core|views|src)/ - [F,L]
# Sensible Dateien und Verzeichnisse vor direktem Webzugriff schützen
<FilesMatch "(^\.|\.(json|lock|sql|env|md)$)">
Require all denied
</FilesMatch>
<Files "auth.php">
Require all denied
</Files>
# 1. Dynamische XML/TXT Dateien -> Ab zum SeoController
RewriteRule ^sitemap\.xml$ home/public/index.php?url=seo/sitemap [L,QSA]
RewriteRule ^robots\.txt$ home/public/index.php?url=seo/robots [L,QSA]
# 2. Domain-Einstieg
RewriteRule ^$ home/public/index.php [L]
# 3. Echte Assets schützen
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} !^/home/public/
# 4. Globales Routing
RewriteRule ^(.*)$ home/public/index.php?url=$1 [L,QSA]

131
admin-tools/index.php Normal file
View file

@ -0,0 +1,131 @@
<?php
// Schritt 1: Das Projekt meldet sich beim Türsteher
$current_project = 'admin-tools';
require_once '../auth.php'; // Der Türsteher prüft, ob du Admin bist
// Schritt 2: Prüfen, ob das Formular abgeschickt wurde
$generated_hash = null;
$original_password = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['password_to_hash'])) {
$original_password = $_POST['password_to_hash'];
$generated_hash = password_hash($original_password, PASSWORD_DEFAULT);
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin: Hash-Generator</title>
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=Lato:wght@400;700&display=swap" rel="stylesheet">
<style>
/* ... komplettes CSS von vorher ... */
:root {
--primary-color: #00e6e6; /* Aqua Blue */
--secondary-color: #ff66b2; /* Hot Pink */
--background-color: #121212;
--box-bg-color: #1f1f1f;
--text-color: #e0e0e0;
--error-color: #ff4d4d;
--glow-effect: none;
--retro-font: "Press Start 2P", cursive;
--modern-font: "Lato", sans-serif;
--cat-body-color: #8a2be2;
--cat-detail-color: var(--secondary-color);
--cat-eye-color: var(--primary-color);
--pixel-size: 2px;
}
body { font-family: var(--modern-font); background-color: var(--background-color); color: var(--text-color); margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; overflow: hidden; position: relative; }
body::before { content: ""; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: repeating-linear-gradient(transparent, rgba(0, 0, 0, 0.3) 1px, transparent 4px); pointer-events: none; animation: scanlines 4s linear infinite; z-index: 100; }
.container { text-align: center; padding: 2rem; max-width: 90%; width: 400px; }
.header h1 { font-family: var(--retro-font); color: var(--primary-color); font-size: 2.5rem; text-shadow: none; margin-bottom: 0.5rem; }
.header p { font-family: var(--retro-font); color: var(--secondary-color); font-size: 1rem; margin-bottom: 2rem; }
.pixel-cat { position: relative; width: calc(30 * var(--pixel-size)); height: calc(35 * var(--pixel-size)); margin: 0 auto -10px; z-index: 10; }
.cat-body { position: absolute; top: calc(15 * var(--pixel-size)); left: calc(5 * var(--pixel-size)); width: calc(20 * var(--pixel-size)); height: calc(15 * var(--pixel-size)); background-color: var(--cat-body-color); z-index: 1; }
.cat-head { position: absolute; top: calc(5 * var(--pixel-size)); left: calc(8 * var(--pixel-size)); width: calc(14 * var(--pixel-size)); height: calc(10 * var(--pixel-size)); background-color: var(--cat-body-color); z-index: 2; }
.cat-ear { position: absolute; width: calc(4 * var(--pixel-size)); height: calc(5 * var(--pixel-size)); background-color: var(--cat-body-color); z-index: 0; }
.cat-ear.left { top: calc(0 * var(--pixel-size)); left: calc(7 * var(--pixel-size)); }
.cat-ear.right { top: calc(0 * var(--pixel-size)); right: calc(7 * var(--pixel-size)); }
.cat-eye { position: absolute; width: calc(2 * var(--pixel-size)); height: calc(2 * var(--pixel-size)); background-color: var(--cat-eye-color); z-index: 3; animation: eye-blink 10s infinite; }
.cat-eye.left { top: calc(8 * var(--pixel-size)); left: calc(10 * var(--pixel-size)); }
.cat-eye.right { top: calc(8 * var(--pixel-size)); right: calc(10 * var(--pixel-size)); }
.cat-nose { position: absolute; top: calc(11 * var(--pixel-size)); left: calc(14 * var(--pixel-size)); width: calc(2 * var(--pixel-size)); height: calc(1 * var(--pixel-size)); background-color: var(--cat-detail-color); z-index: 3; }
.cat-tail { position: absolute; top: calc(19 * var(--pixel-size)); left: calc(22 * var(--pixel-size)); width: calc(3 * var(--pixel-size)); height: calc(10 * var(--pixel-size)); background-color: var(--cat-body-color); transform-origin: bottom center; animation: tail-wag 15s infinite; z-index: 0; }
.main-content { background-color: var(--box-bg-color); padding: 2rem; border: 2px solid var(--cat-body-color); box-shadow: none; border-radius: 0; position: relative; overflow: hidden; min-height: 350px; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.input-group { margin-bottom: 1.5rem; text-align: left; width: 100%; }
.input-group label { display: block; font-family: var(--retro-font); color: var(--text-color); margin-bottom: 0.5rem; font-size: 0.8rem; }
.input-group input { width: 100%; padding: 10px; border: 2px solid var(--primary-color); background-color: rgba(0, 0, 0, 0.4); color: var(--primary-color); font-family: var(--retro-font); outline: none; box-shadow: none; transition: box-shadow 0.3s ease, border-color 0.3s ease; box-sizing: border-box; }
.input-group input:focus { border-color: var(--secondary-color); }
.login-btn, .retry-btn { width: 100%; padding: 12px; background-color: var(--primary-color); color: var(--background-color); font-family: var(--retro-font); border: none; cursor: pointer; font-size: 1rem; text-transform: uppercase; transition: transform 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease; text-decoration: none; display: block; box-sizing: border-box; margin-bottom: 1rem; }
.login-btn:hover, .retry-btn:hover { transform: translateY(-2px); background-color: var(--secondary-color); box-shadow: 0 4px 8px rgba(0,0,0,0.3); }
.result { margin-top: 2rem; background-color: rgba(0,0,0,0.4); padding: 1rem; text-align: left; font-family: monospace; color: var(--primary-color); word-wrap: break-word; width: 100%; box-sizing: border-box; }
.result code { font-size: 0.9rem; }
/* --- NEU: Style für klickbaren Hash --- */
.result code {
cursor: pointer;
transition: color 0.2s ease;
}
.result code:hover {
color: var(--secondary-color);
}
@keyframes scanlines { 0% { background-position: 0 0; } 100% { background-position: 0 50px; } }
@keyframes tail-wag { 0%, 90% { transform: rotate(0deg); } 92.5% { transform: rotate(0deg); } 95% { transform: rotate(25deg); } 97.5% { transform: rotate(0deg); } 100% { transform: rotate(0deg); } }
@keyframes eye-blink { 0%, 99% { transform: scaleY(1); } 100% { transform: scaleY(0); } }
</style>
</head>
<body>
<div class="container">
<header class="header">
<h1>ADMIN TOOLS</h1>
<p>Password Hash Generator</p>
</header>
<div class="pixel-cat"></div>
<main class="main-content">
<form action="index.php" method="POST" style="width: 100%;">
<div class="input-group">
<label for="password_input">NEW PASSWORD</label>
<input type="text" id="password_input" name="password_to_hash" placeholder="> Enter new key" autofocus>
</div>
<button type="submit" class="login-btn">GENERATE HASH</button>
</form>
<?php if ($generated_hash): ?>
<div class="result">
<strong>Original:</strong> <?php echo htmlspecialchars($original_password); ?><br><br>
<strong id="hash-label">Hash (klicken zum Kopieren):</strong><br>
<code id="hash-output"><?php echo $generated_hash; ?></code>
</div>
<?php endif; ?>
<a href="../index.php" class="retry-btn" style="margin-top: 2rem; background-color: #555;">BACK</a>
</main>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const hashOutput = document.getElementById('hash-output');
const hashLabel = document.getElementById('hash-label');
if (hashOutput) {
hashOutput.addEventListener('click', () => {
const hashText = hashOutput.innerText;
navigator.clipboard.writeText(hashText).then(() => {
// Erfolgreich kopiert! Gib dem Nutzer Feedback.
hashLabel.innerText = 'Kopiert! ✔️';
// Ändere es nach 2 Sekunden zurück.
setTimeout(() => {
hashLabel.innerHTML = 'Hash (klicken zum Kopieren):';
}, 2000);
}).catch(err => {
console.error('Fehler beim Kopieren: ', err);
hashLabel.innerText = 'Fehler beim Kopieren!';
});
});
}
});
</script>
</body>
</html>

38
auth.php Normal file
View file

@ -0,0 +1,38 @@
<?php
// DATEI: ./auth.php (liegt ganz oben im Root)
if (session_status() === PHP_SESSION_NONE) {
// Session für die gesamte Domain ("/") gültig machen
session_set_cookie_params([
'path' => '/',
'httponly' => true,
'samesite' => 'Lax' // Lax erlaubt den Wechsel zwischen /home und /test
]);
session_start();
}
// 1. Ist der User eingeloggt?
if (!isset($_SESSION['user_email'])) {
header('Location: /');
exit();
}
// 2. Browser-Check gegen Session Hijacking
if (!isset($_SESSION['user_agent'])) {
$_SESSION['user_agent'] = $_SERVER['HTTP_USER_AGENT'];
} elseif ($_SESSION['user_agent'] !== $_SERVER['HTTP_USER_AGENT']) {
session_destroy();
header('Location: /');
exit();
}
// 3. Rechte-Check (Admins dürfen immer rein)
$isAdmin = isset($_SESSION['is_admin']) && $_SESSION['is_admin'] === true;
$hasProjectAccess = isset($current_project) && isset($_SESSION['user_projects']) && in_array($current_project, $_SESSION['user_projects']);
if (!$isAdmin && !$hasProjectAccess) {
// Kein Admin und kein spezifischer Projekt-Zugriff
header('Location: /');
exit();
}
?>

286
cooles-feature/index.php Normal file
View file

@ -0,0 +1,286 @@
<?php
// Session starten, falls noch nicht geschehen
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// Einfache Logout-Logik direkt in dieser Datei
if (isset($_GET['logout'])) {
session_destroy();
// Seite neu laden ohne Parameter
header("Location: index.php");
exit;
}
// 1. Projekt identifizieren und schützen
$current_project = 'cooles-feature';
require_once '../auth.php';
// 2. Nutzerdaten abrufen
$loggedInUser = isset($_SESSION['user_email']) ? $_SESSION['user_email'] : 'neuankoemmling@beispiel.de';
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Erster Login-Check: Erfolgreich!</title>
<style>
:root {
--primary: #ff00ff;
--secondary: #00ff00;
--bg: #222;
--text: #fff;
--accent: #ffff00;
}
body {
background-color: var(--bg);
color: var(--text);
font-family: 'Comic Sans MS', 'Comic Sans', cursive, sans-serif;
margin: 0;
padding: 20px;
overflow-x: hidden;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.container {
background: #333;
border: 8px solid var(--primary);
padding: 40px;
max-width: 600px;
width: 100%;
border-radius: 30px;
transform: rotate(-1.5deg);
box-shadow: 20px 20px 0px var(--secondary);
position: relative;
}
.header {
text-align: center;
border-bottom: 4px dashed var(--accent);
margin-bottom: 20px;
transform: rotate(2deg);
}
h1 {
color: var(--secondary);
text-shadow: 3px 3px 0px var(--primary);
font-size: 2.2rem;
margin-bottom: 5px;
line-height: 1.2;
}
.user-info {
background: #444;
padding: 5px 15px;
display: inline-block;
border-radius: 10px;
margin-bottom: 10px;
border: 2px solid var(--accent);
}
.main-content {
text-align: center;
margin: 30px 0;
}
#whoopee-cushion {
width: 160px;
height: 160px;
background: #ff4d4d;
border-radius: 50%;
border: 6px solid #b30000;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
font-size: 14px;
font-weight: bold;
color: white;
text-align: center;
margin: 0 auto;
position: relative;
user-select: none;
transition: transform 0.05s;
box-shadow: 0 12px 0 #b30000;
}
#whoopee-cushion:active {
transform: translateY(10px) scale(0.92);
box-shadow: 0 2px 0 #b30000;
}
#whoopee-cushion::after {
content: "HIER DRÜCKEN!";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.fart-cloud {
position: absolute;
background: rgba(180, 255, 100, 0.7);
border-radius: 50%;
pointer-events: none;
z-index: 10;
animation: float-up 1.5s ease-out forwards;
filter: blur(5px);
}
@keyframes float-up {
0% { transform: translateY(0) scale(0.5); opacity: 0.8; }
100% { transform: translateY(-150px) scale(4); opacity: 0; }
}
.logout-btn {
display: block;
width: fit-content;
margin: 30px auto 0;
background: var(--accent);
color: #000;
padding: 12px 25px;
text-decoration: none;
font-weight: bold;
border-radius: 10px;
transform: rotate(-2deg);
border: 4px solid #000;
transition: 0.2s;
}
.logout-btn:hover {
transform: rotate(0deg) scale(1.1);
background: #ff3300;
color: #fff;
}
.shake {
animation: shake 0.4s cubic-bezier(.36,.07,.19,.97) both;
}
@keyframes shake {
10%, 90% { transform: translate3d(-2px, 0, 0) rotate(-1.5deg); }
20%, 80% { transform: translate3d(4px, 0, 0) rotate(-2deg); }
30%, 50%, 70% { transform: translate3d(-6px, 0, 0) rotate(-1deg); }
40%, 60% { transform: translate3d(6px, 0, 0) rotate(-1.8deg); }
}
</style>
</head>
<body>
<div class="container" id="app-container">
<header class="header">
<h1>Willkommen beim ersten Check! 🎉</h1>
<div class="user-info">
Eingeloggt als: <strong><?php echo htmlspecialchars($loggedInUser); ?></strong>
</div>
</header>
<main class="main-content">
<p><strong>Glückwunsch!</strong> Deine Zugangsdaten sind korrekt und du bist erfolgreich im System gelandet.</p>
<p style="font-size: 0.9rem;">Um sicherzugehen, dass auch interaktive Elemente bei dir funktionieren, teste bitte unser hochmodernes Belohnungsmodul:</p>
<div class="fart-section" style="margin-top: 30px;">
<div id="whoopee-cushion" title="Funktionstest für Sound und Animation"></div>
<p id="status-msg" style="margin-top: 20px; font-style: italic; color: #888;">Hinweis: Sound bitte einschalten!</p>
</div>
</main>
<footer>
<!-- Link angepasst auf Parameter-Logout -->
<a href="?logout=1" class="logout-btn">Test beendet? Hier abmelden!</a>
</footer>
</div>
<script>
const cushion = document.getElementById('whoopee-cushion');
const container = document.getElementById('app-container');
const statusMsg = document.getElementById('status-msg');
function playOrganicFart() {
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (audioCtx.state === 'suspended') audioCtx.resume();
const duration = 0.4 + Math.random() * 0.5;
const mainGain = audioCtx.createGain();
const osc = audioCtx.createOscillator();
const oscGain = audioCtx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(45 + Math.random() * 20, audioCtx.currentTime);
osc.frequency.exponentialRampToValueAtTime(20, audioCtx.currentTime + duration);
const lfo = audioCtx.createOscillator();
const lfoGain = audioCtx.createGain();
lfo.type = 'square';
lfo.frequency.setValueAtTime(14 + Math.random() * 8, audioCtx.currentTime);
lfoGain.gain.setValueAtTime(0.8, audioCtx.currentTime);
lfo.connect(lfoGain);
lfoGain.connect(oscGain.gain);
const bufferSize = audioCtx.sampleRate * duration;
const noiseBuffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
const noiseData = noiseBuffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
noiseData[i] = Math.random() * 2 - 1;
}
const noise = audioCtx.createBufferSource();
noise.buffer = noiseBuffer;
const noiseFilter = audioCtx.createBiquadFilter();
noiseFilter.type = 'bandpass';
noiseFilter.frequency.setValueAtTime(400, audioCtx.currentTime);
const lowPass = audioCtx.createBiquadFilter();
lowPass.type = 'lowpass';
lowPass.frequency.setValueAtTime(450, audioCtx.currentTime);
lowPass.frequency.exponentialRampToValueAtTime(80, audioCtx.currentTime + duration);
mainGain.gain.setValueAtTime(0, audioCtx.currentTime);
mainGain.gain.linearRampToValueAtTime(0.5, audioCtx.currentTime + 0.05);
mainGain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + duration);
osc.connect(oscGain);
oscGain.connect(lowPass);
noise.connect(noiseFilter);
noiseFilter.connect(lowPass);
lowPass.connect(mainGain);
mainGain.connect(audioCtx.destination);
osc.start();
lfo.start();
noise.start();
osc.stop(audioCtx.currentTime + duration);
lfo.stop(audioCtx.currentTime + duration);
noise.stop(audioCtx.currentTime + duration);
statusMsg.textContent = "Interaktionstest: Bestanden! 👍";
}
function createCloud() {
const cloud = document.createElement('div');
cloud.classList.add('fart-cloud');
const size = Math.random() * 60 + 20;
cloud.style.width = size + 'px';
cloud.style.height = size + 'px';
const rect = cushion.getBoundingClientRect();
cloud.style.left = (rect.left + rect.width / 2 - size / 2 + (Math.random() * 100 - 50)) + 'px';
cloud.style.top = (rect.top + rect.height / 2 - size / 2) + 'px';
document.body.appendChild(cloud);
setTimeout(() => cloud.remove(), 1500);
}
cushion.addEventListener('click', () => {
playOrganicFart();
container.classList.add('shake');
setTimeout(() => container.classList.remove('shake'), 400);
for(let i = 0; i < 12; i++) {
setTimeout(createCloud, i * 80);
}
});
</script>
</body>
</html>

1
dinos/.env.example Normal file
View file

@ -0,0 +1 @@
GEMINI_API_KEY="your_gemini_api_key_here"

13
dinos/.htaccess Normal file
View file

@ -0,0 +1,13 @@
# 2026-01-08 18:45:00
# Verhindert, dass Fremde deine Schlüssel-Dateien herunterladen können.
# PHP kann sie weiterhin lesen, aber der Browser nicht.
# Verhindert, dass Fremde Schlüssel- und Status-Dateien herunterladen können.
<FilesMatch "(^\.|\.(json|txt|env)$)">
Require all denied
</FilesMatch>
# Erlaubt Zugriff auf dinos/auth.php (wichtig für Google OAuth Callback)
<Files "auth.php">
Require all granted
</Files>

Binary file not shown.

109
dinos/apis.php Normal file
View file

@ -0,0 +1,109 @@
<?php
/*
* Google Gemini API - Model Explorer
* 2026-02-23
*/
// 1. Projekt identifizieren und schützen
$current_project = 'dinos';
require_once '../auth.php'; // Falls du deine globale Auth hast, sonst auskommentieren
// 2. Nutzerdaten abrufen
$loggedInUser = isset($_SESSION['user_email']) ? $_SESSION['user_email'] : 'Gast';
// Dein API-Key sicher aus .env laden
$apiKey = '';
$envFiles = [__DIR__ . '/.env', __DIR__ . '/../home/.env'];
foreach ($envFiles as $envFile) {
if (file_exists($envFile)) {
foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
if (strpos($line, '=') !== false) {
list($k, $v) = explode('=', $line, 2);
if (trim($k) === 'GEMINI_API_KEY') {
$apiKey = trim(trim($v), '"\'');
break 2;
}
}
}
}
}
$url = "https://generativelanguage.googleapis.com/v1beta/models?key=" . $apiKey;
// Daten abrufen
$response = file_get_contents($url);
$data = json_decode($response, true);
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>API Model Explorer</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-slate-900 text-slate-100 p-8 font-sans">
<div class="max-w-5xl mx-auto">
<header class="mb-8 border-b border-slate-700 pb-4">
<h1 class="text-3xl font-bold text-amber-400">🚀 Google API Model Explorer</h1>
<p class="text-slate-400 mt-2">Diese Modelle sind aktuell für deinen Key freigeschaltet.</p>
</header>
<?php if (isset($data['models'])): ?>
<div class="overflow-x-auto bg-slate-800 rounded-xl shadow-xl border border-slate-700">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-slate-700/50 text-amber-300 text-sm uppercase tracking-wider">
<th class="p-4 border-b border-slate-600">ID (für den Code)</th>
<th class="p-4 border-b border-slate-600">Anzeigename</th>
<th class="p-4 border-b border-slate-600 text-xs">Features</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-700">
<?php foreach ($data['models'] as $model):
// Wir entfernen das "models/" Präfix für eine saubere ID
$cleanId = str_replace('models/', '', $model['name']);
?>
<tr class="hover:bg-slate-700/30 transition-colors">
<td class="p-4 font-mono text-pink-400 text-sm">
<span class="bg-slate-900 px-2 py-1 rounded select-all cursor-pointer" title="Klicken zum Markieren">
<?php echo $cleanId; ?>
</span>
</td>
<td class="p-4">
<div class="font-bold text-slate-200"><?php echo $model['displayName']; ?></div>
<div class="text-xs text-slate-500 mt-1 max-w-md"><?php echo $model['description']; ?></div>
</td>
<td class="p-4">
<div class="flex flex-wrap gap-1">
<?php foreach ($model['supportedGenerationMethods'] as $method): ?>
<span class="text-[10px] bg-blue-900/40 text-blue-300 px-2 py-0.5 rounded border border-blue-800/50">
<?php echo $method; ?>
</span>
<?php endforeach; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div class="bg-red-900/20 border border-red-500 text-red-400 p-4 rounded-lg">
<strong>Fehler:</strong> Modelle konnten nicht geladen werden. Prüfe deinen API-Key.
</div>
<?php endif; ?>
<footer class="mt-8 text-center text-slate-600 text-xs">
Stand: <?php echo date('d.m.Y H:i'); ?> | Phili's Dino-Labor 🦕
</footer>
</div>
</body>
</html>

65
dinos/auth.php Normal file
View file

@ -0,0 +1,65 @@
<?php
/*
* 2026-01-08 16:05:00
* AUTHENTIFIZIERUNG PAGE
* Führt den Google Login durch und speichert den Token.
*/
require_once 'google_helper.php';
// Konfiguration
$secretFile = 'client_secret.json';
// WICHTIG: Hier muss exakt die URL stehen, die du in der Google Console eingetragen hast:
$redirectUri = 'https://philippurbschat.de/dinos/auth.php';
$google = new GoogleHelper($secretFile, $redirectUri);
// Szenario 1: Google schickt uns zurück (mit ?code=...)
if (isset($_GET['code'])) {
$success = $google->authenticate($_GET['code']);
if ($success) {
echo '<h1 style="color:green">Verbindung erfolgreich!</h1>';
echo '<p>Der Token wurde gespeichert (tokens.json). Du kannst dieses Fenster schließen.</p>';
echo '<a href="index.php">Zurück zum Dino-Generator</a>';
} else {
echo '<h1 style="color:red">Fehler beim Login</h1>';
echo '<p>Konnte Code nicht gegen Token tauschen.</p>';
}
exit;
}
// Szenario 2: Fehler von Google
if (isset($_GET['error'])) {
die("Google Fehler: " . htmlspecialchars($_GET['error']));
}
// Szenario 3: Startseite - Button anzeigen
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Google Photos Verbindung</title>
<style>
body { font-family: sans-serif; background: #0f172a; color: white; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }
.card { background: #1e293b; padding: 40px; border-radius: 20px; text-align: center; box-shadow: 0 10px 25px rgba(0,0,0,0.5); }
.btn { background: #4285F4; color: white; padding: 15px 30px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block; margin-top: 20px; }
.btn:hover { background: #3367D6; }
</style>
</head>
<body>
<div class="card">
<h1>Google Photos verbinden</h1>
<p>Erlaube der Dino-App, Bilder in dein Album zu laden.</p>
<?php if ($google->isAuthenticated()): ?>
<p style="color: #4ade80;"> Bereits verbunden!</p>
<p style="font-size: 0.8em; opacity: 0.7;">(Erneuter Klick erneuert die Verbindung)</p>
<?php endif; ?>
<a href="<?php echo $google->getAuthUrl(); ?>" class="btn">
Mit Google anmelden
</a>
</div>
</body>
</html>

150
dinos/google_helper.php Normal file
View file

@ -0,0 +1,150 @@
<?php
/*
* 2026-01-08 16:45:00 - Dino Karl
* GOOGLE HELPER KLASSE V2 (Mit Upload & Album Logic)
*/
class GoogleHelper {
private $clientId;
private $clientSecret;
private $redirectUri;
private $tokenFile = 'tokens.json';
private $albumFile = 'album_id.txt'; // Hier merken wir uns die Album ID
public function __construct($secretJsonPath, $redirectUri) {
if (!file_exists($secretJsonPath)) die("Fehler: client_secret.json fehlt!");
$secrets = json_decode(file_get_contents($secretJsonPath), true);
$conf = isset($secrets['web']) ? $secrets['web'] : (isset($secrets['installed']) ? $secrets['installed'] : null);
$this->clientId = $conf['client_id'];
$this->clientSecret = $conf['client_secret'];
$this->redirectUri = $redirectUri;
}
// --- AUTH LOGIK (Unverändert) ---
public function getAuthUrl() {
$params = [
'client_id' => $this->clientId, 'redirect_uri' => $this->redirectUri, 'response_type' => 'code',
'scope' => 'https://www.googleapis.com/auth/photoslibrary.appendonly',
'access_type' => 'offline', 'prompt' => 'consent'
];
return 'https://accounts.google.com/o/oauth2/auth?' . http_build_query($params);
}
public function authenticate($code) {
$data = $this->makeRequest('https://oauth2.googleapis.com/token', [
'code' => $code, 'client_id' => $this->clientId, 'client_secret' => $this->clientSecret,
'redirect_uri' => $this->redirectUri, 'grant_type' => 'authorization_code'
]);
if (isset($data['access_token'])) { $this->saveTokens($data); return true; }
return false;
}
private function getAccessToken() {
$tokens = $this->loadTokens();
if (!$tokens) return null;
if (time() >= ($tokens['created'] + $tokens['expires_in'] - 60)) {
return $this->refreshToken($tokens['refresh_token']);
}
return $tokens['access_token'];
}
private function refreshToken($refresh) {
$data = $this->makeRequest('https://oauth2.googleapis.com/token', [
'refresh_token' => $refresh, 'client_id' => $this->clientId,
'client_secret' => $this->clientSecret, 'grant_type' => 'refresh_token'
]);
if (isset($data['access_token'])) {
$data['refresh_token'] = $refresh; // Alten Refresh Token behalten
$this->saveTokens($data);
return $data['access_token'];
}
return null;
}
// --- NEU: UPLOAD LOGIK ---
public function uploadImage($imageData, $description) {
$token = $this->getAccessToken();
if (!$token) return ['error' => 'Nicht eingeloggt'];
// 1. Das Album finden oder erstellen
$albumId = $this->getOrCreateAlbum($token);
// 2. Die Bytes hochladen (Schritt 1 von 2 bei Google)
$uploadUrl = 'https://photoslibrary.googleapis.com/v1/uploads';
$ch = curl_init($uploadUrl);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer $token",
"Content-type: application/octet-stream",
"X-Goog-Upload-Protocol: raw"
]);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $imageData);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$uploadToken = curl_exec($ch);
curl_close($ch);
if (!$uploadToken) return ['error' => 'Upload der Bilddaten fehlgeschlagen'];
// 3. Das MediaItem erstellen (Schritt 2 von 2)
$createUrl = 'https://photoslibrary.googleapis.com/v1/mediaItems:batchCreate';
$body = [
"albumId" => $albumId,
"newMediaItems" => [[
"description" => $description,
"simpleMediaItem" => ["uploadToken" => $uploadToken]
]]
];
$ch = curl_init($createUrl);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer $token", "Content-type: application/json"]);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = json_decode(curl_exec($ch), true);
curl_close($ch);
return $result;
}
// Hilfsfunktion: Album Management
private function getOrCreateAlbum($token) {
// Haben wir die ID schon gespeichert?
if (file_exists($this->albumFile)) {
return trim(file_get_contents($this->albumFile));
}
// Nein, wir erstellen es neu.
// (Hinweis: Wir könnten erst suchen, aber Erstellen ist sicherer/einfacher)
$url = 'https://photoslibrary.googleapis.com/v1/albums';
$body = json_encode(["album" => ["title" => "Dino Wissenskarten"]]);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer $token", "Content-type: application/json"]);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);
if (isset($response['id'])) {
file_put_contents($this->albumFile, $response['id']);
return $response['id'];
}
return null;
}
// --- UTIL ---
private function makeRequest($url, $fields) {
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($fields));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$res = curl_exec($ch); curl_close($ch);
return json_decode($res, true);
}
private function saveTokens($data) { $data['created'] = time(); file_put_contents($this->tokenFile, json_encode($data)); }
private function loadTokens() { return file_exists($this->tokenFile) ? json_decode(file_get_contents($this->tokenFile), true) : null; }
public function isAuthenticated() { return file_exists($this->tokenFile); }
}
?>

1179
dinos/index.php Normal file

File diff suppressed because it is too large Load diff

94
dinos/prompts.js Normal file
View file

@ -0,0 +1,94 @@
/*
DINO LERNTAFEL - PROMPT KONFIGURATION (FINAL)
*/
const DinoPrompts = {
// 1. PROMPT FÜR DIE FAKTEN (Gemini Flash)
// Ziel: Sauberes JSON ohne Markdown, Zahlen als echte Numbers
getFacts: (term) => `
Du bist ein Experte für Paläontologie. Erstelle ein Daten-Profil für einen "${term}".
WICHTIG ZUM FORMAT:
- Antworte NUR mit dem rohen JSON-String.
- KEINE Markdown-Formatierung (kein \`\`\`json).
- Alle Text-Werte auf DEUTSCH.
JSON STRUKTUR:
{
"name": "Name des Dinos (z.B. Tyrannosaurus Rex)",
"is_fictional": true, // true bei Fantasie, false bei echten Dinos
// Brücke zum Bild-Generator:
"visual_details": "Beschreibe visuell: Körperbau, Hautstruktur (Schuppen/Federn/Panzer), markante Merkmale (Hörner, Kämme) und Farbe. Sei präzise.",
"fun_fact": "Max. 10 Wörter. Ein spannendes Detail für Kinder.",
"translation": "Namensbedeutung (z.B. 'König der Tyrannenechsen')",
"period": "Zeitraum Name (z.B. Oberkreide)",
"years": "Zeitraum Zahlen (z.B. vor 6866 Mio. Jahren)",
"mya_start": 68, // Zahl (Millionen Jahre)
"mya_end": 66, // Zahl (Millionen Jahre)
"height": 5.5, // Höhe in Metern (nur die Zahl als Number/Float)
"length": 12.0, // Länge in Metern (nur die Zahl als Number/Float)
"weight": 8000, // Gewicht in KG (nur die Zahl als Integer)
"diet": "Fleisch" // Genau einer dieser Werte: 'Pflanzen', 'Fleisch', 'Alles', 'Fisch'
}
`,
// 2. PROMPT FÜR DAS BILD (Generalisiert für alle Habitate)
getImage: (data) => `
[KONZEPT]: Ein "Environmental Wide Shot" (Weitwinklige Landschaftsaufnahme).
[DAS MOTIV]: Eine weite prähistorische Landschaft (${data.period}). In der MITTLEREN DISTANZ befindet sich ein einzelner ${data.name}.
[VISUELLE DETAILS]:
${data.visual_details}
[KOMPOSITION & LAYOUT - DIE "ANTI-CROP" REGELN]:
- DISTANZ: Die Kamera ist weit entfernt. Der ${data.name} darf maximal 70% der gesamten Bildhöhe einnehmen.
- VORDERGRUND: Es muss signifikant viel Umgebung UNTER dem Tier sichtbar sein.
- Wir müssen klar sehen, worauf oder worin das Tier steht (z.B. Waldboden, Wasser, Sand, Fels). Die Füße/Basis dürfen den unteren Bildrand NICHT berühren.
- HINTERGRUND OBEN: Es muss signifikant viel Umgebung ÜBER dem Kopf sichtbar sein (Negativraum).
- Egal ob Himmel, Baumkronen oder Felswand: Dieser Bereich muss visuell "ruhig" und hell genug sein, damit dunkler Text darauf lesbar ist.
[FEHLER-VERMEIDUNG]:
- KEIN enges Portrait. Das Tier muss "atmen" können.
- Wenn das Tier oben oder unten anstößt, ist die Kamera zu nah -> Gehe weiter weg.
[ATMOSPHÄRE]:
- Habitat: Passend zur Spezies (z.B. dichter Dschungel, Sumpf, Küste oder offene Ebene).
- Licht: Helles, diffuses Tageslicht für gute Sichtbarkeit.
[REALISMUS]:
- ${data.is_fictional ? "Glaubwürdige Biologie." : "Wissenschaftlich akkurat."}
- 8k Auflösung, fotorealistisch.
[TECHNISCHES]:
- Seitenverhältnis: 16:10
- Objektiv: Weitwinkel (ca. 24mm).
`,
// 3. PROMPT FÜR DEN ZUFALLSGENERATOR (Gemini Flash)
getRandom: () => `
Nenne mir ein zufälliges, spannendes prähistorisches Tier aus dem Zeitalter der Dinosaurier (Mesozoikum) oder davor.
WICHTIGE REGELN:
1. Die Zeitgrenze ist das Massenaussterben vor 66 Mio. Jahren. (Keine Mammuts etc.)
2. SPRACHE: Die 'tagline' MUSS zwingend auf DEUTSCH sein.
3. VIELFALT: Sei kreativ! Nenne nicht nur die bekanntesten Tiere. Grabe tief in der Datenbank der Paläontologie. Bringe gerne auch unbekanntere, bizarre oder bunte Arten. Wähle jedes Mal etwas ganz anderes.
Erlaubte Gruppen:
- Dinosaurier (Theropoden, Sauropoden, Ornithischia...)
- Flugsaurier (Pterosaurier - aber nicht immer Quetzalcoatlus!)
- Meeresechsen (Mosasaurier, Plesiosaurier, Ichthyosaurier)
- Urzeitliche Krokodile, Amphibien oder frühe Synapsiden (z.B. Dimetrodon).
Antworte als reines JSON:
{
"name": "Name des Tiers (Einfacher Name, z.B. Deinocheirus statt Deinocheirus mirificus )",
"tagline": "Eine sehr kurze, knackige Beschreibung auf Deutsch (max 6-8 Wörter)"
}
`
};

1049
dinos/test.html Normal file

File diff suppressed because it is too large Load diff

56
dinos/upload_handler.php Normal file
View file

@ -0,0 +1,56 @@
<?php
/*
* 2026-01-08 16:50:00
* UPLOAD HANDLER
* Nimmt AJAX Request entgegen und sendet an Google Photos.
*/
$current_project = 'dinos';
require_once __DIR__ . '/../auth.php';
require_once 'google_helper.php';
header('Content-Type: application/json');
// 1. Setup
$secretFile = 'client_secret.json';
// Hier wieder DEINE URL eintragen (nur zur Sicherheit, wird hier intern kaum genutzt)
$redirectUri = 'https://philippurbschat.de/dinos/auth.php';
$google = new GoogleHelper($secretFile, $redirectUri);
if (!$google->isAuthenticated()) {
echo json_encode(['success' => false, 'error' => 'Server nicht mit Google verbunden.']);
exit;
}
// 2. Daten empfangen
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['image']) || !isset($input['name'])) {
echo json_encode(['success' => false, 'error' => 'Keine Bilddaten empfangen.']);
exit;
}
// 3. Base64 bereinigen (Das "data:image/png;base64," entfernen)
$imgParts = explode(',', $input['image']);
$base64Data = end($imgParts);
$binaryImage = base64_decode($base64Data);
if (!$binaryImage) {
echo json_encode(['success' => false, 'error' => 'Bild konnte nicht verarbeitet werden.']);
exit;
}
// 4. Beschreibung bauen
$desc = "🦖 " . $input['name'] . "\n" .
"💡 " . ($input['fun_fact'] ?? '') . "\n" .
"📅 " . ($input['years'] ?? '');
// 5. Hochladen
$result = $google->uploadImage($binaryImage, $desc);
if (isset($result['newMediaItemsResult'])) { // Google Success Structure
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => 'Google Fehler', 'details' => $result]);
}
?>

13
home/.env.example Normal file
View file

@ -0,0 +1,13 @@
# App Einstellungen
APP_NAME=philippurbschat.de
APP_ENV=production
BASE_URL=https://philippurbschat.de/
# Datenbank (Optional - leer lassen, wenn keine DB benötigt wird)
DB_HOST=localhost
DB_NAME=your_db_name
DB_USER=your_db_user
DB_PASS=your_db_password
# APIs & Externe Dienste
GEMINI_API_KEY=your_gemini_api_key_here

View file

@ -0,0 +1,89 @@
<?php
require_once __DIR__ . '/../../core/Controller.php';
require_once __DIR__ . '/../services/HealthService.php';
class AdminController extends Controller {
// Auth-Check
public function __construct() {
if (session_status() === PHP_SESSION_NONE) { session_start(); }
if (!isset($_SESSION['is_admin']) || $_SESSION['is_admin'] !== true) {
header('Location: ' . Config::get('BASE_URL', '/'));
exit;
}
}
// Dashboard
public function index() {
$report = HealthService::getHealthReport();
$this->view('admin/dashboard', [
'title' => 'Admin Dashboard',
'health' => $report
]);
}
// Settings
public function settings() {
$this->view('admin/settings', [
'title' => 'Einstellungen'
]);
}
// User List
public function users() {
$userModel = $this->model('User');
$users = $userModel->getAll();
$this->view('admin/users', [
'title' => 'Benutzerverwaltung',
'users' => $users
]);
}
// User Form
public function user_form($id = null) {
$user = null;
if ($id) {
$userModel = $this->model('User');
$user = $userModel->getById($id);
if (!$user) {
Flash::set('Nutzer nicht gefunden.', 'error');
header('Location: ' . Config::get('BASE_URL') . 'admin/users');
exit;
}
}
$this->view('admin/user_form', [
'title' => $id ? 'Benutzer bearbeiten' : 'Neuen Benutzer anlegen',
'user' => $user
]);
}
// Save User
public function user_save() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ' . Config::get('BASE_URL') . 'admin/users');
exit;
}
try {
Security::checkCsrf($_POST['csrf_token']);
$id = !empty($_POST['id']) ? $_POST['id'] : null;
$userModel = $this->model('User');
if (empty($_POST['name']) || empty($_POST['email'])) {
throw new Exception("Name und E-Mail sind Pflichtfelder.");
}
$userModel->save($_POST, $id);
Flash::set('Nutzer erfolgreich gespeichert!', 'success');
} catch (Exception $e) {
Flash::set($e->getMessage(), 'error');
}
header('Location: ' . Config::get('BASE_URL') . 'admin/users');
exit;
}
// Delete User
public function user_delete($id) {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
Security::checkCsrf($_POST['csrf_token']);
$userModel = $this->model('User');
$userModel->delete($id);
Flash::set('Nutzer wurde gelöscht.', 'success');
} catch (Exception $e) {
Flash::set('Fehler beim Löschen: ' . $e->getMessage(), 'error');
}
}
header('Location: ' . Config::get('BASE_URL') . 'admin/users');
exit;
}
}

View file

@ -0,0 +1,28 @@
<?php
require_once __DIR__ . '/../../core/Controller.php';
class HomeController extends Controller {
// Main Route Handler
public function index($name = 'home') {
$routeName = str_replace(['.xml', '.txt'], '', $name);
$allowedRoutes = ['about', 'imprint', 'privacy'];
if (in_array($routeName, $allowedRoutes)) {
return $this->{$routeName}();
}
if ($routeName !== 'home' && $routeName !== '') {
throw new Exception("Route not found.");
}
$this->view('home', [
'name' => $name
]);
}
// Static Views
public function about() {
$this->view('about');
}
public function imprint() {
$this->view('imprint');
}
public function privacy() {
$this->view('privacy');
}
}

View file

@ -0,0 +1,43 @@
<?php
require_once __DIR__ . '/../../core/Controller.php';
class LoginController extends Controller {
// Login Handler
public function index() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ' . Config::get('BASE_URL', '/'));
exit;
}
$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';
// Validation
if (empty($email) || empty($password)) {
Flash::set('Email and password are required.');
header('Location: ' . Config::get('BASE_URL', '/'));
exit;
}
try {
Security::checkCsrf($_POST['csrf_token'] ?? '');
$userModel = $this->model('User');
$user = $userModel->authenticate($email, $password);
if ($user) {
$_SESSION['user_email'] = $email;
$_SESSION['user_projects'] = $user['projects'] ?? [];
$_SESSION['is_admin'] = $user['is_admin'] ?? false;
header('Location: ' . Config::get('BASE_URL', '/'));
} else {
Flash::set('Access denied: Invalid credentials.');
header('Location: ' . Config::get('BASE_URL', '/'));
}
} catch (Exception $e) {
Flash::set('System error: ' . $e->getMessage());
header('Location: ' . Config::get('BASE_URL', '/'));
}
exit;
}
// Logout Handler
public function logout() {
session_destroy();
header('Location: ' . Config::get('BASE_URL', '/'));
exit;
}
}

View file

@ -0,0 +1,40 @@
<?php
require_once __DIR__ . '/../../core/Controller.php';
class SeoController extends Controller {
// Sitemap Generation
public function sitemap() {
$baseUrl = Config::get('BASE_URL', 'https://philippurbschat.de/');
$pages = ['', 'about', 'imprint', 'privacy'];
$lastMod = date('Y-m-d');
$xml = '<?xml version="1.0" encoding="UTF-8"?>';
$xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">';
foreach ($pages as $page) {
$priority = ($page === '') ? '1.0' : '0.8';
$xml .= '<url>';
$xml .= '<loc>' . $baseUrl . $page . '</loc>';
$xml .= '<lastmod>' . $lastMod . '</lastmod>';
$xml .= '<priority>' . $priority . '</priority>';
$xml .= '</url>';
}
$xml .= '</urlset>';
return $this->xml($xml);
}
// Robots.txt Generation
public function robots() {
$baseUrl = Config::get('BASE_URL', 'https://philippurbschat.de/');
$txt = "User-agent: *\n";
$txt .= "Allow: /\n\n";
$txt .= "Disallow: /admin\n";
$txt .= "Disallow: /admin/\n";
$txt .= "Disallow: /admin/users\n";
$txt .= "Disallow: /admin/settings\n";
$txt .= "Disallow: /login\n";
$txt .= "Disallow: /login/\n";
$txt .= "Disallow: /core/\n";
$txt .= "Disallow: /app/\n";
$txt .= "Disallow: /views/\n";
$txt .= "Disallow: /src/\n\n";
$txt .= "Sitemap: " . $baseUrl . "sitemap.xml\n";
return $this->text($txt);
}
}

32
home/app/init.php Normal file
View file

@ -0,0 +1,32 @@
<?php
// Session
if (session_status() === PHP_SESSION_NONE) {
session_set_cookie_params([
'path' => '/',
'httponly' => true,
'samesite' => 'Lax'
]);
session_start();
}
date_default_timezone_set('Europe/Berlin');
// Autoloader
spl_autoload_register(function($className) {
$paths = [
__DIR__ . '/../core/',
__DIR__ . '/models/',
__DIR__ . '/services/'
];
foreach ($paths as $path) {
$file = $path . $className . '.php';
$fileLower = $path . strtolower($className) . '.php';
if (file_exists($file)) {
require_once $file;
return;
} elseif (file_exists($fileLower)) {
require_once $fileLower;
return;
}
}
});
// Environment
Config::load(__DIR__ . '/../.env');

67
home/app/models/User.php Normal file
View file

@ -0,0 +1,67 @@
<?php
class User {
private $db;
public function __construct() {
$this->db = new Database();
}
// Auth-Logic
public function authenticate($email, $password) {
$this->db->query("SELECT * FROM home_users WHERE email = :email AND status = 'Active'");
$this->db->bind(':email', $email);
$user = $this->db->single();
if ($user && password_verify($password, $user['password'])) {
$user['projects'] = json_decode($user['projects'], true) ?? [];
$user['is_admin'] = (bool)$user['is_admin'];
return $user;
}
return false;
}
// Data-Retrieval
public function getAll() {
$this->db->query("SELECT id, name, email, is_admin, status, projects FROM home_users ORDER BY id DESC");
$users = $this->db->resultSet();
foreach ($users as &$user) {
$user['projects'] = json_decode($user['projects'], true) ?? [];
}
return $users;
}
public function getById($id) {
$this->db->query("SELECT * FROM home_users WHERE id = :id");
$this->db->bind(':id', $id);
$user = $this->db->single();
if ($user) {
$user['projects'] = json_decode($user['projects'], true) ?? [];
}
return $user;
}
// Persistence
public function save($data, $id = null) {
$projectsJson = json_encode(array_map('trim', explode(',', $data['projects'])));
if ($id) {
$sql = "UPDATE home_users SET name = :name, email = :email, projects = :projects, is_admin = :is_admin, status = :status ";
if (!empty($data['password'])) {
$sql .= ", password = :password ";
}
$sql .= "WHERE id = :id";
$this->db->query($sql);
$this->db->bind(':id', $id);
} else {
$sql = "INSERT INTO home_users (name, email, password, projects, is_admin, status) VALUES (:name, :email, :password, :projects, :is_admin, :status)";
$this->db->query($sql);
}
$this->db->bind(':name', $data['name']);
$this->db->bind(':email', $data['email']);
$this->db->bind(':projects', $projectsJson);
$this->db->bind(':is_admin', isset($data['is_admin']) ? 1 : 0);
$this->db->bind(':status', $data['status']);
if (!$id || !empty($data['password'])) {
$this->db->bind(':password', password_hash($data['password'], PASSWORD_DEFAULT));
}
return $this->db->execute();
}
public function delete($id) {
$this->db->query("DELETE FROM home_users WHERE id = :id");
$this->db->bind(':id', $id);
return $this->db->execute();
}
}

View file

@ -0,0 +1,72 @@
<?php
class HealthService {
// Main Report
public static function getHealthReport() {
return [
'php_version' => PHP_VERSION,
'disk_free' => self::getDiskStatus(),
'memory' => self::formatBytes(self::getMemoryLimit()),
'env_status' => self::checkEnvFile(),
'db_status' => self::checkDatabase(),
'writable' => self::checkWritableFolders(['public', 'app'])
];
}
// Memory Helpers
private static function getMemoryLimit() {
$limit = ini_get('memory_limit');
if (preg_match('/^(\d+)(.)$/', $limit, $matches)) {
$val = (int)$matches[1];
switch (strtoupper($matches[2])) {
case 'G': $val *= 1024 * 1024 * 1024; break;
case 'M': $val *= 1024 * 1024; break;
case 'K': $val *= 1024; break;
}
return $val;
}
return (int)$limit;
}
private static function formatBytes($bytes, $precision = 2) {
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
return round($bytes / pow(1024, $pow), $precision) . ' ' . $units[$pow];
}
// System Status
private static function getDiskStatus() {
if (function_exists('disk_free_space') && function_exists('disk_total_space')) {
try {
$free = @disk_free_space("/");
$total = @disk_total_space("/");
if ($free !== false && $total > 0) {
return round(($free / $total) * 100) . "% frei";
}
} catch (Exception $e) {}
}
return "Eingeschränkt (Hosting)";
}
private static function checkEnvFile() {
return file_exists(__DIR__ . '/../../.env') ? 'Bereit' : 'Fehlt!';
}
private static function checkDatabase() {
try {
if (empty(Config::get('DB_NAME'))) {
return 'Deaktiviert';
}
$db = new Database();
$db->query("SELECT VERSION() as v");
$res = $db->single();
return $res['v'] ?? 'Online';
} catch (Exception $e) {
return 'Offline / Fehler';
}
}
private static function checkWritableFolders($folders) {
foreach ($folders as $folder) {
$path = realpath(__DIR__ . '/../../' . $folder);
if (!$path || !is_dir($path)) return "Fehlt: $folder";
if (!is_writable($path)) return "Rechte: $folder";
}
return "OK";
}
}

19
home/core/Auth.php Normal file
View file

@ -0,0 +1,19 @@
<?php
class Auth {
// Access Control
public static function check($project) {
$baseUrl = Config::get('BASE_URL', '/');
if (!isset($_SESSION['user_email'])) {
header('Location: ' . $baseUrl . '?error=2');
exit();
}
if (!isset($_SESSION['user_projects']) || !in_array($project, $_SESSION['user_projects'])) {
header('Location: ' . $baseUrl);
exit();
}
}
// User Getter
public static function user() {
return $_SESSION['user_email'] ?? null;
}
}

20
home/core/Config.php Normal file
View file

@ -0,0 +1,20 @@
<?php
class Config {
public static function load($file = '.env') {
if (!file_exists($file)) return;
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
// Kommentare ignorieren
if (strpos(trim($line), '#') === 0) continue;
// Key und Value trennen
list($name, $value) = explode('=', $line, 2);
$_ENV[trim($name)] = trim($value);
}
}
public static function get($key, $default = null) {
return $_ENV[$key] ?? $default;
}
}

46
home/core/Controller.php Normal file
View file

@ -0,0 +1,46 @@
<?php
class Controller {
public function model($model) {
$modelPath = __DIR__ . '/../app/models/' . $model . '.php';
if (file_exists($modelPath)) {
require_once $modelPath;
return new $model();
}
throw new Exception("Model '{$model}' existiert nicht.");
}
public function view($view, $data = []) {
if (!empty($data)) {
extract($data);
}
$viewFile = __DIR__ . '/../views/' . $view . '.php';
if (file_exists($viewFile)) {
require_once $viewFile;
} else {
throw new Exception("View '{$view}' existiert nicht unter: {$viewFile}");
}
}
public function json($data, $status = 200) {
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
public function xml($data) {
header('Content-Type: application/xml; charset=utf-8');
echo $data;
exit;
}
public function text($data) {
header('Content-Type: text/plain; charset=utf-8');
echo $data;
exit;
}
}

68
home/core/Database.php Normal file
View file

@ -0,0 +1,68 @@
<?php
class Database {
private $dbh;
private $stmt;
private $error;
public function __construct() {
$host = Config::get('DB_HOST', 'localhost');
$user = Config::get('DB_USER', 'root');
$pass = Config::get('DB_PASS', '');
$dbname = Config::get('DB_NAME', '');
// Wenn gar keine DB konfiguriert ist, direkt Exception werfen
if (empty($dbname)) {
throw new Exception("Keine Datenbank in .env konfiguriert.");
}
$dsn = 'mysql:host=' . $host . ';dbname=' . $dbname . ';charset=utf8mb4';
$options = [
PDO::ATTR_PERSISTENT => true,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
];
try {
$this->dbh = new PDO($dsn, $user, $pass, $options);
} catch (PDOException $e) {
$this->error = $e->getMessage();
// Exception werfen statt sterben!
throw new Exception("Datenbank-Verbindungsfehler.");
}
}
public function query($sql) {
$this->stmt = $this->dbh->prepare($sql);
}
public function bind($param, $value, $type = null) {
if (is_null($type)) {
switch (true) {
case is_int($value): $type = PDO::PARAM_INT; break;
case is_bool($value): $type = PDO::PARAM_BOOL; break;
case is_null($value): $type = PDO::PARAM_NULL; break;
default: $type = PDO::PARAM_STR;
}
}
$this->stmt->bindValue($param, $value, $type);
}
public function execute() {
return $this->stmt->execute();
}
public function resultSet() {
$this->execute();
return $this->stmt->fetchAll();
}
public function single() {
$this->execute();
return $this->stmt->fetch();
}
public function rowCount() {
return $this->stmt->rowCount();
}
}

42
home/core/Flash.php Normal file
View file

@ -0,0 +1,42 @@
<?php
class Flash {
public static function set($message, $type = 'error') {
$_SESSION['flash_messages'][] = [
'message' => $message,
'type' => $type
];
}
public static function display() {
if (isset($_SESSION['flash_messages'])) {
foreach ($_SESSION['flash_messages'] as $flash) {
// Style-Mapping
$styles = [
'error' => ['class' => 'border-rose-500/50 text-rose-400 shadow-[0_0_20px_rgba(244,63,94,0.25)]', 'prefix' => '[ ERR ]'],
'success' => ['class' => 'border-emerald-500/50 text-emerald-400 shadow-[0_0_20px_rgba(16,185,129,0.25)]', 'prefix' => '[ OK ]'],
'warning' => ['class' => 'border-amber-500/50 text-amber-400 shadow-[0_0_20px_rgba(245,158,11,0.25)]', 'prefix' => '[ WARN ]'],
'info' => ['class' => 'border-cyan-500/50 text-cyan-400 shadow-[0_0_20px_rgba(6,182,212,0.25)]', 'prefix' => '[ INFO ]']
];
$currentStyle = $styles[$flash['type']] ?? $styles['error'];
$cssClasses = $currentStyle['class'];
$prefix = $currentStyle['prefix'];
// Output HUD Element
echo "<div class='flash-message fixed top-6 right-6 bg-slate-950/90 border {$cssClasses} px-6 py-4 rounded-xl backdrop-blur-md z-9999 font-mono text-sm uppercase tracking-wider flex items-center gap-4'>
<span class='font-bold animate-pulse'>{$prefix}</span>
<span class='text-slate-300'>" . htmlspecialchars($flash['message']) . "</span>
</div>";
}
unset($_SESSION['flash_messages']);
// Auto-Hide Script
echo "<script>
setTimeout(() => {
document.querySelectorAll('.flash-message').forEach(alert => {
alert.style.transition = 'opacity 0.5s ease, transform 0.5s ease';
alert.style.opacity = '0';
alert.style.transform = 'translateY(-10px)';
setTimeout(() => alert.remove(), 500);
});
}, 4000);
</script>";
}
}
}

35
home/core/Router.php Normal file
View file

@ -0,0 +1,35 @@
<?php
class Router {
protected $controller = 'HomeController';
protected $method = 'index';
protected $params = [];
// Main Routing Logic
public function route() {
$url = $this->parseUrl();
$controllerPath = __DIR__ . '/../app/controllers/';
// Controller Check
if (isset($url[0]) && file_exists($controllerPath . ucfirst($url[0]) . 'Controller.php')) {
$this->controller = ucfirst($url[0]) . 'Controller';
unset($url[0]);
}
require_once $controllerPath . $this->controller . '.php';
$this->controller = new $this->controller;
// Method Check
if (isset($url[1])) {
if (method_exists($this->controller, $url[1])) {
$this->method = $url[1];
unset($url[1]);
}
}
// Params & Execution
$this->params = $url ? array_values($url) : [];
call_user_func_array([$this->controller, $this->method], $this->params);
}
// URL Parser
private function parseUrl() {
if (isset($_GET['url'])) {
return explode('/', filter_var(rtrim($_GET['url'], '/'), FILTER_SANITIZE_URL));
}
return [];
}
}

17
home/core/Security.php Normal file
View file

@ -0,0 +1,17 @@
<?php
class Security {
// CSRF Generation
public static function csrf() {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
// CSRF Validation
public static function checkCsrf($token) {
if (!isset($_SESSION['csrf_token']) || $token !== $_SESSION['csrf_token']) {
throw new Exception("Sicherheits-Token ungültig. Bitte lade die Seite neu.");
}
return true;
}
}

1055
home/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

17
home/package.json Normal file
View file

@ -0,0 +1,17 @@
{
"name": "home",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"dev": "npx @tailwindcss/cli -i ./src/input.css -o ./public/css/app.css --watch",
"build": "npx @tailwindcss/cli -i ./src/input.css -o ./public/css/app.css --minify"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"@tailwindcss/cli": "^4.2.1",
"tailwindcss": "^4.2.1"
}
}

9
home/public/.htaccess Normal file
View file

@ -0,0 +1,9 @@
RewriteEngine On
# Falls die angefragte Datei oder das Verzeichnis wirklich existiert, nimm sie direkt
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
# Ansonsten schicke alles an die index.php
# Der Router im HomeController kümmert sich um den Rest
RewriteRule ^(.*)$ index.php?url=$1 [L,QSA]

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

2
home/public/css/app.css Normal file

File diff suppressed because one or more lines are too long

125
home/public/css/style.css Normal file
View file

@ -0,0 +1,125 @@
@import "app.css";
:root {
--bg-base: #020617;
}
body {
background-color: var(--bg-base);
overflow-x: hidden;
font-family: 'Outfit', sans-serif;
}
.font-mono {
font-family: 'JetBrains Mono', monospace !important;
}
/* CRT Overlay */
.crt-overlay {
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.1) 50%),
linear-gradient(90deg, rgba(255, 0, 0, 0.02), rgba(0, 255, 0, 0.01), rgba(0, 0, 255, 0.02));
background-size: 100% 3px, 3px 100%;
pointer-events: none;
z-index: 50;
}
/* Noise Texture */
.noise {
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
opacity: 0.025;
pointer-events: none;
z-index: 40;
}
/* Background Glow */
.bg-glow {
position: fixed;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 120vw; height: 120vh;
background: radial-gradient(circle at center, rgba(99, 102, 241, 0.08) 0%, rgba(0, 0, 0, 0) 70%);
pointer-events: none;
z-index: -1;
}
.selection-pink::selection {
background: #f43f5e;
color: white;
}
/* Animations */
@keyframes blob-bounce {
0%, 100% { transform: translate(0, 0) scale(1); }
33% { transform: translate(30px, -50px) scale(1.1); }
66% { transform: translate(-20px, 20px) scale(0.9); }
}
@keyframes particle-drift {
0% { transform: translateY(0) rotate(0deg); opacity: 0; }
50% { opacity: 0.5; }
100% { transform: translateY(-100vh) rotate(360deg); opacity: 0; }
}
@keyframes invader-patrol {
0%, 100% { transform: translateX(-12px); }
50% { transform: translateX(12px); }
}
.animate-blob {
animation: blob-bounce 25s infinite alternate ease-in-out;
filter: blur(80px);
}
.animate-invader {
animation: invader-patrol 4s infinite ease-in-out;
}
.particle {
position: fixed;
background: white;
border-radius: 50%;
pointer-events: none;
z-index: 1;
animation: particle-drift var(--p-duration) infinite linear;
animation-delay: var(--p-delay);
}
/* UI Components */
.bullshit-terminal {
background: rgba(15, 23, 42, 0.6);
border: 1px dashed rgba(100, 116, 139, 0.3);
border-left: 4px solid #f0abfc;
position: relative;
}
.bullshit-terminal::before {
content: "CORPORATE_BULLSHIT_V1.0";
position: absolute;
top: -10px;
left: 20px;
background: #0f172a;
padding: 0 10px;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: #64748b;
letter-spacing: 0.1em;
}
.btn-slim {
display: inline-flex;
align-items: center;
border: 1px solid rgba(240, 171, 252, 0.3);
color: #f0abfc;
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 0.75rem 1.5rem;
border-radius: 12px;
transition: all 0.3s ease;
}
.btn-slim:hover {
background: rgba(240, 171, 252, 0.1);
border-color: #f0abfc;
transform: translateY(-2px);
box-shadow: 0 0 20px rgba(240, 171, 252, 0.1);
}
.sr-only {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border-width: 0 !important;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 920 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
home/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

23
home/public/index.php Normal file
View file

@ -0,0 +1,23 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
if (file_exists(__DIR__ . '/../app/init.php')) {
require_once __DIR__ . '/../app/init.php';
} else {
die("Kritischer Fehler: app/init.php wurde nicht gefunden.");
}
try {
// Router
$router = new Router();
$router->route();
} catch (Exception $e) {
// Error Handling
http_response_code(404);
$errorMessage = Config::get('APP_ENV') === 'development' ? $e->getMessage() : 'Page not found or access denied.';
$errorView = __DIR__ . '/../views/error.php';
if(file_exists($errorView)) {
require_once $errorView;
} else {
echo "<h1>System Error</h1><p>" . htmlspecialchars($errorMessage) . "</p>";
}
}

53
home/public/js/main.js Normal file
View file

@ -0,0 +1,53 @@
let clickCount = 0;
let resetTimer = null;
let isNaughtyMode = false;
function triggerGimmick(e) {
const icon = document.getElementById('invader-icon');
clearTimeout(resetTimer);
// Counter & Mode
clickCount++;
if (clickCount >= 10 && !isNaughtyMode) {
isNaughtyMode = true;
icon.innerText = '🍆';
}
// Icon Animation
icon.style.transition = 'transform 0.5s ease-out';
icon.style.transform = `rotate(${Math.random() > 0.5 ? 360 : -360}deg) scale(1.4)`;
setTimeout(() => {
icon.style.transition = 'transform 0.3s ease-in';
icon.style.transform = 'rotate(0deg) scale(1)';
}, 500);
// Particle Explosion
for(let i = 0; i < 12; i++) {
let bit = document.createElement('div');
if (isNaughtyMode) {
bit.innerText = Math.random() > 0.5 ? '' : '💕';
bit.className = 'fixed pointer-events-none text-2xl z-50 drop-shadow-[0_0_10px_rgba(236,72,153,0.8)]';
} else {
bit.innerText = Math.random() > 0.5 ? '1' : '0';
bit.className = 'fixed font-mono text-emerald-500 pointer-events-none text-xl z-50 font-bold drop-shadow-[0_0_8px_rgba(16,185,129,0.8)]';
}
bit.style.left = e.clientX + 'px';
bit.style.top = e.clientY + 'px';
bit.style.transition = 'all 0.8s cubic-bezier(0.1, 0.8, 0.2, 1)';
document.body.appendChild(bit);
setTimeout(() => {
const angle = Math.random() * Math.PI * 2;
const dist = 60 + Math.random() * 100;
bit.style.transform = `translate(${Math.cos(angle)*dist}px, ${Math.sin(angle)*dist}px) scale(${Math.random() + 0.5})`;
bit.style.opacity = '0';
}, 10);
setTimeout(() => bit.remove(), 800);
}
// Auto-Reset
resetTimer = setTimeout(() => {
clickCount = 0;
if (isNaughtyMode) {
isNaughtyMode = false;
icon.innerText = '👾';
icon.style.transition = 'transform 0.3s ease';
icon.style.transform = 'scale(1.2)';
setTimeout(() => icon.style.transform = 'scale(1)', 300);
}
}, 5000);
}

19
home/src/input.css Normal file
View file

@ -0,0 +1,19 @@
/* DATEI: ./src/input.css */
@import "tailwindcss";
/* --- FONTS (Pfade korrigiert) --- */
@font-face {
font-family: 'Outfit';
font-style: normal;
font-weight: 300 700;
font-display: swap;
src: url('../fonts/outfit-v15-latin-regular.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('../fonts/jetbrains-mono-v24-latin-regular.woff2') format('woff2');
}

43
home/views/about.php Normal file
View file

@ -0,0 +1,43 @@
<?php require_once __DIR__ . '/inc/header.php'; ?>
<div class="max-w-4xl w-full mx-auto mt-4 md:mt-12 px-4 sm:px-6 relative z-10 mb-20">
<div class="mb-6 sm:mb-8 flex items-center justify-between">
<h1 class="text-2xl sm:text-3xl font-bold text-slate-100 font-mono tracking-tight">
<span class="text-fuchsia-500 animate-pulse">_</span>Über mich
</h1>
<a href="<?= Config::get('BASE_URL', '/') ?>" class="text-slate-500 hover:text-fuchsia-400 font-mono text-xs sm:text-sm transition-colors">
< Back to Root
</a>
</div>
<div class="p-5 sm:p-8 md:p-12 bg-slate-900/80 border border-fuchsia-500/20 rounded-3xl sm:rounded-[2.5rem] backdrop-blur-xl shadow-[0_0_30px_rgba(217,70,239,0.15)] relative overflow-hidden">
<div class="absolute top-0 left-0 w-full h-1 bg-linear-to-r from-transparent via-fuchsia-500/40 to-transparent"></div>
<div class="space-y-8 sm:space-y-12 text-slate-300 text-sm md:text-base leading-relaxed relative z-10">
<section>
<div class="bullshit-terminal mb-6 sm:mb-8 p-5 sm:p-8">
<p class="text-base md:text-xl text-slate-400 italic leading-relaxed">
„Als ergebnisorientierter Game-Changer mit einem disruptiven Mindset fokussiere ich mich auf die Maximierung holistischer Synergien im agilen Deep-Dive, um Low-Hanging Fruits in skalierbare Next-Level-Blueprints zu transformieren.
</p>
</div>
<h3 class="text-base sm:text-lg text-fuchsia-400/80 mb-6 sm:mb-8 font-mono tracking-wide"> Merkst du selbst, oder?</h3>
<div class="space-y-6 sm:space-y-8 max-w-2xl">
<p>Wenn du jemanden suchst, der dich mit Bullshit-Bingo einlullt, bist du hier falsch.</p>
<ul class="space-y-4">
<li><strong class="text-cyan-400 font-semibold drop-shadow-[0_0_8px_rgba(34,211,238,0.3)]">Klares Design & durchdachtes Frontend</strong> statt „disruptiver Interface-Paradigmen“ oder „User-Experience-Visionen“.</li>
<li><strong class="text-emerald-400 font-semibold drop-shadow-[0_0_8px_rgba(52,211,153,0.3)]">solides Backend</strong>, das einfach läuft, statt „hyper-skalierbarer Microservice-Ökosysteme“.</li>
<li><strong class="text-amber-400 font-semibold drop-shadow-[0_0_8px_rgba(251,191,36,0.3)]">Echte Sichtbarkeit</strong> statt „Performance-Driven Reach Orchestration“.</li>
<li><strong class="text-indigo-400 font-semibold drop-shadow-[0_0_8px_rgba(129,140,248,0.3)]">Sinnvolle KI-Integrationen</strong> statt „Big-Data-Orchestrierung“ im luftleeren Raum.</li>
<li><strong class="text-transparent bg-clip-text bg-linear-to-r from-cyan-400 via-purple-400 to-fuchsia-400 font-bold">Starke Konzepte</strong>, die alles zusammenhalten, statt „holistischer Transformations-Roadmaps“.</li>
</ul>
<p>Ich baue <strong class="text-fuchsia-400 font-bold drop-shadow-[0_0_12px_rgba(232,121,249,0.6)]">Systeme, die funktionieren</strong>, und löse Probleme, anstatt sie hinter Fachbegriffen zu verstecken.</p>
</div>
</section>
<section class="pt-8 sm:pt-10 border-t border-slate-700/50">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 sm:gap-8">
<div><p class="text-slate-500">Willst du wissen, wie ich arbeite, wer ich bin oder welche Musik ich höre?</p></div>
<div><a href="mailto:hi@philippurbschat.de" class="btn-slim w-full justify-center sm:w-auto text-center">Frag einfach.<br>Ohne Bullshit.</a></div>
</div>
</section>
</div>
<div class="absolute -bottom-10 -right-10 w-32 h-32 bg-fuchsia-500/5 rounded-full blur-3xl pointer-events-none"></div>
</div>
</div>
<?php require_once __DIR__ . '/inc/footer.php'; ?>

View file

@ -0,0 +1,56 @@
<?php require_once __DIR__ . '/../inc/admin_header.php'; ?>
<div class="max-w-6xl w-full mx-auto flex flex-col md:flex-row gap-6 p-6">
<aside class="w-full md:w-64 bg-slate-900/80 backdrop-blur-sm border border-slate-700/50 rounded-xl shadow-lg p-4 h-fit">
<div class="flex items-center gap-2 mb-6 border-b border-slate-700/50 pb-3">
<span class="text-xl">⚙️</span>
<h2 class="text-emerald-400 font-mono font-bold text-lg tracking-tight">System_Admin</h2>
</div>
<nav class="flex flex-col gap-2 font-mono text-sm">
<a href="<?= Config::get('BASE_URL', '/') ?>admin" class="px-3 py-2 bg-emerald-500/10 text-emerald-400 rounded-md border border-emerald-500/20 transition-all">> Dashboard</a>
<a href="<?= Config::get('BASE_URL', '/') ?>admin/users" class="px-3 py-2 hover:bg-slate-800 text-slate-400 hover:text-slate-200 rounded-md transition-all">> Benutzer</a>
<a href="<?= Config::get('BASE_URL', '/') ?>admin/settings" class="px-3 py-2 hover:bg-slate-800 text-slate-400 hover:text-slate-200 rounded-md transition-all">> Einstellungen</a>
</nav>
</aside>
<main class="flex-1 bg-slate-900/80 backdrop-blur-sm border border-slate-700/50 rounded-xl shadow-2xl overflow-hidden flex flex-col">
<div class="bg-slate-800/80 px-4 py-3 border-b border-slate-700/50 flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-rose-500"></div>
<div class="w-3 h-3 rounded-full bg-amber-500"></div>
<div class="w-3 h-3 rounded-full bg-emerald-500"></div>
<div class="ml-2 font-mono text-xs text-slate-400">philcore-dashboard.sh</div>
<div class="ml-auto text-[10px] text-slate-500 font-mono italic">terminal_session_main</div>
</div>
<div class="p-6 sm:p-8 flex-1">
<header class="mb-8 border-b border-slate-800 pb-6">
<h1 class="text-3xl font-extrabold text-transparent bg-clip-text bg-linear-to-r from-emerald-400 to-cyan-400 tracking-tight mb-2">> Dashboard_</h1>
<p class="text-slate-400 font-mono text-sm">System-Status für <span class="text-emerald-400"><?= htmlspecialchars(Auth::user() ?? 'Phili') ?></span></p>
</header>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="p-5 bg-slate-950/80 rounded-lg border border-slate-800/80 shadow-inner">
<h3 class="text-slate-500 font-mono text-xs uppercase tracking-wider mb-2">> db_connection</h3>
<p class="text-emerald-400 font-mono text-sm flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
<?= htmlspecialchars($health['db_status'] ?? 'Error') ?>
</p>
</div>
<div class="p-5 bg-slate-950/80 rounded-lg border border-slate-800/80 shadow-inner">
<h3 class="text-slate-500 font-mono text-xs uppercase tracking-wider mb-2">> php_engine</h3>
<p class="text-cyan-400 font-mono text-sm">v<?= htmlspecialchars($health['php_version'] ?? 'N/A') ?> (<?= htmlspecialchars($health['memory'] ?? 'N/A') ?>)</p>
</div>
<div class="p-5 bg-slate-950/80 rounded-lg border border-slate-800/80 shadow-inner">
<h3 class="text-slate-500 font-mono text-xs uppercase tracking-wider mb-2">> disk_space</h3>
<p class="text-slate-300 font-mono text-sm"><?= htmlspecialchars($health['disk_free'] ?? 'Locked') ?></p>
</div>
<div class="p-5 bg-slate-950/80 rounded-lg border border-slate-800/80 shadow-inner">
<h3 class="text-slate-500 font-mono text-xs uppercase tracking-wider mb-2">> write_permissions</h3>
<p class="font-mono text-sm <?= ($health['writable'] ?? '') === 'OK' ? 'text-emerald-400' : 'text-rose-500' ?>"><?= htmlspecialchars($health['writable'] ?? 'Failed') ?></p>
</div>
</div>
<div class="mt-8 p-4 bg-black/40 rounded border border-slate-800 font-mono text-[10px] text-slate-500 leading-relaxed">
<p>> [<?= date('H:i:s') ?>] SYS_LOAD: OPTIMAL</p>
<p>> [<?= date('H:i:s') ?>] ENV_STATUS: <?= htmlspecialchars($health['env_status'] ?? 'Unknown') ?></p>
<p>> [<?= date('H:i:s') ?>] Alle Systeme bereit.</p>
</div>
</div>
</main>
</div>
<?php require_once __DIR__ . '/../inc/admin_footer.php'; ?>

View file

@ -0,0 +1,45 @@
<?php require_once __DIR__ . '/../inc/admin_header.php'; ?>
<div class="max-w-6xl w-full mx-auto flex flex-col md:flex-row gap-6 p-6">
<aside class="w-full md:w-64 bg-slate-900/80 backdrop-blur-sm border border-slate-700/50 rounded-xl shadow-lg p-4 h-fit">
<div class="flex items-center gap-2 mb-6 border-b border-slate-700/50 pb-3">
<span class="text-xl">⚙️</span>
<h2 class="text-emerald-400 font-mono font-bold text-lg tracking-tight">System_Admin</h2>
</div>
<nav class="flex flex-col gap-2 font-mono text-sm">
<a href="<?= Config::get('BASE_URL', '/') ?>admin" class="px-3 py-2 hover:bg-slate-800 text-slate-400 hover:text-slate-200 rounded-md transition-all">> Dashboard</a>
<a href="<?= Config::get('BASE_URL', '/') ?>admin/users" class="px-3 py-2 hover:bg-slate-800 text-slate-400 hover:text-slate-200 rounded-md transition-all">> Benutzer</a>
<a href="<?= Config::get('BASE_URL', '/') ?>admin/settings" class="px-3 py-2 bg-emerald-500/10 text-emerald-400 rounded-md border border-emerald-500/20 transition-all">> Einstellungen</a>
</nav>
</aside>
<main class="flex-1 bg-slate-900/80 backdrop-blur-sm border border-slate-700/50 rounded-xl shadow-2xl overflow-hidden flex flex-col">
<div class="bg-slate-800/80 px-4 py-3 border-b border-slate-700/50 flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-rose-500"></div>
<div class="w-3 h-3 rounded-full bg-amber-500"></div>
<div class="w-3 h-3 rounded-full bg-emerald-500"></div>
<div class="ml-2 font-mono text-xs text-slate-400">philcore-settings.sh</div>
</div>
<div class="p-6 sm:p-8 flex-1">
<header class="mb-8 border-b border-slate-800 pb-6">
<h1 class="text-3xl font-extrabold text-transparent bg-clip-text bg-linear-to-r from-emerald-400 to-cyan-400 tracking-tight mb-2">> <?= htmlspecialchars($title) ?>_</h1>
<p class="text-slate-400 font-mono text-sm">Systemkonfiguration anpassen.</p>
</header>
<div class="space-y-6">
<div class="p-5 bg-slate-950/80 rounded-lg border border-slate-800/80 shadow-inner">
<h3 class="text-slate-500 font-mono text-xs uppercase tracking-wider mb-4">> env_variables</h3>
<div class="space-y-4">
<div>
<label class="block text-slate-400 font-mono text-sm mb-1">APP_NAME</label>
<input type="text" value="<?= htmlspecialchars(Config::get('APP_NAME', 'PhilCore')) ?>" class="w-full bg-slate-900 border border-slate-700 rounded p-2 text-emerald-400 font-mono text-sm focus:outline-none" readonly>
</div>
<div>
<label class="block text-slate-400 font-mono text-sm mb-1">GEMINI_API_KEY</label>
<input type="text" value="<?= substr(Config::get('GEMINI_API_KEY', ''), 0, 5) ?>*******************" class="w-full bg-slate-900 border border-slate-700 rounded p-2 text-emerald-400 font-mono text-sm focus:outline-none" readonly>
<p class="text-xs text-slate-500 mt-2 font-mono tracking-tighter">// Hinweis: Änderungen direkt in der .env vornehmen.</p>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<?php require_once __DIR__ . '/../inc/admin_footer.php'; ?>

View file

@ -0,0 +1,50 @@
<?php require_once __DIR__ . '/../inc/admin_header.php'; ?>
<div class="max-w-4xl w-full mx-auto mt-8 px-6">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-slate-100 font-mono tracking-tight">
<span class="text-emerald-500">></span> <?= htmlspecialchars($title) ?>
</h1>
<a href="<?= Config::get('BASE_URL') ?>admin/users" class="text-slate-500 hover:text-emerald-400 font-mono text-sm transition-colors">< Zurück zur Liste</a>
</div>
<div class="bg-slate-900/80 border border-slate-700/50 rounded-xl shadow-2xl p-6 sm:p-8 backdrop-blur-sm relative overflow-hidden">
<div class="absolute top-0 left-0 w-full h-1 bg-linear-to-r from-transparent via-emerald-500/40 to-transparent"></div>
<form action="<?= Config::get('BASE_URL') ?>admin/user_save" method="POST" class="space-y-6 relative z-10">
<input type="hidden" name="csrf_token" value="<?= Security::csrf() ?>">
<?php if($user): ?><input type="hidden" name="id" value="<?= $user['id'] ?>"><?php endif; ?>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-slate-400 font-mono text-xs uppercase tracking-wider mb-2">Name</label>
<input type="text" name="name" value="<?= htmlspecialchars($user['name'] ?? '') ?>" required class="w-full bg-slate-950/80 border border-slate-700/80 rounded-lg p-3 text-slate-200 text-sm focus:outline-none focus:border-emerald-500/50 transition-colors">
</div>
<div>
<label class="block text-slate-400 font-mono text-xs uppercase tracking-wider mb-2">E-Mail (Login)</label>
<input type="email" name="email" value="<?= htmlspecialchars($user['email'] ?? '') ?>" required class="w-full bg-slate-950/80 border border-slate-700/80 rounded-lg p-3 text-slate-200 text-sm focus:outline-none focus:border-emerald-500/50 transition-colors">
</div>
<div>
<label class="block text-slate-400 font-mono text-xs uppercase tracking-wider mb-2">Passwort <?= $user ? '<span class="text-[10px] text-slate-600">(Leer lassen für keine Änderung)</span>' : '' ?></label>
<input type="password" name="password" <?= $user ? '' : 'required' ?> class="w-full bg-slate-950/80 border border-slate-700/80 rounded-lg p-3 text-slate-200 text-sm focus:outline-none focus:border-emerald-500/50 transition-colors">
</div>
<div>
<label class="block text-slate-400 font-mono text-xs uppercase tracking-wider mb-2">Status</label>
<select name="status" class="w-full bg-slate-950/80 border border-slate-700/80 rounded-lg p-3 text-slate-200 text-sm focus:outline-none focus:border-emerald-500/50 transition-colors">
<option value="Active" <?= ($user['status'] ?? '') === 'Active' ? 'selected' : '' ?>>Active</option>
<option value="Inactive" <?= ($user['status'] ?? '') === 'Inactive' ? 'selected' : '' ?>>Inactive</option>
</select>
</div>
</div>
<div>
<label class="block text-slate-400 font-mono text-xs uppercase tracking-wider mb-2">Projekte (Kommasepariert)</label>
<input type="text" name="projects" placeholder="z.B. plants, storymachine, dinos" value="<?= htmlspecialchars(implode(', ', $user['projects'] ?? [])) ?>" class="w-full bg-slate-950/80 border border-slate-700/80 rounded-lg p-3 text-slate-200 text-sm focus:outline-none focus:border-emerald-500/50 transition-colors">
<p class="text-[10px] text-slate-500 font-mono mt-1">Verzeichnisse im Root für Zugriffsberechtigung.</p>
</div>
<div class="flex items-center gap-3 bg-slate-950/50 p-4 rounded-lg border border-slate-800">
<input type="checkbox" name="is_admin" id="is_admin" value="1" <?= (!empty($user['is_admin'])) ? 'checked' : '' ?> class="w-4 h-4 accent-emerald-500 bg-slate-900 border-slate-700 rounded">
<label for="is_admin" class="text-slate-300 font-mono text-sm cursor-pointer">Benutzer ist Administrator (Vollzugriff)</label>
</div>
<div class="pt-4 border-t border-slate-800">
<button type="submit" class="bg-emerald-500/20 border border-emerald-500/50 text-emerald-400 hover:bg-emerald-500/40 hover:text-emerald-300 font-bold font-mono uppercase tracking-wider py-3 px-6 rounded-lg transition-all duration-300">💾 Speichern</button>
</div>
</form>
</div>
</div>
<?php require_once __DIR__ . '/../inc/admin_footer.php'; ?>

View file

@ -0,0 +1,70 @@
<?php require_once __DIR__ . '/../inc/admin_header.php'; ?>
<div class="max-w-6xl w-full mx-auto flex flex-col md:flex-row gap-4 sm:gap-6 p-4 sm:p-6">
<aside class="w-full md:w-64 bg-slate-900/80 backdrop-blur-sm border border-slate-700/50 rounded-xl shadow-lg p-4 h-fit shrink-0">
<div class="flex items-center gap-2 mb-6 border-b border-slate-700/50 pb-3">
<span class="text-xl">⚙️</span>
<h2 class="text-emerald-400 font-mono font-bold text-lg tracking-tight">System_Admin</h2>
</div>
<nav class="flex flex-col gap-2 font-mono text-sm">
<a href="<?= Config::get('BASE_URL', '/') ?>admin" class="px-3 py-2 hover:bg-slate-800 text-slate-400 hover:text-slate-200 rounded-md transition-all">> Dashboard</a>
<a href="<?= Config::get('BASE_URL', '/') ?>admin/users" class="px-3 py-2 bg-emerald-500/10 text-emerald-400 rounded-md border border-emerald-500/20 transition-all">> Benutzer</a>
<a href="<?= Config::get('BASE_URL', '/') ?>admin/settings" class="px-3 py-2 hover:bg-slate-800 text-slate-400 hover:text-slate-200 rounded-md transition-all">> Einstellungen</a>
</nav>
</aside>
<main class="flex-1 min-w-0 bg-slate-900/80 backdrop-blur-sm border border-slate-700/50 rounded-xl shadow-2xl overflow-hidden flex flex-col">
<div class="bg-slate-800/80 px-4 py-3 border-b border-slate-700/50 flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-rose-500 hidden sm:block"></div>
<div class="w-3 h-3 rounded-full bg-amber-500 hidden sm:block"></div>
<div class="w-3 h-3 rounded-full bg-emerald-500 hidden sm:block"></div>
<div class="sm:ml-2 font-mono text-xs text-slate-400">philcore-users.sh</div>
</div>
<a href="<?= Config::get('BASE_URL') ?>admin/user_form" class="text-xs font-mono bg-emerald-500/20 text-emerald-400 border border-emerald-500/50 px-3 py-1 rounded hover:bg-emerald-500/40 transition-colors whitespace-nowrap">+ NEW_USER</a>
</div>
<div class="p-4 sm:p-8 flex-1">
<header class="mb-6 sm:mb-8 border-b border-slate-800 pb-4 sm:pb-6">
<h1 class="text-2xl sm:text-3xl font-extrabold text-transparent bg-clip-text bg-linear-to-r from-emerald-400 to-cyan-400 tracking-tight mb-2">> <?= htmlspecialchars($title) ?>_</h1>
<p class="text-slate-400 font-mono text-xs sm:text-sm">Registrierte System-Accounts.</p>
</header>
<div class="overflow-x-auto border border-slate-800/80 rounded-lg -mx-4 sm:mx-0">
<table class="w-full text-left font-mono text-xs sm:text-sm text-slate-400">
<thead class="bg-slate-950/80 text-emerald-400 text-[10px] sm:text-xs uppercase">
<tr>
<th class="px-3 sm:px-4 py-3 border-b border-slate-800/80 whitespace-nowrap">ID</th>
<th class="px-3 sm:px-4 py-3 border-b border-slate-800/80 whitespace-nowrap md:whitespace-normal">Name</th>
<th class="px-3 sm:px-4 py-3 border-b border-slate-800/80 whitespace-nowrap md:whitespace-normal">Email</th>
<th class="px-3 sm:px-4 py-3 border-b border-slate-800/80 whitespace-nowrap md:whitespace-normal">Rolle</th>
<th class="px-3 sm:px-4 py-3 border-b border-slate-800/80 whitespace-nowrap md:whitespace-normal">Projekte</th>
<th class="px-3 sm:px-4 py-3 border-b border-slate-800/80 whitespace-nowrap">Status</th>
<th class="px-3 sm:px-4 py-3 border-b border-slate-800/80 text-right whitespace-nowrap">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800/50 bg-slate-900/50">
<?php if(empty($users)): ?>
<tr><td colspan="7" class="px-4 py-4 text-center">Keine Benutzer gefunden.</td></tr>
<?php else: foreach($users as $u): ?>
<tr class="hover:bg-slate-800/50 transition-colors">
<td class="px-3 sm:px-4 py-3 whitespace-nowrap">#<?= $u['id'] ?></td>
<td class="px-3 sm:px-4 py-3 text-slate-200 text-xs sm:text-sm whitespace-nowrap md:whitespace-normal"><?= htmlspecialchars($u['name']) ?></td>
<td class="px-3 sm:px-4 py-3 text-[10px] sm:text-xs text-slate-400 whitespace-nowrap md:whitespace-normal break-all"><?= htmlspecialchars($u['email']) ?></td>
<td class="px-3 sm:px-4 py-3 whitespace-nowrap"><?= $u['is_admin'] ? '<span class="text-fuchsia-400">Admin</span>' : 'User' ?></td>
<td class="px-3 sm:px-4 py-3 text-[10px] sm:text-xs text-slate-500 min-w-120px max-w-180px md:max-w-none truncate md:whitespace-normal md:wrap-break-words"><?php $proj = $u['projects'] ?? []; echo empty($proj) ? '-' : htmlspecialchars(implode(', ', $proj)); ?></td>
<td class="px-3 sm:px-4 py-3 whitespace-nowrap"><span class="<?= $u['status'] === 'Active' ? 'text-emerald-500' : 'text-rose-500' ?>"><?= htmlspecialchars($u['status']) ?></span></td>
<td class="px-3 sm:px-4 py-3 text-right whitespace-nowrap">
<div class="flex justify-end gap-2 sm:gap-3">
<a href="<?= Config::get('BASE_URL') ?>admin/user_form/<?= $u['id'] ?>" class="text-cyan-400 hover:text-cyan-300">Edit</a>
<form action="<?= Config::get('BASE_URL') ?>admin/user_delete/<?= $u['id'] ?>" method="POST" class="inline" onsubmit="return confirm('Wirklich löschen?');">
<input type="hidden" name="csrf_token" value="<?= Security::csrf() ?>">
<button type="submit" class="text-rose-400 hover:text-rose-300">Del</button>
</form>
</div>
</td>
</tr>
<?php endforeach; endif; ?>
</tbody>
</table>
</div>
</div>
</main>
</div>
<?php require_once __DIR__ . '/../inc/admin_footer.php'; ?>

27
home/views/error.php Normal file
View file

@ -0,0 +1,27 @@
<?php require_once __DIR__ . '/inc/header.php'; ?>
<div class="max-w-3xl w-full text-center space-y-6 sm:space-y-8 mt-12 md:mt-24 mx-auto px-4 sm:px-6 relative z-10">
<div class="text-6xl sm:text-8xl cursor-default inline-block drop-shadow-[0_0_15px_rgba(168,85,247,0.8)] animate-invader select-none mb-2 sm:mb-4">
👾
</div>
<h1 class="text-4xl sm:text-5xl md:text-7xl font-extrabold text-transparent bg-clip-text bg-linear-to-r from-fuchsia-400 to-cyan-400 tracking-tight pb-2 font-mono wrap-break-words">
GAME OVER
</h1>
<div class="mt-6 inline-block w-full max-w-lg bg-slate-900/80 border border-rose-500/20 rounded-3xl sm:rounded-[2.5rem] p-5 sm:p-6 md:p-12 backdrop-blur-xl shadow-[0_0_30px_rgba(244,63,94,0.15)] text-left mb-6 sm:mb-8 overflow-hidden relative">
<div class="absolute top-0 left-0 w-full h-1 bg-linear-to-r from-transparent via-rose-500/40 to-transparent"></div>
<div class="relative z-10">
<p class="text-rose-500 font-mono text-xs sm:text-sm mb-4 animate-pulse uppercase">
/ system_failure_404
</p>
<p class="text-emerald-400 font-mono text-sm md:text-base drop-shadow-[0_0_8px_rgba(52,211,153,0.3)]">
> <?= htmlspecialchars($errorMessage ?? 'Diese Seite existiert nicht (mehr).') ?>
</p>
</div>
<div class="absolute -bottom-10 -right-10 w-32 h-32 bg-rose-500/5 rounded-full blur-3xl pointer-events-none"></div>
</div>
<div class="pt-6 sm:pt-8 pb-10 relative z-10">
<a href="<?= Config::get('BASE_URL', '/') ?>" class="text-slate-500 hover:text-fuchsia-400 font-mono text-xs sm:text-sm transition-colors">
< Back to Root
</a>
</div>
</div>
<?php require_once __DIR__ . '/inc/footer.php'; ?>

100
home/views/home.php Normal file
View file

@ -0,0 +1,100 @@
<?php require_once __DIR__ . '/inc/header.php'; ?>
<style>
@keyframes colorCycle {
0%, 100% { color: #22d3ee; opacity: 1; text-shadow: 0 0 8px rgba(34, 211, 238, 0.5); }
20% { color: #34d399; opacity: 0.4; text-shadow: 0 0 8px rgba(52, 211, 153, 0.5); }
40% { color: #fbbf24; opacity: 1; text-shadow: 0 0 8px rgba(251, 191, 36, 0.5); }
60% { color: #fb7185; opacity: 0.4; text-shadow: 0 0 8px rgba(251, 113, 133, 0.5); }
80% { color: #818cf8; opacity: 1; text-shadow: 0 0 8px rgba(129, 140, 248, 0.5); }
}
.animate-color-cycle { animation: colorCycle 4s ease-in-out infinite; }
</style>
<div class="max-w-4xl w-full mx-auto mt-4 md:mt-12 px-4 sm:px-6 relative z-10">
<div class="mb-8 sm:mb-10 flex justify-center md:justify-start">
<h1 class="text-2xl sm:text-3xl font-bold text-slate-100 font-mono tracking-tight">
<span class="sr-only">Philipp Urbschat Webentwickler für Individualsoftware, Webdesign, SEO, Datenanalyse und KI-Lösungen in Detmold. Fullstack-Entwicklung für Frontend und Backend.</span>
<span class="animate-color-cycle" aria-hidden="true">_</span>PhilippUrbschat
</h1>
</div>
<div class="flex flex-col md:flex-row items-center md:items-start gap-6 sm:gap-10 mb-16 sm:mb-20">
<div class="relative group cursor-pointer" onclick="triggerGimmick(event)">
<div class="absolute -inset-4 bg-linear-to-r from-emerald-500/20 via-purple-500/20 to-cyan-500/20 rounded-full blur-2xl opacity-50 group-hover:opacity-100 animate-pulse transition-opacity duration-700"></div>
<div class="relative w-24 h-24 md:w-32 md:h-32 shrink-0 bg-slate-900/80 border border-slate-700/60 rounded-3xl shadow-2xl hover:scale-105 transition-all duration-500 flex items-center justify-center text-4xl md:text-5xl backdrop-blur-md overflow-hidden">
<span id="invader-icon" role="img" aria-label="Philipp Urbschat - Digital Developer Icon" class="drop-shadow-[0_0_15px_rgba(168,85,247,0.8)] animate-invader inline-block select-none">👾</span>
</div>
</div>
<div class="text-center md:text-left mt-2 md:mt-0">
<div class="text-base md:text-lg text-slate-400 font-light leading-normal max-w-2xl space-y-4">
<p><strong class="text-slate-200">Developer, Designer, Punkrocker, Problem Solver.</strong></p>
<p>Digital with <strong class="text-fuchsia-400 font-bold drop-shadow-[0_0_12px_rgba(232,121,249,0.6)]">zero Bullshit</strong>. Whether it's a <strong class="text-cyan-400 font-semibold drop-shadow-[0_0_8px_rgba(34,211,238,0.3)]">thoughtful frontend</strong>, a <strong class="text-emerald-400 font-semibold drop-shadow-[0_0_8px_rgba(52,211,153,0.3)]">solid backend</strong>, <strong class="text-amber-400 font-semibold drop-shadow-[0_0_8px_rgba(251,191,36,0.3)]">hands-on SEO</strong>, <strong class="text-rose-400 font-semibold drop-shadow-[0_0_8px_rgba(251,113,133,0.3)]">deep data analysis</strong>, <strong class="text-indigo-400 font-semibold drop-shadow-[0_0_8px_rgba(129,140,248,0.3)]">smart AI integrations</strong>, or a <strong class="text-purple-400 font-bold drop-shadow-[0_0_8px_rgba(192,132,252,0.3)]">sharp concept tying it all together</strong> &ndash; I build systems that just work.</p>
<p>Active staging ground for <strong class="text-slate-300">raw code experiments</strong> and <strong class="text-slate-300">live test environments</strong>.</p>
</div>
<div class="mt-6 sm:mt-8 flex gap-6 justify-center md:justify-start items-center">
<a href="mailto:hi@philippurbschat.de" class="group font-mono text-sm text-slate-500 hover:text-fuchsia-400 transition-all flex items-center gap-2">
<span class="text-fuchsia-500/50 group-hover:scale-125 transition-transform inline-block">#</span>
<span class="relative">hi@philippurbschat.de<span class="absolute bottom-0 left-0 w-0 h-px bg-fuchsia-500 group-hover:w-full transition-all duration-300"></span></span>
</a>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 sm:gap-10 mb-16">
<?php if (isset($_SESSION['user_email'])): ?>
<div class="p-5 sm:p-8 bg-slate-900/80 border border-emerald-500/20 rounded-3xl sm:rounded-[2.5rem] backdrop-blur-xl shadow-[0_0_30px_rgba(16,185,129,0.15)] relative group overflow-hidden">
<div class="absolute top-0 left-0 w-full h-1 bg-linear-to-r from-transparent via-emerald-500/40 to-transparent"></div>
<div class="flex justify-between items-start mb-6">
<h2 class="text-slate-300 text-xs font-bold uppercase tracking-[0.2em] font-mono">Projects</h2>
<a href="<?= Config::get('BASE_URL', '/') ?>login/logout" class="text-xs font-mono text-rose-400 hover:text-rose-300 transition-colors uppercase tracking-wider">Logout</a>
</div>
<p class="text-slate-400 text-sm mb-6 sm:mb-8 font-mono"><span class="text-emerald-500 animate-pulse">/</span> access_granted</p>
<div class="space-y-4 relative z-10">
<?php $projects = $_SESSION['user_projects'] ?? [];
if (empty($projects)): ?>
<p class="text-slate-400 font-mono text-sm">Keine aktiven Projekte gefunden.</p>
<?php else: foreach ($projects as $project): ?>
<a href="<?= Config::get('BASE_URL', '/') ?><?= htmlspecialchars($project) ?>" class="flex items-center justify-between w-full bg-slate-950/80 border border-slate-700/60 hover:border-emerald-500/50 rounded-2xl p-4 text-slate-200 text-sm transition-all shadow-lg hover:shadow-emerald-500/20 group/btn">
<span class="font-mono font-bold uppercase tracking-wider"><?= htmlspecialchars($project) ?></span>
<span class="text-slate-500 group-hover/btn:text-emerald-400 group-hover/btn:translate-x-1 transition-all"></span>
</a>
<?php endforeach; endif; ?>
</div>
<div class="absolute -bottom-10 -right-10 w-32 h-32 bg-emerald-500/5 rounded-full blur-3xl"></div>
</div>
<?php else: ?>
<div class="p-5 sm:p-8 bg-slate-900/80 border border-fuchsia-500/20 rounded-3xl sm:rounded-[2.5rem] backdrop-blur-xl shadow-[0_0_30px_rgba(217,70,239,0.15)] relative group overflow-hidden">
<div class="absolute top-0 left-0 w-full h-1 bg-linear-to-r from-transparent via-fuchsia-500/40 to-transparent"></div>
<h2 class="text-slate-300 text-xs font-bold uppercase tracking-[0.2em] font-mono mb-6">Login</h2>
<p class="text-slate-400 text-sm mb-6 sm:mb-8 font-mono"><span class="text-rose-500 animate-pulse">/</span> access_restricted</p>
<form action="<?= Config::get('BASE_URL', '/') ?>login" method="POST" class="space-y-4 relative z-10">
<input type="hidden" name="csrf_token" value="<?= Security::csrf() ?>">
<input type="email" name="email" placeholder="Email" autocomplete="username" required class="w-full bg-slate-950/80 border border-slate-700/80 rounded-2xl p-4 text-slate-200 text-sm focus:outline-none focus:border-fuchsia-500/50 focus:ring-1 focus:ring-fuchsia-500/50 transition-all placeholder:text-slate-500">
<input type="password" name="password" placeholder="Password" autocomplete="current-password" required class="w-full bg-slate-950/80 border border-slate-700/80 rounded-2xl p-4 text-slate-200 text-sm focus:outline-none focus:border-fuchsia-500/50 focus:ring-1 focus:ring-fuchsia-500/50 transition-all placeholder:text-slate-500">
<button type="submit" class="w-full cursor-pointer bg-slate-900/50 border border-fuchsia-500/40 hover:bg-fuchsia-500/10 text-fuchsia-400 hover:text-fuchsia-300 font-bold font-mono uppercase tracking-wider py-4 rounded-2xl transition-all duration-300 transform active:scale-95 shadow-[0_0_10px_rgba(217,70,239,0.05)] hover:shadow-[0_0_15px_rgba(217,70,239,0.2)]">Authenticate</button>
</form>
<div class="absolute -bottom-10 -right-10 w-32 h-32 bg-fuchsia-500/5 rounded-full blur-3xl"></div>
</div>
<?php endif; ?>
<div class="p-5 sm:p-8 bg-slate-900/80 border border-cyan-500/20 rounded-3xl sm:rounded-[2.5rem] backdrop-blur-xl shadow-[0_0_30px_rgba(6,182,212,0.15)] relative group overflow-hidden flex flex-col justify-between transition-colors duration-500">
<div class="absolute top-0 left-0 w-full h-1 bg-linear-to-r from-transparent via-cyan-500/40 to-transparent"></div>
<div class="relative z-10">
<h3 class="text-slate-300 text-xs font-bold uppercase tracking-[0.2em] mb-6 font-mono">Directory</h3>
<ul class="space-y-4 font-mono text-sm">
<li><a href="<?= Config::get('BASE_URL', '/') ?>about" class="group/link font-mono text-slate-400 hover:text-cyan-400 transition-colors flex items-center gap-2"><span class="text-cyan-500 animate-pulse">/</span>about</a></li>
<li><a href="<?= Config::get('BASE_URL', '/') ?>imprint" class="group/link font-mono text-slate-400 hover:text-cyan-400 transition-colors flex items-center gap-2"><span class="text-cyan-500 animate-pulse">/</span>imprint</a></li>
<li><a href="<?= Config::get('BASE_URL', '/') ?>privacy" class="group/link font-mono text-slate-400 hover:text-cyan-400 transition-colors flex items-center gap-2"><span class="text-cyan-500 animate-pulse">/</span>privacy</a></li>
</ul>
</div>
<div class="mt-10 sm:mt-12 pt-6 border-t border-slate-700/50 relative z-10">
<?php if (isset($_SESSION['is_admin']) && $_SESSION['is_admin'] === true): ?>
<a href="<?= Config::get('BASE_URL', '/') ?>admin" class="group/admin text-[10px] font-mono text-slate-400 hover:text-cyan-400 transition-colors tracking-widest uppercase flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-cyan-500 group-hover:bg-cyan-400 animate-pulse shadow-[0_0_5px_rgba(6,182,212,0.8)]"></span>Kernel Access</a>
<?php else: ?>
<span class="group/admin text-[10px] font-mono text-slate-500 cursor-not-allowed tracking-widest uppercase flex items-center gap-2" title="Access Denied">
<span class="w-1.5 h-1.5 rounded-full bg-rose-500 animate-pulse shadow-[0_0_5px_rgba(244,63,94,0.8)]"></span>Kernel Access (Locked)</span>
<?php endif; ?>
</div>
<div class="absolute -bottom-10 -right-10 w-32 h-32 bg-cyan-500/5 rounded-full blur-3xl pointer-events-none"></div>
</div>
</div>
</div>
<script src="/home/public/js/main.js"></script>
<?php require_once __DIR__ . '/inc/footer.php'; ?>

34
home/views/imprint.php Normal file
View file

@ -0,0 +1,34 @@
<?php require_once __DIR__ . '/inc/header.php'; ?>
<div class="max-w-4xl w-full mx-auto mt-4 md:mt-12 px-4 sm:px-6 relative z-10 mb-20">
<div class="mb-6 sm:mb-8 flex items-center justify-between">
<h1 class="text-2xl sm:text-3xl font-bold text-slate-100 font-mono tracking-tight">
<span class="text-cyan-500 animate-pulse">_</span>Impressum
</h1>
<a href="<?= Config::get('BASE_URL', '/') ?>" class="text-slate-500 hover:text-cyan-400 font-mono text-xs sm:text-sm transition-colors">
< Back to Root
</a>
</div>
<div class="p-5 sm:p-8 md:p-12 bg-slate-900/80 border border-cyan-500/20 rounded-3xl sm:rounded-[2.5rem] backdrop-blur-xl shadow-[0_0_30px_rgba(6,182,212,0.15)] relative overflow-hidden">
<div class="absolute top-0 left-0 w-full h-1 bg-linear-to-r from-transparent via-cyan-500/40 to-transparent"></div>
<div class="space-y-8 sm:space-y-10 text-slate-300 text-sm md:text-base leading-relaxed relative z-10">
<section>
<h2 class="text-lg sm:text-xl font-bold mb-3 sm:mb-4 font-mono text-cyan-400">Angaben gemäß § 5 TMG</h2>
<p>Philipp Urbschat<br>Leopoldstr. 32<br>32756 Detmold</p>
</section>
<section>
<h2 class="text-lg sm:text-xl font-bold mb-3 sm:mb-4 font-mono text-cyan-400">Kontakt</h2>
<p>E-Mail: hi@philippurbschat.de</p>
</section>
<section>
<h2 class="text-lg sm:text-xl font-bold mb-3 sm:mb-4 font-mono text-cyan-400">Haftung für Inhalte</h2>
<p>Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.</p>
</section>
<section>
<h2 class="text-lg sm:text-xl font-bold mb-3 sm:mb-4 font-mono text-cyan-400">Urheberrecht</h2>
<p>Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers.</p>
</section>
</div>
<div class="absolute -bottom-10 -right-10 w-32 h-32 bg-cyan-500/5 rounded-full blur-3xl pointer-events-none"></div>
</div>
</div>
<?php require_once __DIR__ . '/inc/footer.php'; ?>

View file

@ -0,0 +1,6 @@
</main>
<div class="fixed bottom-4 right-4 text-[10px] text-slate-700 font-mono">
PHILCORE_SYSTEM_OS [v1.0.4]
</div>
</body>
</html>

View file

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#020617">
<title>Admin | <?= htmlspecialchars(Config::get('APP_NAME', 'PhilCore')) ?></title>
<link rel="stylesheet" href="/home/public/css/style.css">
<link rel="apple-touch-icon" sizes="180x180" href="/home/public/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/home/public/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/home/public/favicon-16x16.png">
<link rel="shortcut icon" href="/home/public/favicon.ico">
</head>
<body class="bg-[#0b1120] text-slate-300 min-h-screen bg-grid-admin font-sans">
<header class="p-4 border-b border-slate-800 bg-slate-900/50 backdrop-blur-md sticky top-0 z-50">
<div class="container mx-auto flex justify-between items-center">
<div class="flex items-center gap-2 text-emerald-400 font-bold">
👾 <span class="tracking-tight"><?= htmlspecialchars(Config::get('APP_NAME', 'PhilCore')) ?></span>
<span class="text-slate-600 ml-2 text-xs font-mono">/ Admin</span>
</div>
<a href="<?= Config::get('BASE_URL', '/') ?>" class="text-xs font-mono text-slate-500 hover:text-emerald-400 transition-colors">Home</a>
</div>
</header>
<?php Flash::display(); ?>
<main class="p-6 md:p-12">

15
home/views/inc/footer.php Normal file
View file

@ -0,0 +1,15 @@
</main>
<footer class="p-8 border-t border-slate-800/60 text-center text-slate-600 text-sm">
&copy; <?= date('Y') ?>. Made with 🍺 and 🍕.
</footer>
<?php if (Config::get('APP_ENV') !== 'production'): ?>
<div class="fixed bottom-4 left-4 bg-slate-900 border border-rose-500/30 text-rose-400 px-3 py-1.5 rounded-md shadow-lg flex items-center gap-3 text-xs font-mono z-100">
<span class="flex h-2 w-2 relative">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-rose-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-rose-500"></span>
</span>
<span>ENV: <?= strtoupper(htmlspecialchars(Config::get('APP_ENV', 'DEV'))) ?></span>
</div>
<?php endif; ?>
</body>
</html>

62
home/views/inc/header.php Normal file
View file

@ -0,0 +1,62 @@
<?php
// SEO & Meta Setup
$baseUrl = rtrim(Config::get('BASE_URL', 'https://philippurbschat.de/'), '/');
$currentPath = isset($_GET['url']) ? '/' . rtrim($_GET['url'], '/') : '';
$canonicalUrl = $baseUrl . $currentPath;
$pageTitle = isset($title) ? $title . ' | Philipp Urbschat' : 'Philipp Urbschat Developer, Designer, Problem Solver';
// Particle Logic
$isFirstVisit = !isset($_SESSION['particles_initialized']);
$_SESSION['particles_initialized'] = true;
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#020617">
<title><?= htmlspecialchars($pageTitle) ?></title>
<meta name="title" content="<?= htmlspecialchars($pageTitle) ?>">
<meta name="description" content="Developer, Designer, Punkrocker, Problem Solver. Digital with zero Bullshit.">
<meta property="og:type" content="website">
<meta property="og:url" content="<?= htmlspecialchars($canonicalUrl) ?>">
<meta property="og:title" content="<?= htmlspecialchars($pageTitle) ?>">
<meta property="og:description" content="Developer, Designer, Punkrocker, Problem Solver.">
<link rel="canonical" href="<?= htmlspecialchars($canonicalUrl) ?>">
<link rel="stylesheet" href="/home/public/css/style.css">
<link rel="apple-touch-icon" sizes="180x180" href="/home/public/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/home/public/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/home/public/favicon-16x16.png">
<link rel="shortcut icon" href="/home/public/favicon.ico">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Person",
"name": "Philipp Urbschat",
"jobTitle": "Webentwickler & Designer",
"url": "<?= htmlspecialchars($baseUrl) ?>",
"knowsAbout": ["Webentwicklung", "Frontend Design", "Backend Development", "SEO", "Datenanalyse", "AI Integrations"]
}
</script>
</head>
<body class="text-slate-300 min-h-screen flex flex-col relative selection-pink font-sans">
<div class="crt-overlay"></div>
<div class="noise"></div>
<div class="bg-glow"></div>
<div class="fixed inset-0 overflow-hidden pointer-events-none z-0">
<div class="absolute top-[-20%] left-[-10%] w-[60%] h-[60%] bg-indigo-900/10 rounded-full animate-blob"></div>
<div class="absolute bottom-[-20%] right-[-10%] w-[70%] h-[70%] bg-slate-900/30 rounded-full animate-blob" style="animation-delay: -5s;"></div>
<div class="absolute top-[10%] right-[10%] w-[50%] h-[50%] bg-blue-900/5 rounded-full animate-blob" style="animation-delay: -10s;"></div>
<?php
for($i = 0; $i < 15; $i++) {
$duration = rand(15, 30);
$delay = $isFirstVisit ? rand(0, 20) : rand(-$duration, 0);
$style = sprintf(
'width: %dpx; height: %dpx; left: %d%%; bottom: -20px; opacity: %.2f; --p-duration: %ds; --p-delay: %ds;',
rand(1, 3), rand(1, 3), rand(0, 100), rand(10, 40) / 100, $duration, $delay
);
echo "<div class=\"particle\" style=\"{$style}\"></div>\n";
}
?>
</div>
<?php Flash::display(); ?>
<main class="grow flex items-start justify-center p-6 relative z-10">

38
home/views/privacy.php Normal file
View file

@ -0,0 +1,38 @@
<?php require_once __DIR__ . '/inc/header.php'; ?>
<div class="max-w-4xl w-full mx-auto mt-4 md:mt-12 px-4 sm:px-6 relative z-10 mb-20">
<div class="mb-6 sm:mb-8 flex items-center justify-between">
<h1 class="text-2xl sm:text-3xl font-bold text-slate-100 font-mono tracking-tight">
<span class="text-emerald-500 animate-pulse">_</span>Datenschutz
</h1>
<a href="<?= Config::get('BASE_URL', '/') ?>" class="text-slate-500 hover:text-emerald-400 font-mono text-xs sm:text-sm transition-colors">
< Back to Root
</a>
</div>
<div class="p-5 sm:p-8 md:p-12 bg-slate-900/80 border border-emerald-500/20 rounded-3xl sm:rounded-[2.5rem] backdrop-blur-xl shadow-[0_0_30px_rgba(16,185,129,0.15)] relative overflow-hidden">
<div class="absolute top-0 left-0 w-full h-1 bg-linear-to-r from-transparent via-emerald-500/40 to-transparent"></div>
<div class="space-y-8 sm:space-y-10 text-slate-300 text-sm md:text-base leading-relaxed relative z-10">
<section>
<h2 class="text-lg sm:text-xl font-bold mb-3 sm:mb-4 font-mono text-emerald-400">1. Datenschutz auf einen Blick</h2>
<p class="mb-4">Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit deinen personenbezogenen Daten passiert, wenn du diese Website besuchst.</p>
<h3 class="font-bold text-slate-200 mt-4 mb-2">Verantwortliche Stelle</h3>
<p>Philipp Urbschat<br>Leopoldstr. 32<br>32756 Detmold<br>E-Mail: hi@philippurbschat.de</p>
</section>
<section>
<h2 class="text-lg sm:text-xl font-bold mb-3 sm:mb-4 font-mono text-emerald-400">2. Datenerfassung auf dieser Website</h2>
<h3 class="font-bold text-slate-200 mt-4 mb-2">Server-Log-Dateien</h3>
<p class="mb-4">Der Provider der Seiten erhebt und speichert automatisch Informationen in so genannten Server-Log-Dateien, die dein Browser automatisch an uns übermittelt. Dies sind: Browsertyp/Browserversion, verwendetes Betriebssystem, Referrer URL, Hostname des zugreifenden Rechners, Uhrzeit der Serveranfrage und IP-Adresse.</p>
<h3 class="font-bold text-slate-200 mt-4 mb-2">Technisch notwendige Cookies (Session)</h3>
<p class="mb-4">Diese Website verwendet ausschließlich sogenannte "Session-Cookies". Diese sind kleine Textdateien, die nur für die Dauer deines Besuchs im Speicher deines Browsers abgelegt werden. Sie sind technisch notwendig, um Kernfunktionen wie den internen Login-Bereich oder die Darstellung von Animationen über Seitenwechsel hinweg bereitzustellen. Session-Cookies werden nach dem Schließen des Browsers automatisch gelöscht. Ein Tracking oder eine Analyse deines Nutzerverhaltens findet dadurch nicht statt.</p>
<h3 class="font-bold text-slate-200 mt-4 mb-2">Login und Authentifizierung</h3>
<p>Wenn du den internen Login-Bereich nutzt, speichern wir die von dir eingegebenen Anmeldedaten (E-Mail, Passwort in verschlüsselter Form) zur Bereitstellung der Systemfunktionen und zum Schutz vor unbefugtem Zugriff.</p>
</section>
<section>
<h2 class="text-lg sm:text-xl font-bold mb-3 sm:mb-4 font-mono text-emerald-400">3. Lokale Assets</h2>
<h3 class="font-bold text-slate-200 mt-4 mb-2">Schriftarten</h3>
<p>Diese Seite nutzt zur einheitlichen Darstellung von Schriftarten Web Fonts (Outfit, JetBrains Mono), die lokal auf unserem Server bereitgestellt werden. Es findet keine Verbindung zu externen Servern von Drittanbietern statt.</p>
</section>
</div>
<div class="absolute -bottom-10 -right-10 w-32 h-32 bg-emerald-500/5 rounded-full blur-3xl pointer-events-none"></div>
</div>
</div>
<?php require_once __DIR__ . '/inc/footer.php'; ?>

13
philcore/.htaccess Normal file
View file

@ -0,0 +1,13 @@
Options -Indexes
RewriteEngine On
# Leitet den Aufruf ohne Pfad direkt in den public-Ordner
RewriteRule ^$ public/index.php [L]
# GANZ WICHTIG: Stoppt die Umleitung, wenn eine echte Datei (Bilder, CSS) oder ein echter Ordner aufgerufen wird
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
# Leitet alle anderen Aufrufe an den Router um
RewriteCond %{REQUEST_URI} !^/public/
RewriteRule ^(.*)$ public/index.php?url=$1 [L,QSA]

View file

@ -0,0 +1,36 @@
<?php
require_once __DIR__ . '/../../core/Controller.php';
require_once __DIR__ . '/../services/HealthService.php';
class AdminController extends Controller {
// 1. Das Haupt-Dashboard
public function index() {
$report = HealthService::getHealthReport();
$this->view('admin/dashboard', [
'title' => 'Admin Dashboard',
'health' => $report
]);
}
// 2. Die Einstellungen
public function settings() {
$this->view('admin/settings', [
'title' => 'Einstellungen'
]);
}
// 3. Die Benutzerverwaltung
public function users() {
// Erstmal mit Dummy-Daten, bis wir das Model anbinden
$dummyUsers = [
['id' => 1, 'name' => 'Admin Phili', 'email' => 'admin@philcore.local', 'status' => 'Active']
];
$this->view('admin/users', [
'title' => 'Benutzerverwaltung',
'users' => $dummyUsers
]);
}
}

View file

@ -0,0 +1,11 @@
<?php
// Absoluter Pfad: Zwei Ebenen hoch (app -> philcore), dann in core
require_once __DIR__ . '/../../core/Controller.php';
class HomeController extends Controller {
public function index($name = 'Phili') {
$this->view('home', [
'name' => $name
]);
}
}

44
philcore/app/init.php Normal file
View file

@ -0,0 +1,44 @@
<?php
// 1. Session starten
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
date_default_timezone_set('Europe/Berlin');
// 2. Autoloader
spl_autoload_register(function($className) {
$paths = [
__DIR__ . '/../core/',
__DIR__ . '/models/',
__DIR__ . '/services/'
];
foreach ($paths as $path) {
$file = $path . $className . '.php';
$fileLower = $path . strtolower($className) . '.php';
if (file_exists($file)) {
require_once $file;
return;
} elseif (file_exists($fileLower)) {
require_once $fileLower;
return;
}
}
});
// 3. ENV laden
Config::load(__DIR__ . '/../.env');
/**
* 4. GLOBALER STAGING-SCHUTZ
* Dieser Block ist spezifisch für die "philippurbschat.de" Staging-Umgebung.
* Er bindet den zentralen Login (auth.php) ein, der eine Ebene über
* diesem Projekt liegt, um Single-Sign-On zu ermöglichen.
*/
$current_project = 'philcore';
$globalAuth = __DIR__ . '/../../auth.php';
if (file_exists($globalAuth)) {
require_once $globalAuth;
}

View file

@ -0,0 +1,80 @@
<?php
class HealthService {
public static function getHealthReport() {
return [
'php_version' => PHP_VERSION,
'disk_free' => self::getDiskStatus(),
'memory' => self::formatBytes(self::getMemoryLimit()),
'env_status' => self::checkEnvFile(),
'db_status' => self::checkDatabase(),
'writable' => self::checkWritableFolders(['public', 'app'])
];
}
private static function getMemoryLimit() {
$limit = ini_get('memory_limit');
if (preg_match('/^(\d+)(.)$/', $limit, $matches)) {
$val = (int)$matches[1];
switch (strtoupper($matches[2])) {
case 'G': $val *= 1024 * 1024 * 1024; break;
case 'M': $val *= 1024 * 1024; break;
case 'K': $val *= 1024; break;
}
return $val;
}
return (int)$limit;
}
private static function formatBytes($bytes, $precision = 2) {
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
return round($bytes / pow(1024, $pow), $precision) . ' ' . $units[$pow];
}
private static function getDiskStatus() {
if (function_exists('disk_free_space') && function_exists('disk_total_space')) {
try {
$free = @disk_free_space("/");
$total = @disk_total_space("/");
if ($free !== false && $total > 0) {
return round(($free / $total) * 100) . "% frei";
}
} catch (Exception $e) {}
}
return "Eingeschränkt (Hosting)";
}
private static function checkEnvFile() {
// Springt aus app/services/ zwei Ebenen hoch zum Hauptverzeichnis
return file_exists(__DIR__ . '/../../.env') ? 'Bereit' : 'Fehlt!';
}
private static function checkDatabase() {
try {
// Checkt, ob überhaupt ein Name gesetzt ist
if (empty(Config::get('DB_NAME'))) {
return 'Deaktiviert';
}
$db = new Database();
$db->query("SELECT VERSION() as v");
$res = $db->single();
return $res['v'] ?? 'Online';
} catch (Exception $e) {
return 'Offline / Fehler';
}
}
private static function checkWritableFolders($folders) {
foreach ($folders as $folder) {
// Geht vom aktuellen Verzeichnis (app/services/) zwei Ebenen hoch
$path = realpath(__DIR__ . '/../../' . $folder);
if (!$path || !is_dir($path)) return "Fehlt: $folder";
if (!is_writable($path)) return "Rechte: $folder";
}
return "OK";
}
}

25
philcore/core/Auth.php Normal file
View file

@ -0,0 +1,25 @@
<?php
class Auth {
// Unser neuer, objektorientierter Türsteher
public static function check($project) {
$baseUrl = Config::get('BASE_URL', '/');
// 1. Ist der User eingeloggt?
if (!isset($_SESSION['user_email'])) {
header('Location: ' . $baseUrl . '?error=2');
exit();
}
// 2. Hat der User die Rechte für dieses Projekt?
if (!isset($_SESSION['user_projects']) || !in_array($project, $_SESSION['user_projects'])) {
header('Location: ' . $baseUrl);
exit();
}
}
// Helfer, um schnell die E-Mail des aktuellen Users abzufragen
public static function user() {
return $_SESSION['user_email'] ?? null;
}
}

20
philcore/core/Config.php Normal file
View file

@ -0,0 +1,20 @@
<?php
class Config {
public static function load($file = '.env') {
if (!file_exists($file)) return;
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
// Kommentare ignorieren
if (strpos(trim($line), '#') === 0) continue;
// Key und Value trennen
list($name, $value) = explode('=', $line, 2);
$_ENV[trim($name)] = trim($value);
}
}
public static function get($key, $default = null) {
return $_ENV[$key] ?? $default;
}
}

View file

@ -0,0 +1,37 @@
<?php
class Controller {
// Modell laden
public function model($model) {
$modelPath = __DIR__ . '/../app/models/' . $model . '.php';
if (file_exists($modelPath)) {
require_once $modelPath;
return new $model();
}
throw new Exception("Model '{$model}' existiert nicht.");
}
// View laden
public function view($view, $data = []) {
if (!empty($data)) {
extract($data);
}
// Korrigierter Pfad: Von core/ eins hoch, dann direkt in views/
$viewFile = __DIR__ . '/../views/' . $view . '.php';
if (file_exists($viewFile)) {
require_once $viewFile;
} else {
throw new Exception("View '{$view}' existiert nicht unter: {$viewFile}");
}
}
// JSON-Response für APIs und AJAX
public function json($data, $status = 200) {
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
}

View file

@ -0,0 +1,68 @@
<?php
class Database {
private $dbh;
private $stmt;
private $error;
public function __construct() {
$host = Config::get('DB_HOST', 'localhost');
$user = Config::get('DB_USER', 'root');
$pass = Config::get('DB_PASS', '');
$dbname = Config::get('DB_NAME', '');
// Wenn gar keine DB konfiguriert ist, direkt Exception werfen
if (empty($dbname)) {
throw new Exception("Keine Datenbank in .env konfiguriert.");
}
$dsn = 'mysql:host=' . $host . ';dbname=' . $dbname . ';charset=utf8mb4';
$options = [
PDO::ATTR_PERSISTENT => true,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
];
try {
$this->dbh = new PDO($dsn, $user, $pass, $options);
} catch (PDOException $e) {
$this->error = $e->getMessage();
// Exception werfen statt sterben!
throw new Exception("Datenbank-Verbindungsfehler.");
}
}
public function query($sql) {
$this->stmt = $this->dbh->prepare($sql);
}
public function bind($param, $value, $type = null) {
if (is_null($type)) {
switch (true) {
case is_int($value): $type = PDO::PARAM_INT; break;
case is_bool($value): $type = PDO::PARAM_BOOL; break;
case is_null($value): $type = PDO::PARAM_NULL; break;
default: $type = PDO::PARAM_STR;
}
}
$this->stmt->bindValue($param, $value, $type);
}
public function execute() {
return $this->stmt->execute();
}
public function resultSet() {
$this->execute();
return $this->stmt->fetchAll();
}
public function single() {
$this->execute();
return $this->stmt->fetch();
}
public function rowCount() {
return $this->stmt->rowCount();
}
}

26
philcore/core/Flash.php Normal file
View file

@ -0,0 +1,26 @@
<?php
class Flash {
// Nachricht setzen (Typ: 'error', 'success', 'warning', 'info')
public static function set($message, $type = 'error') {
$_SESSION['flash_messages'][] = [
'message' => $message,
'type' => $type
];
}
// Nachrichten ausgeben und danach löschen
public static function display() {
if (isset($_SESSION['flash_messages'])) {
foreach ($_SESSION['flash_messages'] as $flash) {
// Farben je nach Typ
$color = $flash['type'] === 'error' ? 'red' : 'emerald';
echo "<div class='fixed top-4 right-4 bg-{$color}-500/10 border border-{$color}-500/30 text-{$color}-400 px-6 py-3 rounded-lg shadow-lg backdrop-blur-sm z-50 animate-bounce'>
" . htmlspecialchars($flash['message']) . "
</div>";
}
// Nach dem Anzeigen direkt aufräumen!
unset($_SESSION['flash_messages']);
}
}
}

43
philcore/core/Router.php Normal file
View file

@ -0,0 +1,43 @@
<?php
class Router {
protected $controller = 'HomeController';
protected $method = 'index';
protected $params = [];
public function route() {
$url = $this->parseUrl();
// Absoluter Pfad zu den Controllern (geht von core/ einen Ordner hoch und in app/controllers)
$controllerPath = __DIR__ . '/../app/controllers/';
// 1. Controller prüfen (z.B. /user/ -> UserController)
if (isset($url[0]) && file_exists($controllerPath . ucfirst($url[0]) . 'Controller.php')) {
$this->controller = ucfirst($url[0]) . 'Controller';
unset($url[0]);
}
require_once $controllerPath . $this->controller . '.php';
$this->controller = new $this->controller;
// 2. Methode prüfen (z.B. /user/edit -> Methode edit())
if (isset($url[1])) {
if (method_exists($this->controller, $url[1])) {
$this->method = $url[1];
unset($url[1]);
}
}
// 3. Restliche Parameter sammeln
$this->params = $url ? array_values($url) : [];
// 4. Controller-Methode mit den Parametern aufrufen
call_user_func_array([$this->controller, $this->method], $this->params);
}
private function parseUrl() {
if (isset($_GET['url'])) {
return explode('/', filter_var(rtrim($_GET['url'], '/'), FILTER_SANITIZE_URL));
}
return [];
}
}

View file

@ -0,0 +1,18 @@
<?php
class Security {
// Token generieren (z.B. für Formulare)
public static function csrf() {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
// Token beim POST-Request prüfen
public static function checkCsrf($token) {
if (!isset($_SESSION['csrf_token']) || $token !== $_SESSION['csrf_token']) {
throw new Exception("Sicherheits-Token ungültig. Bitte lade die Seite neu.");
}
return true;
}
}

View file

@ -0,0 +1,7 @@
Options -Indexes
RewriteEngine On
# Wenn es keine echte Datei oder Ordner ist, an den Router im selben Verzeichnis schicken
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php?url=$1 [QSA,L]

32
philcore/public/index.php Normal file
View file

@ -0,0 +1,32 @@
<?php
/**
* PhilCore Entry Point
*/
error_reporting(E_ALL);
ini_set('display_errors', 1);
if (file_exists(__DIR__ . '/../app/init.php')) {
require_once __DIR__ . '/../app/init.php';
} else {
die("Kritischer Fehler: app/init.php wurde nicht gefunden.");
}
try {
// Router starten
$router = new Router();
$router->route();
} catch (Exception $e) {
// Fehler abfangen und hübsche Error-View laden
http_response_code(404);
// In Produktion zeigen wir keine genauen Fehlermeldungen an
$errorMessage = Config::get('APP_ENV') === 'development' ? $e->getMessage() : 'Seite nicht gefunden oder Zugriff verweigert.';
$errorView = __DIR__ . '/../views/error.php';
if(file_exists($errorView)) {
require_once $errorView;
} else {
echo "<h1>Systemfehler</h1><p>" . htmlspecialchars($errorMessage) . "</p>";
}
}

27
philcore/readme.md Normal file
View file

@ -0,0 +1,27 @@
# 🍕 PhilCore MVC
Ein leichtgewichtiges, schnelles und sicheres PHP MVC-Grundgerüst für neue Projekte. C&P ready!
## 🚀 Features
* **Routing:** Automatisches URL-Routing (`/controller/method/params`).
* **Datenbank:** Sichere PDO-Klasse mit Prepared Statements (optional nutzbar).
* **Sicherheit:** Integrierter CSRF-Schutz & globale `.env`-Konfiguration.
* **Autoloader:** Kein nerviges `require_once` mehr für Klassen.
* **Admin-Dashboard:** Integrierter `HealthService` für System-Checks.
## 🛠 Installation
1. **Dateien kopieren:** Lade das komplette Projektverzeichnis auf deinen Server oder in deine lokale Entwicklungsumgebung.
2. **Umgebungsvariablen:** Kopiere die `.env.example`, benenne sie in `.env` um und trage deine Daten ein (Base-URL, DB-Credentials etc.).
3. **Routing aktivieren:** Stelle sicher, dass `mod_rewrite` auf deinem Apache-Server aktiv ist. Die `.htaccess`-Dateien leiten alle Anfragen auf `public/index.php` um.
4. **(Optional) DocumentRoot:** Für maximale Sicherheit beim Hosting das Web-Stammverzeichnis direkt auf den Ordner `/public` legen.
## 📂 Struktur
* `/app`: Deine Anwendungslogik (Controllers, Models, Services).
* `/core`: Das Herzstück (Router, Database, Config, Auth, Security).
* `/public`: Der Einstiegspunkt (`index.php`) und statische Assets.
* `/views`: Deine HTML/PHP-Templates (TailwindCSS vorbereitet).
## 💡 Nutzung
Neue Seiten erstellst du einfach durch neue Controller in `/app/controllers/`.
Eine Methode `profile()` im `UserController.php` ist direkt über `deinedomain.de/user/profile` aufrufbar.

View file

@ -0,0 +1,87 @@
<?php
require_once __DIR__ . '/../inc/admin_header.php';
?>
<div class="max-w-6xl w-full mx-auto flex flex-col md:flex-row gap-6 p-6">
<aside class="w-full md:w-64 bg-slate-900/80 backdrop-blur-sm border border-slate-700/50 rounded-xl shadow-lg p-4 h-fit">
<div class="flex items-center gap-2 mb-6 border-b border-slate-700/50 pb-3">
<span class="text-xl">⚙️</span>
<h2 class="text-emerald-400 font-mono font-bold text-lg tracking-tight">System_Admin</h2>
</div>
<nav class="flex flex-col gap-2 font-mono text-sm">
<a href="<?= Config::get('BASE_URL', '/') ?>admin" class="px-3 py-2 bg-emerald-500/10 text-emerald-400 rounded-md border border-emerald-500/20 transition-all">
> Dashboard
</a>
<a href="<?= Config::get('BASE_URL', '/') ?>admin/users" class="px-3 py-2 hover:bg-slate-800 text-slate-400 hover:text-slate-200 rounded-md transition-all">
> Benutzer
</a>
<a href="<?= Config::get('BASE_URL', '/') ?>admin/settings" class="px-3 py-2 hover:bg-slate-800 text-slate-400 hover:text-slate-200 rounded-md transition-all">
> Einstellungen
</a>
</nav>
</aside>
<main class="flex-1 bg-slate-900/80 backdrop-blur-sm border border-slate-700/50 rounded-xl shadow-2xl overflow-hidden flex flex-col">
<div class="bg-slate-800/80 px-4 py-3 border-b border-slate-700/50 flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-rose-500"></div>
<div class="w-3 h-3 rounded-full bg-amber-500"></div>
<div class="w-3 h-3 rounded-full bg-emerald-500"></div>
<div class="ml-2 font-mono text-xs text-slate-400">philcore-dashboard.sh</div>
<div class="ml-auto text-[10px] text-slate-500 font-mono italic">terminal_session_main</div>
</div>
<div class="p-6 sm:p-8 flex-1">
<header class="mb-8 border-b border-slate-800 pb-6">
<h1 class="text-3xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-cyan-400 tracking-tight mb-2">
> Dashboard_
</h1>
<p class="text-slate-400 font-mono text-sm">System-Status für <span class="text-emerald-400"><?= htmlspecialchars(Auth::user() ?? 'Phili') ?></span></p>
</header>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="p-5 bg-slate-950/80 rounded-lg border border-slate-800/80 shadow-inner">
<h3 class="text-slate-500 font-mono text-xs uppercase tracking-wider mb-2">> db_connection</h3>
<p class="text-emerald-400 font-mono text-sm flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
<?= htmlspecialchars($health['db_status'] ?? 'Error') ?>
</p>
</div>
<div class="p-5 bg-slate-950/80 rounded-lg border border-slate-800/80 shadow-inner">
<h3 class="text-slate-500 font-mono text-xs uppercase tracking-wider mb-2">> php_engine</h3>
<p class="text-cyan-400 font-mono text-sm">
v<?= htmlspecialchars($health['php_version'] ?? 'N/A') ?> (<?= htmlspecialchars($health['memory'] ?? 'N/A') ?>)
</p>
</div>
<div class="p-5 bg-slate-950/80 rounded-lg border border-slate-800/80 shadow-inner">
<h3 class="text-slate-500 font-mono text-xs uppercase tracking-wider mb-2">> disk_space</h3>
<p class="text-slate-300 font-mono text-sm">
<?= htmlspecialchars($health['disk_free'] ?? 'Locked') ?>
</p>
</div>
<div class="p-5 bg-slate-950/80 rounded-lg border border-slate-800/80 shadow-inner">
<h3 class="text-slate-500 font-mono text-xs uppercase tracking-wider mb-2">> write_permissions</h3>
<p class="font-mono text-sm <?= ($health['writable'] ?? '') === 'OK' ? 'text-emerald-400' : 'text-rose-500' ?>">
<?= htmlspecialchars($health['writable'] ?? 'Failed') ?>
</p>
</div>
</div>
<div class="mt-8 p-4 bg-black/40 rounded border border-slate-800 font-mono text-[10px] text-slate-500 leading-relaxed">
<p>> [<?= date('H:i:s') ?>] SYS_LOAD: OPTIMAL</p>
<p>> [<?= date('H:i:s') ?>] ENV_STATUS: <?= htmlspecialchars($health['env_status'] ?? 'Unknown') ?></p>
<p>> [<?= date('H:i:s') ?>] Alle Systeme bereit.</p>
</div>
</div>
</main>
</div>
<?php
require_once __DIR__ . '/../inc/admin_footer.php';
?>

View file

@ -0,0 +1,66 @@
<?php require_once __DIR__ . '/../inc/admin_header.php'; ?>
<div class="max-w-6xl w-full mx-auto flex flex-col md:flex-row gap-6 p-6">
<aside class="w-full md:w-64 bg-slate-900/80 backdrop-blur-sm border border-slate-700/50 rounded-xl shadow-lg p-4 h-fit">
<div class="flex items-center gap-2 mb-6 border-b border-slate-700/50 pb-3">
<span class="text-xl">⚙️</span>
<h2 class="text-emerald-400 font-mono font-bold text-lg tracking-tight">System_Admin</h2>
</div>
<nav class="flex flex-col gap-2 font-mono text-sm">
<a href="<?= Config::get('BASE_URL', '/') ?>admin" class="px-3 py-2 hover:bg-slate-800 text-slate-400 hover:text-slate-200 rounded-md transition-all">
> Dashboard
</a>
<a href="<?= Config::get('BASE_URL', '/') ?>admin/users" class="px-3 py-2 hover:bg-slate-800 text-slate-400 hover:text-slate-200 rounded-md transition-all">
> Benutzer
</a>
<a href="<?= Config::get('BASE_URL', '/') ?>admin/settings" class="px-3 py-2 bg-emerald-500/10 text-emerald-400 rounded-md border border-emerald-500/20 transition-all">
> Einstellungen
</a>
</nav>
</aside>
<main class="flex-1 bg-slate-900/80 backdrop-blur-sm border border-slate-700/50 rounded-xl shadow-2xl overflow-hidden flex flex-col">
<div class="bg-slate-800/80 px-4 py-3 border-b border-slate-700/50 flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-rose-500"></div>
<div class="w-3 h-3 rounded-full bg-amber-500"></div>
<div class="w-3 h-3 rounded-full bg-emerald-500"></div>
<div class="ml-2 font-mono text-xs text-slate-400">philcore-settings.sh</div>
</div>
<div class="p-6 sm:p-8 flex-1">
<header class="mb-8 border-b border-slate-800 pb-6">
<h1 class="text-3xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-cyan-400 tracking-tight mb-2">
> <?= htmlspecialchars($title) ?>_
</h1>
<p class="text-slate-400 font-mono text-sm">Systemkonfiguration anpassen.</p>
</header>
<div class="space-y-6">
<div class="p-5 bg-slate-950/80 rounded-lg border border-slate-800/80 shadow-inner">
<h3 class="text-slate-500 font-mono text-xs uppercase tracking-wider mb-4">> env_variables</h3>
<div class="space-y-4">
<div>
<label class="block text-slate-400 font-mono text-sm mb-1">APP_NAME</label>
<input type="text" value="<?= htmlspecialchars(Config::get('APP_NAME', 'PhilCore')) ?>"
class="w-full bg-slate-900 border border-slate-700 rounded p-2 text-emerald-400 font-mono text-sm focus:outline-none" readonly>
</div>
<div>
<label class="block text-slate-400 font-mono text-sm mb-1">GEMINI_API_KEY</label>
<input type="text" value="<?= substr(Config::get('GEMINI_API_KEY', ''), 0, 5) ?>*******************"
class="w-full bg-slate-900 border border-slate-700 rounded p-2 text-emerald-400 font-mono text-sm focus:outline-none" readonly>
<p class="text-xs text-slate-500 mt-2 font-mono tracking-tighter">
// Hinweis: Änderungen direkt in der .env vornehmen.
</p>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<?php require_once __DIR__ . '/../inc/admin_footer.php'; ?>

View file

@ -0,0 +1,66 @@
<?php require_once __DIR__ . '/../inc/admin_header.php'; ?>
<div class="max-w-6xl w-full mx-auto flex flex-col md:flex-row gap-6 p-6">
<aside class="w-full md:w-64 bg-slate-900/80 backdrop-blur-sm border border-slate-700/50 rounded-xl shadow-lg p-4 h-fit">
<div class="flex items-center gap-2 mb-6 border-b border-slate-700/50 pb-3">
<span class="text-xl">⚙️</span>
<h2 class="text-emerald-400 font-mono font-bold text-lg tracking-tight">System_Admin</h2>
</div>
<nav class="flex flex-col gap-2 font-mono text-sm">
<a href="<?= Config::get('BASE_URL', '/') ?>admin" class="px-3 py-2 hover:bg-slate-800 text-slate-400 hover:text-slate-200 rounded-md transition-all">
> Dashboard
</a>
<a href="<?= Config::get('BASE_URL', '/') ?>admin/users" class="px-3 py-2 bg-emerald-500/10 text-emerald-400 rounded-md border border-emerald-500/20 transition-all">
> Benutzer
</a>
<a href="<?= Config::get('BASE_URL', '/') ?>admin/settings" class="px-3 py-2 hover:bg-slate-800 text-slate-400 hover:text-slate-200 rounded-md transition-all">
> Einstellungen
</a>
</nav>
</aside>
<main class="flex-1 bg-slate-900/80 backdrop-blur-sm border border-slate-700/50 rounded-xl shadow-2xl overflow-hidden flex flex-col">
<div class="bg-slate-800/80 px-4 py-3 border-b border-slate-700/50 flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-rose-500"></div>
<div class="w-3 h-3 rounded-full bg-amber-500"></div>
<div class="w-3 h-3 rounded-full bg-emerald-500"></div>
<div class="ml-2 font-mono text-xs text-slate-400">philcore-users.sh</div>
</div>
<div class="p-6 sm:p-8 flex-1">
<header class="mb-8 border-b border-slate-800 pb-6">
<h1 class="text-3xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-cyan-400 tracking-tight mb-2">
> <?= htmlspecialchars($title) ?>_
</h1>
<p class="text-slate-400 font-mono text-sm">Registrierte System-Accounts.</p>
</header>
<div class="overflow-x-auto border border-slate-800/80 rounded-lg">
<table class="w-full text-left font-mono text-sm text-slate-400">
<thead class="bg-slate-950/80 text-emerald-400 text-xs uppercase">
<tr>
<th class="px-4 py-3 border-b border-slate-800/80">ID</th>
<th class="px-4 py-3 border-b border-slate-800/80">Name</th>
<th class="px-4 py-3 border-b border-slate-800/80">Email</th>
<th class="px-4 py-3 border-b border-slate-800/80">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800/50 bg-slate-900/50">
<?php foreach($users as $u): ?>
<tr class="hover:bg-slate-800/50 transition-colors">
<td class="px-4 py-3">#<?= $u['id'] ?></td>
<td class="px-4 py-3 text-slate-200"><?= htmlspecialchars($u['name']) ?></td>
<td class="px-4 py-3"><?= htmlspecialchars($u['email']) ?></td>
<td class="px-4 py-3"><span class="text-emerald-500"><?= $u['status'] ?></span></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</main>
</div>
<?php require_once __DIR__ . '/../inc/admin_footer.php'; ?>

26
philcore/views/error.php Normal file
View file

@ -0,0 +1,26 @@
<?php require_once __DIR__ . '/inc/header.php'; ?>
<div class="max-w-3xl w-full text-center space-y-8 mt-12 md:mt-24 mx-auto">
<div class="text-8xl cursor-default inline-block drop-shadow-2xl grayscale">
🍕
</div>
<h1 class="text-5xl md:text-7xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-slate-500 to-slate-700 tracking-tight pb-2">
Fehler 404
</h1>
<div class="mt-6 inline-block bg-rose-500/10 border border-rose-500/20 rounded-lg p-4">
<p class="text-rose-400 font-mono text-sm">
> <?= htmlspecialchars($errorMessage ?? 'Da ist etwas schiefgelaufen.') ?>
</p>
</div>
<div class="pt-10">
<a href="<?= Config::get('BASE_URL', '/') ?>" class="inline-flex items-center gap-3 px-8 py-4 bg-slate-800 hover:bg-slate-700 text-slate-300 font-bold rounded-2xl transition-all shadow-lg">
<span>🔙</span>
<span>Zurück zur Basis</span>
</a>
</div>
</div>
<?php require_once __DIR__ . '/inc/footer.php'; ?>

24
philcore/views/home.php Normal file
View file

@ -0,0 +1,24 @@
<?php require_once __DIR__ . '/inc/header.php'; ?>
<div class="max-w-3xl w-full text-center space-y-8 mt-12 md:mt-24 mx-auto">
<div class="text-8xl hover:rotate-12 transition-transform cursor-default inline-block drop-shadow-2xl">
🍕
</div>
<h1 class="text-5xl md:text-7xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-amber-400 to-rose-500 tracking-tight pb-2">
Hallo, <?= htmlspecialchars($name ?? 'Gast') ?>.
</h1>
<p class="text-xl text-slate-400 max-w-2xl mx-auto font-light mt-6">
Das Fundament steht. Zeit, etwas Großartiges zu bauen.
</p>
<div class="pt-10">
<a href="<?= Config::get('BASE_URL', '/') ?>admin" class="inline-flex items-center gap-3 px-8 py-4 bg-gradient-to-r from-amber-500 to-rose-500 text-slate-900 font-bold rounded-2xl transition-all shadow-lg hover:scale-105 active:scale-95">
<span>🚀</span>
<span>Zum Admin-Bereich</span>
</a>
</div>
</div>
<?php require_once __DIR__ . '/inc/footer.php'; ?>

View file

@ -0,0 +1,6 @@
</main>
<div class="fixed bottom-4 right-4 text-[10px] text-slate-700 font-mono">
PHILCORE_SYSTEM_OS [v1.0.4]
</div>
</body>
</html>

View file

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin | <?= htmlspecialchars(Config::get('APP_NAME', 'PhilCore')) ?></title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Fira+Code&display=swap" rel="stylesheet">
<style>
.bg-grid-admin {
background-size: 40px 40px;
background-image: linear-gradient(to right, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
}
</style>
</head>
<body class="bg-[#0b1120] text-slate-300 min-h-screen bg-grid-admin font-sans">
<header class="p-4 border-b border-slate-800 bg-slate-900/50 backdrop-blur-md sticky top-0 z-50">
<div class="container mx-auto flex justify-between items-center">
<div class="flex items-center gap-2 text-emerald-400 font-bold">
🍕 <span class="tracking-tight"><?= htmlspecialchars(Config::get('APP_NAME', 'PhilCore')) ?></span>
<span class="text-slate-600 ml-2 text-xs font-mono">/ Admin</span>
</div>
<a href="<?= Config::get('BASE_URL', '/') ?>" class="text-xs font-mono text-slate-500 hover:text-emerald-400 transition-colors">Home</a>
</div>
</header>
<?php Flash::display(); ?>
<main class="p-6 md:p-12">

View file

@ -0,0 +1,16 @@
</main>
<footer class="p-8 border-t border-slate-800/60 text-center text-slate-500 text-sm">
&copy; <?= date('Y') ?> <?= htmlspecialchars(Config::get('APP_NAME')) ?>. Made with ❤️ and Pizza.
</footer>
<?php if (Config::get('APP_ENV') !== 'production'): ?>
<div class="fixed bottom-4 left-4 bg-slate-900 border border-rose-500/30 text-rose-400 px-3 py-1.5 rounded-md shadow-lg flex items-center gap-3 text-xs font-mono z-[100]">
<span class="flex h-2 w-2 relative">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-rose-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-rose-500"></span>
</span>
<span>ENV: <?= strtoupper(htmlspecialchars(Config::get('APP_ENV', 'DEV'))) ?></span>
</div>
<?php endif; ?>
</body>
</html>

View file

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars(Config::get('APP_NAME', 'PhilCore')) ?></title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
<style>
.bg-grid {
background-size: 40px 40px;
background-image: linear-gradient(to right, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
}
</style>
</head>
<body class="bg-slate-950 text-slate-300 min-h-screen flex flex-col relative bg-grid font-sans selection:bg-rose-500 selection:text-white">
<header class="p-4 border-b border-slate-800/60 bg-slate-900/80 backdrop-blur-md sticky top-0 z-40">
<div class="container mx-auto flex justify-between items-center">
<div class="flex items-center gap-2 text-rose-500 font-bold text-xl tracking-tight">
<span>🍕</span> <?= htmlspecialchars(Config::get('APP_NAME', 'PhilCore')) ?>
</div>
<nav class="text-sm font-medium">
<a href="<?= Config::get('BASE_URL', '/') ?>" class="hover:text-rose-400 transition-colors">Home</a>
</nav>
</div>
</header>
<?php Flash::display(); ?>
<main class="flex-grow flex items-start justify-center p-6">

2
plants/.env.example Normal file
View file

@ -0,0 +1,2 @@
DB_PASSWORD="your_db_password_here"
GEMINI_API_KEY="your_gemini_api_key_here"

1
plants/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.env

173
plants/api.js Normal file
View file

@ -0,0 +1,173 @@
// Version: 01.10.2025 11:45 (FINAL FIX)
const API_URL = 'api.php';
// --- Hilfsfunktion für Fehlerbehandlung ---
async function handleResponse(response) {
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Unbekannter API-Fehler' }));
throw new Error(errorData.error || `HTTP-Fehler! Status: ${response.status}`);
}
return response.json();
}
// --- Nutzer-Funktionen ---
export async function getUserSettings() {
const response = await fetch(`${API_URL}?action=get_user_settings`);
return handleResponse(response);
}
export async function updateUserSettings(settings) {
const response = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ request_type: 'update_user_settings', settings: settings }),
});
return handleResponse(response);
}
// --- Lexikon-Funktionen ---
export async function getLexiconEntries() {
const response = await fetch(`${API_URL}?action=get_lexicon_entries`);
return handleResponse(response);
}
export async function getLexiconEntryDetails(latinName) {
const response = await fetch(`${API_URL}?action=get_lexicon_entry_details&latin_name=${encodeURIComponent(latinName)}`);
return handleResponse(response);
}
// --- Pflanzen-Funktionen ---
export async function getPlants() {
const response = await fetch(`${API_URL}?action=get_plants`);
return handleResponse(response);
}
export async function savePlant(formData) {
const response = await fetch(API_URL, { method: 'POST', body: formData });
return handleResponse(response);
}
export async function deletePlant(plantId) {
const response = await fetch(`${API_URL}?id=${plantId}`, { method: 'DELETE' });
return handleResponse(response);
}
export async function recordCareAction(plantId, action) {
const response = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ request_type: 'care_action', id: plantId, action }),
});
return handleResponse(response);
}
export async function setProfilePicture(plantId, photoPath) {
const response = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ request_type: 'set_profile_picture', plant_id: plantId, photo_path: photoPath }),
});
return handleResponse(response);
}
// --- Tagebuch-Funktionen ---
export async function getDiaryEntries(plantId) {
const response = await fetch(`${API_URL}?action=get_diary&plant_id=${plantId}`);
return handleResponse(response);
}
export async function addDiaryEntry(formData) {
formData.append('action', 'add_diary_entry');
const response = await fetch(API_URL, { method: 'POST', body: formData });
return handleResponse(response);
}
export async function deleteDiaryEntry(entryId) {
const response = await fetch(`${API_URL}?diary_entry_id=${entryId}`, { method: 'DELETE' });
return handleResponse(response);
}
// --- KI-Funktionen ---
// Schritt 1: Identifikation & Foto-Analyse
export async function performInitialAnalysis(base64Image, excludedLatinNames = []) {
let prompt = `
Du bist ein Botaniker und Foto-Analyst. Führe ZWEI Aufgaben aus und gib das Ergebnis als EINEN einzigen, sauberen JSON-String zurück. Das JSON-Objekt muss zwei Hauptschlüssel haben: "identification" und "photo_analysis".
1. **Im "identification"-Objekt:** Identifiziere die Pflanze.
- "name": Der gebräuchlichste Name, so wie er im deutschen Einzelhandel genutzt wird.
- "latin_name": Der korrekte, zweiteilige botanische Name.
- "confidence_score": Bewerte deine Sicherheit KRITISCH (0-100).
- "identification_notes": Kurze Notiz zur Identifizierung.
- REGEL: Falls dein confidence_score < 75 ist, setze alle Werte auf "Unbekannt" oder 0.
2. **Im "photo_analysis"-Objekt:** Analysiere die Fotoqualität.
- "photo_quality_score" (integer): Bewerte die Qualität KRITISCH (0-100).
- "is_high_quality" (boolean): true, wenn photo_quality_score > 70 ist.
- "rejection_reason" (string): Kurzer Grund, falls is_high_quality false ist, sonst null.
- "photo_type" (string): 'whole_plant', 'leaf_detail', 'flower_detail', oder 'other'.
- "focal_point" (object): Ein Objekt mit "x"- und "y"-Koordinaten (Prozent von 0-100) des visuellen Mittelpunkts.
- "dominant_color_hex" (string): Der dominante Farbton des Hauptmotivs als HEX-Code.
`;
if (excludedLatinNames.length > 0) {
prompt += ` Ignoriere dabei diese falschen Vorschläge: ${excludedLatinNames.join(', ')}.`;
}
const payload = {
request_type: 'ai_identification',
payload: { "contents": [{ "parts": [ { "text": prompt }, { "inline_data": { "mime_type": "image/jpeg", "data": base64Image } } ] }] }
};
const response = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const result = await handleResponse(response);
const resultText = result.candidates[0].content.parts[0].text;
return JSON.parse(resultText.replace(/```json|```/g, '').trim());
}
// Schritt 2: Detaillierte Anreicherung
export async function aiEnrich(latinName) {
const prompt = `
Du bist ein Botanik-Experte. Gib detaillierte Pflegedaten für "${latinName}" als sauberen JSON-String zurück.
ANWEISUNGEN FÜR DEN INHALT:
- Für "description_full": Erstelle einen ausführlichen, enzyklopädischen Text. **Verwende KEINE einleitenden Grußformeln, Willkommenssätze oder persönlichen Anreden wie "Du"**. Gliedere den Text in Abschnitte mit Titeln (z.B. 'Standort & Licht:') und spreche den Leser neutral an (z.B. "Diese Pflanze benötigt..."). Die Titel dürfen KEIN Markdown enthalten.
- Für "care_tips": Erstelle genau drei kurze, prägnante Tipps aus der Ich-Perspektive der Pflanze.
- Für "tags": Gib 3-5 passende Schlagwörter als kommaseparierten String an (z.B. "Anfängerfreundlich, Haustier-sicher, Buntblatt").
Das JSON-Objekt MUSS die folgenden Schlüssel enthalten, auch wenn der Wert null ist: "plant_family", "plant_genus", "description_full", "watering_interval_days", "fertilizing_interval_days", "care_tips", "light_requirement", "water_requirement", "humidity_requirement", "care_difficulty", "growth_habit", "mature_size", "toxicity", "edibility", "temperature_preference", "flowering_period", "substrate_recommendation", "native_to", "tags".
WICHTIGE REGELN FÜR KATEGORIEN:
- Für "substrate_recommendation": Wähle einen oder MEHRERE passende Werte aus der Liste und gib sie als kommaseparierten String zurück: 'Universal-Erde', 'Sukkulenten/Kakteen-Erde', 'Anzucht-Erde', 'Kräuter-Erde', 'Saurer Boden (Moorbeet-Erde)', 'Orchideen-Substrat', 'Hydrokultur/Pon', 'Erde-frei (Aroid-Mix)'.
- Für die folgenden Felder, wähle EXAKT EINEN der angegebenen Werte in Kleinbuchstaben:
- "light_requirement": 'wenig', 'mittel', 'viel'.
- "water_requirement": 'niedrig', 'mittel', 'hoch'.
- "humidity_requirement": 'normal', 'erhöht'.
- "care_difficulty": 'einfach', 'mittel', 'schwer'.
- "growth_habit": 'kletternd', 'hängend', 'aufrecht', 'buschig', 'rosette'.
- "mature_size": 'klein', 'mittel', 'groß'.
- "toxicity": 'ungiftig', 'leicht giftig', 'giftig'.
- "edibility": 'essbar', 'nicht essbar', 'zierfrüchte'.
- "temperature_preference": 'zimmerwarm', 'kühl', 'winterhart'.
- "flowering_period": 'frühling', 'sommer', 'herbst', 'winter', 'ganzjährig', 'selten', oder null.
FORMATIERUNGSREGELN für "care_tips":
1. Jeder Tipp MUSS mit einem passenden Emoji und einem Titel beginnen (z.B. "☀️ Licht:").
2. Zwischen den Tipps MUSS sich ein doppelter Zeilenumbruch (\\n\\n) befinden.`;
const payload = {
request_type: 'ai_enrichment',
payload: { "contents": [{ "parts": [ { "text": prompt } ] }] }
};
const response = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const result = await handleResponse(response);
const resultText = result.candidates[0].content.parts[0].text;
return JSON.parse(resultText.replace(/```json|```/g, '').trim());
}

358
plants/api.php Normal file
View file

@ -0,0 +1,358 @@
<?php
// Version: 01.10.2025 14:15 (FIX created_at COLUMN)
session_start();
require_once 'db.php';
require_once __DIR__ . '/vendor/autoload.php';
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->load();
if (!isset($_SESSION['user_id'])) {
http_response_code(401);
echo json_encode(['error' => 'Benutzer nicht authentifiziert.']);
exit;
}
$currentUserId = $_SESSION['user_id'];
// === HELPER FUNCTIONS ===
function callGeminiAPI(array $payload, string $apiKey): array {
$googleApiUrl = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent';
$fullApiUrl = $googleApiUrl . '?key=' . $apiKey;
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $fullApiUrl,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_TIMEOUT => 90,
]);
$response_body = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch) || $http_code >= 400) {
$error_message = curl_errno($ch) ? curl_error($ch) : 'KI-API-Fehler mit Status ' . $http_code . ' - ' . $response_body;
curl_close($ch);
throw new Exception('Fehler bei der Kommunikation mit der KI: ' . $error_message);
}
curl_close($ch);
return json_decode($response_body, true);
}
function processUploadedPhoto(array $fileInfo, int $targetWidth = 800): ?string {
if ($fileInfo['error'] !== UPLOAD_ERR_OK) return null;
$allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!in_array(mime_content_type($fileInfo['tmp_name']), $allowedMimeTypes) || $fileInfo['size'] > (10 * 1024 * 1024)) return null;
$uploadDir = 'uploads/';
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
$fileName = uniqid() . '-' . pathinfo($fileInfo['name'], PATHINFO_FILENAME) . '.webp';
$targetFilePath = $uploadDir . $fileName;
$sourceImage = null;
switch (exif_imagetype($fileInfo['tmp_name'])) {
case IMAGETYPE_JPEG: $sourceImage = imagecreatefromjpeg($fileInfo['tmp_name']); break;
case IMAGETYPE_PNG: $sourceImage = imagecreatefrompng($fileInfo['tmp_name']); break;
case IMAGETYPE_WEBP: $sourceImage = imagecreatefromwebp($fileInfo['tmp_name']); break;
}
if (!$sourceImage) return null;
$resizedImage = (imagesx($sourceImage) > $targetWidth) ? imagescale($sourceImage, $targetWidth) : $sourceImage;
imagewebp($resizedImage, $targetFilePath, 85);
imagedestroy($sourceImage);
if ($resizedImage !== $sourceImage) imagedestroy($resizedImage);
return $targetFilePath;
}
function getPlantWithDueDates($pdo, $plantId, $userId) {
$stmt = $pdo->prepare("SELECT p.id, p.name, p.latin_name, p.photo, p.last_watered, p.last_fertilized, p.watering_interval_days, p.fertilizing_interval_days, p.care_tips FROM plants p WHERE p.id = ? AND p.user_id = ?");
$stmt->execute([$plantId, $userId]);
$plant = $stmt->fetch(PDO::FETCH_ASSOC);
if ($plant) {
$plant['watering_interval'] = $plant['watering_interval_days'];
$plant['fertilizing_interval'] = $plant['fertilizing_interval_days'];
$isValidDate = fn($dateStr) => !empty($dateStr) && $dateStr !== '0000-00-00';
if ((int)$plant['watering_interval'] > 0 && $isValidDate($plant['last_watered'])) {
$plant['next_watering_due'] = (new DateTime($plant['last_watered']))->add(new DateInterval('P' . $plant['watering_interval'] . 'D'))->format('Y-m-d');
} else { $plant['next_watering_due'] = null; }
if ((int)$plant['fertilizing_interval'] > 0 && $isValidDate($plant['last_fertilized'])) {
$plant['next_fertilizing_due'] = (new DateTime($plant['last_fertilized']))->add(new DateInterval('P' . $plant['fertilizing_interval'] . 'D'))->format('Y-m-d');
} else { $plant['next_fertilizing_due'] = null; }
}
return $plant;
}
// === CREDIT MANAGEMENT ===
try {
$stmt = $pdo->prepare("SELECT ai_credits_last_reset FROM users WHERE id = ?");
$stmt->execute([$currentUserId]);
$lastReset = $stmt->fetchColumn();
$now = new DateTime();
if ($lastReset === null || (new DateTime($lastReset))->format('Y-m') < $now->format('Y-m')) {
$stmt = $pdo->prepare("UPDATE users SET ai_credits = 100, ai_credits_last_reset = NOW() WHERE id = ?");
$stmt->execute([$currentUserId]);
}
} catch (Exception $e) { /* non-blocking */ }
// === API ROUTING ===
$method = $_SERVER['REQUEST_METHOD'];
$json_data = json_decode(file_get_contents('php://input'), true);
header('Content-Type: application/json');
try {
// --- Handle JSON-based POST requests FIRST ---
if ($method === 'POST' && !empty($json_data)) {
$request_type = $json_data['request_type'] ?? '';
switch ($request_type) {
case 'ai_identification':
case 'ai_enrichment':
$pdo->beginTransaction();
$stmt = $pdo->prepare("UPDATE users SET ai_credits = ai_credits - 1 WHERE id = ? AND ai_credits >= 1");
$stmt->execute([$currentUserId]);
if ($stmt->rowCount() === 0) throw new Exception('Nicht genügend KI-Credits (1 benötigt).', 402);
$response_data = callGeminiAPI($json_data['payload'], $_ENV['GEMINI_API_KEY']);
$pdo->commit();
echo json_encode($response_data);
break;
case 'update_user_settings':
$settings = $json_data['settings'] ?? [];
$allowedKeys = ['theme', 'allow_photo_sharing', 'auto_fill_watering', 'auto_fill_fertilizing', 'auto_fill_care_tips'];
$sqlParts = []; $params = [];
foreach ($settings as $key => $value) {
if (in_array($key, $allowedKeys)) {
$sqlParts[] = "$key = ?";
$params[] = is_bool($value) ? (int)$value : $value;
}
}
if (empty($sqlParts)) throw new Exception('Keine gültigen Einstellungsfelder.', 400);
$params[] = $currentUserId;
$stmt = $pdo->prepare("UPDATE users SET " . implode(', ', $sqlParts) . " WHERE id = ?");
$stmt->execute($params);
echo json_encode(['success' => true]);
break;
case 'care_action':
$plantId = $json_data['id'];
$column = ($json_data['action'] === 'water') ? 'last_watered' : 'last_fertilized';
$entryType = ($json_data['action'] === 'water') ? 'watered' : 'fertilized';
$pdo->beginTransaction();
$stmt = $pdo->prepare("UPDATE plants SET $column = CURDATE() WHERE id = ? AND user_id = ?");
$stmt->execute([$plantId, $currentUserId]);
$stmt = $pdo->prepare("INSERT INTO plant_diary (plant_id, user_id, entry_type) VALUES (?, ?, ?)");
$stmt->execute([$plantId, $currentUserId, $entryType]);
$pdo->commit();
echo json_encode(['success' => true, 'plant' => getPlantWithDueDates($pdo, $plantId, $currentUserId)]);
break;
case 'set_profile_picture':
$stmt = $pdo->prepare("UPDATE users SET profile_image = ? WHERE id = ?");
$stmt->execute([$json_data['photo_path'], $currentUserId]);
echo json_encode(['success' => true, 'new_image' => $json_data['photo_path']]);
break;
default:
throw new Exception('Unbekannter JSON-Request-Typ.', 400);
}
exit;
}
// --- Handle GET, DELETE, and multipart/form-data POST requests ---
switch ($method) {
case 'GET':
$action = $_GET['action'] ?? 'get_plants';
switch ($action) {
case 'get_plants':
$stmt = $pdo->prepare("SELECT id FROM plants WHERE user_id = ? ORDER BY id DESC");
$stmt->execute([$currentUserId]);
$plant_ids = $stmt->fetchAll(PDO::FETCH_COLUMN);
echo json_encode(array_map(fn($id) => getPlantWithDueDates($pdo, $id, $currentUserId), $plant_ids));
break;
case 'get_diary':
$stmt = $pdo->prepare("SELECT * FROM plant_diary WHERE plant_id = ? AND user_id = ? ORDER BY entry_date DESC");
$stmt->execute([$_GET['plant_id'], $currentUserId]);
echo json_encode($stmt->fetchAll());
break;
case 'get_user_settings':
$stmt = $pdo->prepare("SELECT username, email, profile_image, theme, allow_photo_sharing, ai_credits, auto_fill_watering, auto_fill_fertilizing, auto_fill_care_tips FROM users WHERE id = ?");
$stmt->execute([$currentUserId]);
$userSettings = $stmt->fetch();
$stmt_count = $pdo->prepare("SELECT COUNT(id) as plant_count FROM plants WHERE user_id = ?");
$stmt_count->execute([$currentUserId]);
$userSettings['plant_count'] = $stmt_count->fetchColumn();
echo json_encode($userSettings);
break;
case 'get_lexicon_entries':
$stmt = $pdo->prepare("SELECT pl.name, pl.latin_name, lp.photo_path as photo_url FROM plant_lexicon pl LEFT JOIN (SELECT plant_latin_name, photo_path, ROW_NUMBER() OVER(PARTITION BY plant_latin_name ORDER BY ai_photo_quality_score DESC, id DESC) as rn FROM lexicon_photos WHERE status = 'approved') lp ON pl.latin_name = lp.plant_latin_name AND lp.rn = 1 WHERE pl.entry_count > 0 ORDER BY pl.name ASC");
$stmt->execute();
echo json_encode($stmt->fetchAll());
break;
case 'get_lexicon_entry_details':
$latinName = $_GET['latin_name'] ?? '';
if (empty($latinName)) throw new Exception('Kein botanischer Name angegeben.', 400);
$stmt = $pdo->prepare("SELECT * FROM plant_lexicon WHERE latin_name = ?");
$stmt->execute([$latinName]);
$plantDetails = $stmt->fetch();
if (!$plantDetails) throw new Exception('Pflanze nicht im Lexikon gefunden.', 404);
$stmtPhotos = $pdo->prepare("SELECT photo_path FROM lexicon_photos WHERE plant_latin_name = ? AND status = 'approved' ORDER BY ai_photo_quality_score DESC, id DESC LIMIT 10");
$stmtPhotos->execute([$latinName]);
$plantDetails['photo_gallery'] = $stmtPhotos->fetchAll(PDO::FETCH_COLUMN);
echo json_encode($plantDetails);
break;
default:
throw new Exception('Ungültige GET-Aktion.', 400);
}
break;
case 'POST':
$action = $_POST['action'] ?? 'save_plant';
switch ($action) {
case 'add_diary_entry':
$photoPath = isset($_FILES['photo']) ? processUploadedPhoto($_FILES['photo'], 600) : null;
$stmt = $pdo->prepare("INSERT INTO plant_diary (plant_id, user_id, notes, photo, entry_type) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$_POST['plant_id'], $currentUserId, $_POST['notes'] ?? null, $photoPath, isset($_POST['is_milestone']) ? 'milestone' : 'manual']);
$newEntryId = $pdo->lastInsertId();
$stmt = $pdo->prepare("SELECT * FROM plant_diary WHERE id = ?");
$stmt->execute([$newEntryId]);
echo json_encode(['success' => true, 'entry' => $stmt->fetch()]);
break;
case 'save_plant':
$name = trim($_POST['name'] ?? '');
if (empty($name)) throw new Exception('Der Name der Pflanze darf nicht leer sein.', 400);
$plantId = $_POST['id'] ?? null;
$latinName = trim($_POST['latin_name'] ?? '') ?: null;
$photoPath = isset($_FILES['photo']) ? processUploadedPhoto($_FILES['photo']) : null;
$finalPhotoPath = $photoPath ?? $_POST['current_photo'] ?? null;
// ROBUSTE PRÜFUNG DER INTERVALLE
$wateringInterval = (isset($_POST['watering_interval']) && is_numeric($_POST['watering_interval'])) ? (int)$_POST['watering_interval'] : null;
$fertilizingInterval = (isset($_POST['fertilizing_interval']) && is_numeric($_POST['fertilizing_interval'])) ? (int)$_POST['fertilizing_interval'] : null;
$careTips = trim($_POST['care_tips'] ?? '') ?: null;
$pdo->beginTransaction();
$oldLatinName = null;
if ($plantId) { // Edit
$stmtOld = $pdo->prepare("SELECT latin_name FROM plants WHERE id = ? AND user_id = ?");
$stmtOld->execute([$plantId, $currentUserId]);
$oldLatinName = $stmtOld->fetchColumn();
$stmt = $pdo->prepare("UPDATE plants SET name = ?, latin_name = ?, photo = ?, watering_interval_days = ?, fertilizing_interval_days = ?, care_tips = ? WHERE id = ? AND user_id = ?");
$stmt->execute([$name, $latinName, $finalPhotoPath, $wateringInterval, $fertilizingInterval, $careTips, $plantId, $currentUserId]);
} else { // Add
$stmt = $pdo->prepare("INSERT INTO plants (user_id, name, latin_name, photo, last_watered, last_fertilized, watering_interval_days, fertilizing_interval_days, care_tips) VALUES (?, ?, ?, ?, CURDATE(), CURDATE(), ?, ?, ?)");
$stmt->execute([$currentUserId, $name, $latinName, $finalPhotoPath, $wateringInterval, $fertilizingInterval, $careTips]);
$plantId = $pdo->lastInsertId();
$stmt = $pdo->prepare("INSERT INTO plant_diary (plant_id, user_id, entry_type, notes) VALUES (?, ?, 'milestone', 'Pflanze hinzugefügt')");
$stmt->execute([$plantId, $currentUserId]);
}
// Handle lexicon entry count changes
if ($oldLatinName && $oldLatinName !== $latinName) {
$stmtDec = $pdo->prepare("UPDATE plant_lexicon SET entry_count = entry_count - 1 WHERE latin_name = ? AND entry_count > 0");
$stmtDec->execute([$oldLatinName]);
}
if ($latinName && (!$oldLatinName || $oldLatinName !== $latinName)) {
$stmt = $pdo->prepare("INSERT INTO plant_lexicon (latin_name, name) VALUES (?, ?) ON DUPLICATE KEY UPDATE entry_count = entry_count + 1, name = VALUES(name)");
$stmt->execute([$latinName, $name]);
}
// Process AI data for lexicon if available
$enrichmentData = json_decode($_POST['ai_enrichment_json'] ?? '{}', true);
if(!empty($enrichmentData)) {
$stmt = $pdo->prepare("UPDATE plant_lexicon SET plant_family = :plant_family, plant_genus = :plant_genus, description_full = :description_full, care_tips = :care_tips, watering_interval_days = :watering_interval_days, fertilizing_interval_days = :fertilizing_interval_days, light_requirement = :light_requirement, water_requirement = :water_requirement, humidity_requirement = :humidity_requirement, care_difficulty = :care_difficulty, growth_habit = :growth_habit, mature_size = :mature_size, toxicity = :toxicity, edibility = :edibility, temperature_preference = :temperature_preference, flowering_period = :flowering_period, substrate_recommendation = :substrate_recommendation, native_to = :native_to, tags = :tags, updated_at = NOW() WHERE latin_name = :latin_name AND description_full IS NULL");
$stmt->execute([
'plant_family' => $enrichmentData['plant_family'] ?? null,
'plant_genus' => $enrichmentData['plant_genus'] ?? null,
'description_full' => $enrichmentData['description_full'] ?? null,
'care_tips' => $enrichmentData['care_tips'] ?? null,
'watering_interval_days' => $enrichmentData['watering_interval_days'] ?? null,
'fertilizing_interval_days' => $enrichmentData['fertilizing_interval_days'] ?? null,
'light_requirement' => $enrichmentData['light_requirement'] ?? null,
'water_requirement' => $enrichmentData['water_requirement'] ?? null,
'humidity_requirement' => $enrichmentData['humidity_requirement'] ?? null,
'care_difficulty' => $enrichmentData['care_difficulty'] ?? null,
'growth_habit' => $enrichmentData['growth_habit'] ?? null,
'mature_size' => $enrichmentData['mature_size'] ?? null,
'toxicity' => $enrichmentData['toxicity'] ?? null,
'edibility' => $enrichmentData['edibility'] ?? null,
'temperature_preference' => $enrichmentData['temperature_preference'] ?? null,
'flowering_period' => $enrichmentData['flowering_period'] ?? null,
'substrate_recommendation' => $enrichmentData['substrate_recommendation'] ?? null,
'native_to' => $enrichmentData['native_to'] ?? null,
'tags' => $enrichmentData['tags'] ?? null,
'latin_name' => $latinName
]);
}
// Process photo for lexicon if allowed and available
$fullAiResponse = json_decode($_POST['ai_full_response_json'] ?? '{}', true);
$stmtAllow = $pdo->prepare("SELECT allow_photo_sharing FROM users WHERE id = ?");
$stmtAllow->execute([$currentUserId]);
if ($stmtAllow->fetchColumn() && $finalPhotoPath && $photoPath && !empty($fullAiResponse['photo_analysis'])) {
$photoData = $fullAiResponse['photo_analysis'];
$isHighQuality = $photoData['is_high_quality'] ?? false;
$stmt = $pdo->prepare("INSERT INTO lexicon_photos (user_id, plant_latin_name, photo_path, status, rejection_reason, ai_confidence, photo_type, focal_point_x, focal_point_y, dominant_color_hex, ai_photo_quality_score) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
$stmt->execute([
$currentUserId,
$latinName,
$finalPhotoPath,
($isHighQuality ? 'approved' : 'rejected'),
$photoData['rejection_reason'] ?? null,
$fullAiResponse['identification']['confidence_score'] ?? null,
$photoData['photo_type'] ?? null,
$photoData['focal_point']['x'] ?? null,
$photoData['focal_point']['y'] ?? null,
$photoData['dominant_color_hex'] ?? null,
$photoData['photo_quality_score'] ?? null
]);
}
$pdo->commit();
echo json_encode(['success' => true, 'plant' => getPlantWithDueDates($pdo, $plantId, $currentUserId)]);
break;
default:
throw new Exception('Ungültige Formular-Aktion.', 400);
}
break;
case 'DELETE':
$plantId = $_GET['id'] ?? null;
if ($plantId) {
$pdo->beginTransaction();
$stmtLatin = $pdo->prepare("SELECT latin_name FROM plants WHERE id = ? AND user_id = ?");
$stmtLatin->execute([$plantId, $currentUserId]);
$latinNameToDecrement = $stmtLatin->fetchColumn();
$stmt = $pdo->prepare("DELETE FROM plants WHERE id = ? AND user_id = ?");
$stmt->execute([$plantId, $currentUserId]);
if ($latinNameToDecrement) {
$stmtDec = $pdo->prepare("UPDATE plant_lexicon SET entry_count = entry_count - 1 WHERE latin_name = ? AND entry_count > 0");
$stmtDec->execute([$latinNameToDecrement]);
}
$pdo->commit();
echo json_encode(['success' => true]);
} else if (isset($_GET['diary_entry_id'])) {
$entryId = $_GET['diary_entry_id'];
$stmt = $pdo->prepare("DELETE FROM plant_diary WHERE id = ? AND user_id = ?");
$stmt->execute([$entryId, $currentUserId]);
echo json_encode(['success' => $stmt->rowCount() > 0]);
}
else {
throw new Exception('Keine ID zum Löschen angegeben.', 400);
}
break;
default:
throw new Exception('Methode nicht erlaubt.', 405);
}
} catch (Exception $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
$code = $e->getCode();
if (!is_int($code) || $code < 400 || $code >= 600) {
$code = 500;
}
http_response_code($code);
echo json_encode(['error' => $e->getMessage()]);
}
?>

60
plants/api_proxy.php Normal file
View file

@ -0,0 +1,60 @@
<?php
$current_project = 'plants';
require_once __DIR__ . '/../auth.php';
// Lade die Abhängigkeiten und die .env-Variablen
require_once __DIR__ . '/vendor/autoload.php';
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->load();
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Nur POST-Anfragen sind erlaubt.']);
exit;
}
// Dein geheimer API-Schlüssel wird sicher aus der .env-Datei geladen
$apiKey = $_ENV['GEMINI_API_KEY'];
$json_input = file_get_contents('php://input');
$request_data = json_decode($json_input, true);
if (json_last_error() !== JSON_ERROR_NONE || !isset($request_data['payload'])) {
http_response_code(400);
echo json_encode(['error' => 'Ungültige Anfrage-Daten.']);
exit;
}
$payload = $request_data['payload'];
// Die Ziel-URL ist jetzt fest im Code hinterlegt und kann nicht von außen manipuliert werden.
$googleApiUrl = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent';
$fullApiUrl = $googleApiUrl . '?key=' . $apiKey;
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $fullApiUrl,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
$response_body = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch)) {
http_response_code(500);
echo json_encode(['error' => 'Fehler bei der Weiterleitung: ' . curl_error($ch)]);
exit;
}
curl_close($ch);
http_response_code($http_code);
echo $response_body;
?>

117
plants/auth.js Normal file
View file

@ -0,0 +1,117 @@
// Version: 24.09.2025 16:29
// === DOM-Elemente ===
const loginFormContainer = document.getElementById('login-form-container');
const registerFormContainer = document.getElementById('register-form-container');
const showRegisterLink = document.getElementById('showRegister');
const showLoginLink = document.getElementById('showLogin');
const registerForm = document.getElementById('registerForm');
const loginForm = document.getElementById('loginForm');
const AUTH_API_URL = 'auth_api.php';
// === Toast-Funktion (isoliert für diese Seite) ===
function showToast(message, isError = false) {
const existingToast = document.querySelector('.toast');
if (existingToast) existingToast.remove();
const toast = document.createElement('div');
toast.className = `toast ${isError ? 'error' : ''}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.classList.add('show'), 10);
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => document.body.removeChild(toast), 300);
}, 3000);
}
// === Event Handlers ===
function switchForms(e) {
e.preventDefault();
if (loginFormContainer.style.display === 'none') {
registerFormContainer.style.display = 'none';
loginFormContainer.style.display = 'block';
} else {
loginFormContainer.style.display = 'none';
registerFormContainer.style.display = 'block';
}
}
async function handleRegister(e) {
e.preventDefault();
const submitBtn = registerForm.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.textContent = 'Erstelle Konto...';
const formData = new FormData(registerForm);
const data = Object.fromEntries(formData.entries());
data.action = 'register';
try {
const response = await fetch(AUTH_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const result = await response.json();
if (!response.ok) throw new Error(result.error);
showToast(result.message || 'Registrierung erfolgreich!');
registerForm.reset();
switchForms(e); // Zum Login wechseln
} catch (error) {
showToast(error.message, true);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Konto erstellen';
}
}
async function handleLogin(e) {
e.preventDefault();
const submitBtn = loginForm.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.textContent = 'Logge ein...';
const formData = new FormData(loginForm);
const data = Object.fromEntries(formData.entries());
data.action = 'login';
try {
const response = await fetch(AUTH_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const result = await response.json();
if (!response.ok) throw new Error(result.error);
// Login war erfolgreich, lade die Seite neu
// Der Server wird uns jetzt die Pflanzen-Ansicht schicken
location.reload();
} catch(error) {
showToast(error.message, true);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Einloggen';
}
}
// === Initialisierung ===
function init() {
showRegisterLink.addEventListener('click', switchForms);
showLoginLink.addEventListener('click', switchForms);
registerForm.addEventListener('submit', handleRegister);
loginForm.addEventListener('submit', handleLogin);
}
document.addEventListener('DOMContentLoaded', init);

86
plants/auth_api.php Normal file
View file

@ -0,0 +1,86 @@
<?php
// Version: 24.09.2025 16:29
require_once 'db.php'; // Stellt die $pdo-Verbindung her
// Eine sichere Methode, um eine Session zu starten
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
header('Content-Type: application/json');
$data = json_decode(file_get_contents('php://input'), true);
$action = $data['action'] ?? $_GET['action'] ?? null;
// ============================
// === BEARBEITUNG DER ANFRAGEN ===
// ============================
try {
if ($action === 'register') {
// --- Registrierung ---
$username = $data['username'] ?? '';
$email = $data['email'] ?? '';
$password = $data['password'] ?? '';
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new Exception('Bitte gib eine gültige E-Mail-Adresse ein.');
}
if (strlen($password) < 8) {
throw new Exception('Das Passwort muss mindestens 8 Zeichen lang sein.');
}
if (empty($username)) {
throw new Exception('Bitte gib einen Namen ein.');
}
$stmt = $pdo->prepare("SELECT id FROM users WHERE email = ?");
$stmt->execute([$email]);
if ($stmt->fetch()) {
throw new Exception('Diese E-Mail-Adresse ist bereits registriert.');
}
$password_hash = password_hash($password, PASSWORD_ARGON2ID);
$stmt = $pdo->prepare("INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)");
$stmt->execute([$username, $email, $password_hash]);
echo json_encode(['success' => true, 'message' => 'Konto erfolgreich erstellt! Du kannst dich jetzt einloggen.']);
} elseif ($action === 'login') {
// --- Login ---
$email = $data['email'] ?? '';
$password = $data['password'] ?? '';
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || empty($password)) {
throw new Exception('Bitte gib E-Mail und Passwort ein.');
}
$stmt = $pdo->prepare("SELECT id, username, password_hash FROM users WHERE email = ?");
$stmt->execute([$email]);
$user = $stmt->fetch();
if ($user && password_verify($password, $user['password_hash'])) {
// Passwort ist korrekt, starte die Session
session_regenerate_id(true); // Wichtig für die Sicherheit
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
echo json_encode(['success' => true, 'message' => 'Login erfolgreich!']);
} else {
throw new Exception('E-Mail oder Passwort ist falsch.');
}
} elseif ($action === 'logout') {
// --- Logout ---
session_destroy();
echo json_encode(['success' => true, 'message' => 'Du wurdest ausgeloggt.']);
} else {
throw new Exception('Ungültige Aktion.');
}
} catch (Exception $e) {
http_response_code(400); // Bad Request
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
?>

761
plants/components.css Normal file
View file

@ -0,0 +1,761 @@
/* Version: 30.09.2025 08:00 */
/* =============================================== */
/* === PLANT CARDS (GRID VIEW) === */
/* =============================================== */
.plant-card {
background-color: transparent;
width: 100%;
aspect-ratio: 1 / 1.35;
perspective: 1000px;
opacity: 0;
transform: translateY(20px);
animation: fadeIn 0.5s ease-out forwards;
}
.list-view .plant-card {
display: none;
}
.plant-card.menu-open {
position: relative;
z-index: 10;
}
.plant-card-inner {
position: relative;
width: 100%;
height: 100%;
transition: transform 0.6s, box-shadow 0.3s ease;
transform-style: preserve-3d;
border-radius: var(--border-radius-large);
border: 1px solid var(--border-color);
box-shadow: 0 4px 10px var(--shadow-color);
}
.plant-card:hover .plant-card-inner {
box-shadow: 0 8px 20px var(--shadow-color-hover);
}
.plant-card-inner.is-flipped {
transform: rotateY(180deg);
}
.plant-card-front, .plant-card-back {
position: absolute;
width: 100%;
height: 100%;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
background-color: var(--surface-color);
border-radius: var(--border-radius-large);
overflow: hidden;
display: flex;
flex-direction: column;
}
.plant-card-back {
transform: rotateY(180deg);
padding: 25px 20px 20px;
justify-content: flex-start;
cursor: pointer;
}
.plant-img-container {
width: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
position: relative;
}
.plant-img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.plant-name-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0,0,0,0.6), transparent);
padding: 20px 15px 15px;
}
.plant-name-overlay h3 {
margin: 0;
color: white;
font-size: 1.5rem;
text-shadow: 1px 1px 3px rgba(0,0,0,0.5);
}
.plant-info {
padding: 15px;
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.card-actions {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}
.action-btn {
width: 100%;
height: auto;
background-color: transparent;
border: 1px solid var(--border-color);
border-radius: 10px;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
padding: 10px 12px;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.2s, border-color 0.2s;
}
.action-btn:hover {
background-color: #fafafa;
border-color: #e0e0e0;
}
.action-btn:active {
background-color: #f5f5f5;
transform: none !important;
}
.action-btn-progress {
position: absolute;
bottom: 0;
left: 0;
height: 4px;
border-radius: 0;
transition: width 0.4s ease, background-color 0.4s ease;
}
.action-btn-content {
position: relative;
z-index: 1;
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
font-weight: 600;
pointer-events: none;
color: var(--text-color);
padding: 0;
}
.action-btn.simple {
background-color: transparent;
border-color: var(--border-color);
}
.action-btn.simple:hover {
background-color: #f5f5f5;
}
.action-btn.simple .action-btn-content {
justify-content: flex-start;
color: var(--text-color);
}
.action-btn.simple .care-status,
.action-btn.simple .action-btn-progress {
display: none;
}
.action-btn-content .care-status {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-light);
background-color: transparent;
padding: 0;
}
.water-btn .action-btn-progress[data-status="green"],
.water-btn .action-btn-progress[data-status="yellow"] {
background-color: var(--status-blue);
}
.fertilize-btn .action-btn-progress[data-status="green"] {
background-color: var(--secondary-color);
}
.action-btn-progress[data-status="yellow"] {
background-color: var(--status-yellow);
}
.action-btn-progress[data-status="red"] {
background-color: var(--status-red);
}
.action-btn-progress[data-status="disabled"] {
background-color: #e0e0e0;
}
.action-btn:disabled {
cursor: wait;
}
.action-btn.is-loading .action-btn-content > span:first-child {
display: none;
}
.action-btn.is-loading .care-status {
animation: pulse 1.5s infinite ease-in-out;
}
.plant-card-back h3 {
margin-top: 0;
margin-bottom: 15px;
color: var(--primary-color);
}
.care-tips-content {
flex-grow: 1;
overflow-y: auto;
font-size: 0.85rem;
line-height: 1.5;
padding-right: 5px;
}
.care-tips-content p {
margin: 0 0 1em 0;
}
.care-tips-content p:last-child {
margin-bottom: 0;
}
.care-tips-content strong {
color: var(--primary-color);
font-weight: 700;
}
.care-tips-latin-name {
background-color: #f8f8f8;
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 15px;
font-size: 0.9em;
border: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: flex-start; /* HIER IST DIE ÄNDERUNG */
cursor: pointer;
transition: background-color 0.2s, box-shadow 0.2s;
}
.care-tips-latin-name:hover {
background-color: #fefefe;
box-shadow: 0 2px 8px var(--shadow-color);
}
.care-tips-latin-name strong {
color: var(--primary-color);
display: block;
font-weight: 600;
font-size: 0.9em;
margin-bottom: 2px;
}
.care-tips-latin-name em {
font-style: italic;
color: var(--text-color);
}
.latin-name-hint {
font-size: 0.8em;
font-weight: 600;
color: var(--text-light);
transition: color 0.2s;
flex-shrink: 0;
padding-left: 10px;
}
.care-tips-latin-name:hover .latin-name-hint {
color: var(--text-color);
}
.back-controls {
margin-top: auto;
padding-top: 15px;
text-align: center;
color: var(--text-light);
font-size: 0.85rem;
flex-shrink: 0;
}
.back-controls svg {
vertical-align: middle;
margin-right: 5px;
}
.card-overlay {
position: absolute;
top: 10px;
right: 10px;
display: flex;
gap: 8px;
z-index: 5;
}
.card-btn {
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background-color: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(5px);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-color);
transition: background-color 0.2s, transform 0.2s;
}
.card-btn:hover {
background-color: white;
transform: scale(1.1);
}
.card-btn:active {
transform: scale(1);
}
.menu-container {
position: relative;
}
.menu-dropdown {
display: none;
position: absolute;
top: 40px;
right: 0;
background: white;
border-radius: var(--border-radius);
box-shadow: 0 4px 15px var(--shadow-color);
overflow: hidden;
z-index: 10;
width: 150px;
}
.menu-dropdown.show {
display: block;
}
.menu-dropdown button {
display: block;
width: 100%;
padding: 12px 15px;
background: none;
border: none;
text-align: left;
cursor: pointer;
font-size: 0.95rem;
color: var(--text-color);
}
.menu-dropdown button:hover {
background-color: #f5f5f5;
}
.menu-dropdown .delete-btn:hover {
background-color: var(--status-red);
color: white;
}
/* =============================================== */
/* === LIST VIEW === */
/* =============================================== */
.plant-list-item {
display: none;
}
.list-view .plant-list-item {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 15px;
background-color: var(--surface-color);
padding: 12px;
border-radius: var(--border-radius-large);
border: 1px solid var(--border-color);
opacity: 0;
transform: translateY(20px);
animation: fadeIn 0.5s ease-out forwards;
width: 100%;
transition: background-color 0.3s ease;
}
.plant-list-item.menu-open {
position: relative;
z-index: 10;
}
.plant-list-item.tips-visible {
background-color: #f9f9f9;
}
.list-item-img-container {
width: 70px;
height: 70px;
flex-shrink: 0;
border-radius: var(--border-radius);
overflow: hidden;
}
.list-item-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.list-item-info {
flex-grow: 1;
}
.list-item-info h3 {
margin: 0 0 4px 0;
font-size: 1.2rem;
}
.list-item-actions {
display: flex;
gap: 10px;
align-items: center;
}
.list-item-actions .action-btn {
min-width: 165px;
}
.list-item-menu {
display: flex;
gap: 8px;
}
.list-item-care-tips {
max-height: 0;
overflow: hidden;
transition: max-height 0.4s ease-out;
width: 100%;
font-size: 0.85rem;
box-sizing: border-box;
color: var(--text-color);
grid-column: 1 / -1;
}
.list-item-care-tips-content {
padding: 0;
border-top: 1px solid transparent;
transition: padding 0.4s ease-out, border-color 0.4s ease-out;
}
.list-item-care-tips-content p {
margin: 0 0 1em 0;
}
.list-item-care-tips-content p:last-child {
margin-bottom: 0;
}
.list-item-care-tips-content strong {
color: var(--primary-color);
font-weight: 700;
}
.plant-list-item.tips-visible .list-item-care-tips {
max-height: 500px;
}
.plant-list-item.tips-visible .list-item-care-tips-content {
padding: 15px 0 5px;
margin-top: 10px;
border-top: 1px solid var(--border-color);
}
/* =============================================== */
/* === MOBILE & RESPONSIVE STYLES === */
/* =============================================== */
.plant-compact-card {
display: none;
}
.plant-detail-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(30, 32, 30, 0.6);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
z-index: 1100;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
}
.plant-detail-overlay.show {
opacity: 1;
visibility: visible;
}
.plant-detail-content {
position: relative;
width: 100%;
max-width: 380px;
transform: scale(0.95);
transition: transform 0.3s ease;
}
.plant-detail-content .plant-card {
display: block;
opacity: 1;
transform: none;
}
.plant-detail-overlay.show .plant-detail-content {
transform: scale(1);
}
.plant-detail-close-btn {
position: absolute;
top: 15px;
right: 15px;
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background-color: rgba(255, 255, 255, 0.9);
color: var(--text-color);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
z-index: 1101;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
display: flex;
align-items: center;
justify-content: center;
}
@media (max-width: 768px) {
.add-btn span {
display: none;
}
.user-menu .username {
display: none;
}
.list-view .plant-list-item {
display: grid;
}
.list-view .plant-card {
display: none;
}
.list-view .plant-list-item {
grid-template-columns: 60px 1fr;
grid-template-rows: auto auto;
gap: 2px 12px;
align-items: center;
position: relative;
padding: 12px;
}
.list-view .list-item-img-container {
grid-row: 1 / 3;
width: 60px;
height: 60px;
}
.list-view .list-item-info {
grid-column: 2;
grid-row: 1;
align-self: end;
}
.list-view .list-item-info h3 {
font-size: 1.1rem;
margin-bottom: 2px;
}
.list-view .list-item-actions {
grid-column: 2;
grid-row: 2;
align-self: start;
flex-direction: row;
gap: 8px;
width: 100%;
}
.list-view .list-item-actions .action-btn {
flex: 1;
min-width: 0;
padding: 6px 8px;
height: auto;
border-radius: 8px;
}
.list-view .list-item-actions .action-btn-progress {
top: 0;
bottom: 0;
height: 100%;
opacity: 0.15;
}
.list-view .list-item-actions .action-btn-content span:first-child {
font-size: 0.8rem;
}
.list-view .list-item-actions .action-btn-content .care-status {
font-size: 0.75rem;
}
.list-view .list-item-menu {
position: absolute;
top: 8px;
right: 8px;
}
.compact-view-enabled.plant-grid:not(.list-view) {
grid-template-columns: repeat(3, 1fr);
gap: 15px;
align-items: start;
}
.compact-view-enabled:not(.list-view) .plant-list-item,
.compact-view-enabled:not(.list-view) .plant-card {
display: none;
}
.compact-view-enabled:not(.list-view) .plant-compact-card {
display: flex;
flex-direction: column;
width: 100%;
background-color: var(--surface-color);
border-radius: var(--border-radius);
overflow: hidden;
position: relative;
box-shadow: 0 2px 6px var(--shadow-color);
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
-webkit-tap-highlight-color: transparent;
opacity: 0;
transform: translateY(20px);
animation: fadeIn 0.5s ease-out forwards;
}
.plant-compact-card:active {
transform: scale(0.96);
box-shadow: 0 1px 3px var(--shadow-color);
}
.compact-img-container {
width: 100%;
aspect-ratio: 1 / 1;
position: relative;
flex-shrink: 0;
overflow: hidden;
}
.compact-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.compact-name-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0,0,0,0.7), transparent);
padding: 15px 8px 8px;
color: white;
text-align: center;
font-size: 0.8rem;
font-weight: 600;
pointer-events: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.compact-card-actions {
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
border-top: 1px solid var(--border-color);
flex-shrink: 0;
container-type: inline-size;
container-name: compact-actions;
}
.compact-action-pill {
width: 100%;
background-color: transparent;
border: 1px solid var(--border-color);
border-radius: 8px;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
padding: 5px 8px;
font-size: 0.8rem;
}
.compact-action-progress {
position: absolute;
top: 0;
bottom: 0;
left: 0;
opacity: 0.2;
transition: width 0.4s ease, background-color 0.4s ease;
}
.compact-action-content {
position: relative;
z-index: 1;
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
font-weight: 600;
pointer-events: none;
color: var(--text-color);
}
.action-label {
display: flex;
align-items: center;
gap: 4px;
}
.action-emoji {
display: none;
font-size: 0.9rem;
}
.compact-action-content .care-status {
font-size: 0.75rem;
color: var(--text-light);
}
.compact-action-progress[data-status="green"] {
background-color: var(--status-blue);
}
.compact-action-pill[data-action="fertilize"] .compact-action-progress[data-status="green"] {
background-color: var(--secondary-color);
}
.compact-action-progress[data-status="yellow"] {
background-color: var(--status-yellow);
}
.compact-action-progress[data-status="red"] {
background-color: var(--status-red);
}
.compact-action-progress[data-status="disabled"] {
background-color: #e0e0e0;
}
}
@container compact-actions (max-width: 110px) {
.compact-action-pill .action-text {
display: none;
}
.compact-action-pill .action-emoji {
display: inline;
}
.compact-action-content {
justify-content: space-between;
}
}
/* =============================================== */
/* === DARK MODE OVERRIDES === */
/* =============================================== */
/* --- Manually selected Dark Mode --- */
body[data-theme='dark'] .action-btn:hover {
background-color: #2a2a2a;
border-color: #444;
}
body[data-theme='dark'] .card-btn {
background-color: rgba(0, 0, 0, 0.3);
color: var(--text-color);
}
body[data-theme='dark'] .card-btn:hover {
background-color: rgba(0, 0, 0, 0.5);
}
body[data-theme='dark'] .menu-dropdown {
background: #2c2c2c;
box-shadow: 0 4px 15px var(--shadow-color);
}
body[data-theme='dark'] .menu-dropdown button:hover {
background-color: #3a3a3a;
}
body[data-theme='dark'] .menu-dropdown .delete-btn:hover {
background-color: var(--status-red);
color: white;
}
body[data-theme='dark'] .care-tips-latin-name {
background-color: #2a2a2a;
}
body[data-theme='dark'] .plant-list-item.tips-visible {
background-color: #252525;
}
body[data-theme='dark'] .plant-detail-close-btn {
background-color: rgba(40, 40, 40, 0.8);
color: var(--text-color);
}
/* --- Auto Dark Mode (from System) --- */
@media (prefers-color-scheme: dark) {
body[data-theme='auto'] .action-btn:hover {
background-color: #2a2a2a;
border-color: #444;
}
body[data-theme='auto'] .card-btn {
background-color: rgba(0, 0, 0, 0.3);
color: var(--text-color);
}
body[data-theme='auto'] .card-btn:hover {
background-color: rgba(0, 0, 0, 0.5);
}
body[data-theme='auto'] .menu-dropdown {
background: #2c2c2c;
box-shadow: 0 4px 15px var(--shadow-color);
}
body[data-theme='auto'] .menu-dropdown button:hover {
background-color: #3a3a3a;
}
body[data-theme='auto'] .menu-dropdown .delete-btn:hover {
background-color: var(--status-red);
color: white;
}
body[data-theme='auto'] .care-tips-latin-name {
background-color: #2a2a2a;
}
body[data-theme='auto'] .plant-list-item.tips-visible {
background-color: #252525;
}
body[data-theme='auto'] .plant-detail-close-btn {
background-color: rgba(40, 40, 40, 0.8);
color: var(--text-color);
}
}

5
plants/composer.json Normal file
View file

@ -0,0 +1,5 @@
{
"require": {
"vlucas/phpdotenv": "^5.6"
}
}

492
plants/composer.lock generated Normal file
View file

@ -0,0 +1,492 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "108be68e4e2b97fed51d36a10eed0849",
"packages": [
{
"name": "graham-campbell/result-type",
"version": "v1.1.3",
"source": {
"type": "git",
"url": "https://github.com/GrahamCampbell/Result-Type.git",
"reference": "3ba905c11371512af9d9bdd27d99b782216b6945"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/3ba905c11371512af9d9bdd27d99b782216b6945",
"reference": "3ba905c11371512af9d9bdd27d99b782216b6945",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0",
"phpoption/phpoption": "^1.9.3"
},
"require-dev": {
"phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28"
},
"type": "library",
"autoload": {
"psr-4": {
"GrahamCampbell\\ResultType\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
}
],
"description": "An Implementation Of The Result Type",
"keywords": [
"Graham Campbell",
"GrahamCampbell",
"Result Type",
"Result-Type",
"result"
],
"support": {
"issues": "https://github.com/GrahamCampbell/Result-Type/issues",
"source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.3"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type",
"type": "tidelift"
}
],
"time": "2024-07-20T21:45:45+00:00"
},
{
"name": "phpoption/phpoption",
"version": "1.9.4",
"source": {
"type": "git",
"url": "https://github.com/schmittjoh/php-option.git",
"reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/schmittjoh/php-option/zipball/638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d",
"reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34"
},
"type": "library",
"extra": {
"bamarni-bin": {
"bin-links": true,
"forward-command": false
},
"branch-alias": {
"dev-master": "1.9-dev"
}
},
"autoload": {
"psr-4": {
"PhpOption\\": "src/PhpOption/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Johannes M. Schmitt",
"email": "schmittjoh@gmail.com",
"homepage": "https://github.com/schmittjoh"
},
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
}
],
"description": "Option Type for PHP",
"keywords": [
"language",
"option",
"php",
"type"
],
"support": {
"issues": "https://github.com/schmittjoh/php-option/issues",
"source": "https://github.com/schmittjoh/php-option/tree/1.9.4"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption",
"type": "tidelift"
}
],
"time": "2025-08-21T11:53:16+00:00"
},
{
"name": "symfony/polyfill-ctype",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
"reference": "a3cc8b044a6ea513310cbd48ef7333b384945638"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638",
"reference": "a3cc8b044a6ea513310cbd48ef7333b384945638",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"provide": {
"ext-ctype": "*"
},
"suggest": {
"ext-ctype": "For best performance"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Ctype\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Gert de Pagter",
"email": "BackEndTea@gmail.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for ctype functions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"ctype",
"polyfill",
"portable"
],
"support": {
"source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-09-09T11:45:10+00:00"
},
{
"name": "symfony/polyfill-mbstring",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
"reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493",
"reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493",
"shasum": ""
},
"require": {
"ext-iconv": "*",
"php": ">=7.2"
},
"provide": {
"ext-mbstring": "*"
},
"suggest": {
"ext-mbstring": "For best performance"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Mbstring\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for the Mbstring extension",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"mbstring",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-12-23T08:48:59+00:00"
},
{
"name": "symfony/polyfill-php80",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php80.git",
"reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608",
"reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Php80\\": ""
},
"classmap": [
"Resources/stubs"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ion Bazan",
"email": "ion.bazan@gmail.com"
},
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-01-02T08:10:11+00:00"
},
{
"name": "vlucas/phpdotenv",
"version": "v5.6.2",
"source": {
"type": "git",
"url": "https://github.com/vlucas/phpdotenv.git",
"reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/24ac4c74f91ee2c193fa1aaa5c249cb0822809af",
"reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af",
"shasum": ""
},
"require": {
"ext-pcre": "*",
"graham-campbell/result-type": "^1.1.3",
"php": "^7.2.5 || ^8.0",
"phpoption/phpoption": "^1.9.3",
"symfony/polyfill-ctype": "^1.24",
"symfony/polyfill-mbstring": "^1.24",
"symfony/polyfill-php80": "^1.24"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"ext-filter": "*",
"phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2"
},
"suggest": {
"ext-filter": "Required to use the boolean validator."
},
"type": "library",
"extra": {
"bamarni-bin": {
"bin-links": true,
"forward-command": false
},
"branch-alias": {
"dev-master": "5.6-dev"
}
},
"autoload": {
"psr-4": {
"Dotenv\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Vance Lucas",
"email": "vance@vancelucas.com",
"homepage": "https://github.com/vlucas"
}
],
"description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.",
"keywords": [
"dotenv",
"env",
"environment"
],
"support": {
"issues": "https://github.com/vlucas/phpdotenv/issues",
"source": "https://github.com/vlucas/phpdotenv/tree/v5.6.2"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv",
"type": "tidelift"
}
],
"time": "2025-04-30T23:37:27+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {},
"platform-dev": {},
"plugin-api-version": "2.6.0"
}

34
plants/db.php Normal file
View file

@ -0,0 +1,34 @@
<?php
// Version: 24.09.2025 14:23
// Lade die Abhängigkeiten, die mit Composer installiert wurden
require_once __DIR__ . '/vendor/autoload.php';
// Initialisiere Dotenv, um die .env-Datei aus dem Hauptverzeichnis zu laden
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->load();
// Datenbankverbindung herstellen
$host = 'localhost';
$dbname = 'cmwrbwr55_philidb';
$username = 'cmwrbwr55_philidb';
// Das Passwort wird jetzt sicher aus der Umgebungsvariable geladen
$password = $_ENV['DB_PASSWORD'];
$charset = 'utf8mb4';
$dsn = "mysql:host=$host;dbname=$dbname;charset=$charset";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$pdo = new PDO($dsn, $username, $password, $options);
} catch (\PDOException $e) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode(['error' => 'Datenbankverbindung fehlgeschlagen.']);
exit;
}
?>

BIN
plants/img/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
plants/img/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

341
plants/index.php Normal file
View file

@ -0,0 +1,341 @@
<?php
// Version: 01.10.2025 16:15 (FINAL & COMPLETE)
$current_project = 'plants';
require_once '../auth.php';
session_start();
$is_logged_in = isset($_SESSION['user_id']);
$username = $_SESSION['username'] ?? '';
$user_theme = 'auto';
if ($is_logged_in) {
require_once 'db.php';
try {
$stmt = $pdo->prepare("SELECT theme FROM users WHERE id = ?");
$stmt->execute([$_SESSION['user_id']]);
$result = $stmt->fetch();
if ($result && !empty($result['theme'])) {
$user_theme = $result['theme'];
}
} catch (PDOException $e) {
// Fehler ignorieren
}
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Plants</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Alan+Sans:wght@300;400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="components.css">
</head>
<body data-theme="<?php echo htmlspecialchars($user_theme); ?>">
<?php if ($is_logged_in): ?>
<header class="main-header">
<div class="container">
<div class="logo-title-container">
<img src="img/logo.webp" alt="Plants App Logo" class="header-logo">
<h1>Plants</h1>
</div>
<div class="header-controls">
<a href="lexikon.php" id="lexiconBtn" class="add-btn icon-btn" title="Lexikon">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path></svg>
</a>
<div class="header-button-group">
<button id="addPlantBtn" class="add-btn">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
<span>Hinzufügen</span>
</button>
<button id="settingsBtn" class="add-btn icon-btn" title="Einstellungen">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
</button>
</div>
</div>
</div>
</header>
<main class="container">
<div id="dashboard-main-content">
<div class="dashboard-controls">
<div class="sort-container">
<select id="sortSelect">
<option value="date_desc">Neueste zuerst</option>
<option value="name_asc">Name (A-Z)</option>
<option value="watering_due">Gießen (bald fällig)</option>
</select>
</div>
<div class="view-toggle">
<button id="viewGridBtn" class="view-btn active" title="Grid-Ansicht">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect><rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect></svg>
</button>
<button id="viewListBtn" class="view-btn" title="Listen-Ansicht">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>
</button>
</div>
</div>
<div id="plant-dashboard" class="plant-grid">
<!-- Inhalt wird von main.js geladen -->
</div>
</div>
</main>
<!-- =========== MODALS =========== -->
<div id="plantModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="modalTitle">Neue Pflanze hinzufügen</h2>
<button class="close-btn">&times;</button>
</div>
<div id="formContent" class="modal-body">
<form id="plantForm" enctype="multipart/form-data">
<input type="hidden" id="plantId" name="id">
<input type="hidden" id="currentPhoto" name="current_photo">
<input type="hidden" id="aiFullResponse" name="ai_full_response_json">
<input type="hidden" id="aiEnrichmentResponse" name="ai_enrichment_json">
<div class="form-group-upload">
<div id="initialUploadArea" class="upload-area">
<button type="button" id="startCameraBtn" class="btn"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"></path><circle cx="12" cy="13" r="4"></circle></svg><span>Kamera</span></button>
<label for="plantPhoto" class="btn btn-secondary"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 15 9 5 19"></polyline></svg><span>Auswählen</span></label>
</div>
<div id="imagePreviewContainer" class="image-preview-wrapper" style="display: none;">
<img id="imagePreview" src="#" alt="Vorschau">
<div class="ai-animation-overlay">
<div class="ai-loader"></div>
<div class="ai-analysis-text">Analysiere...</div>
</div>
<div class="preview-actions">
<label for="plantPhoto" class="card-btn" title="Datei auswählen"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg></label>
<button type="button" id="retakePhotoBtn" class="card-btn" title="Neues Foto aufnehmen"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"></path><circle cx="12" cy="13" r="4"></circle></svg></button>
</div>
</div>
<input type="file" id="plantPhoto" name="photo" accept="image/*" style="display: none;">
</div>
<div id="ai-analysis-section" class="form-group" style="display: none;"><button type="button" id="analyzeBtn" class="btn btn-ai"><span role="img" aria-label="magic wand"></span><span>Automatisch erkennen</span></button></div>
<div class="form-group"><label for="plantName">Name der Pflanze</label><input type="text" id="plantName" name="name" required><button type="button" id="retryAiBtn" class="btn-retry" style="display: none;">Falsche Pflanze? Erneut versuchen.</button></div>
<div class="form-group"><label for="latinName">Botanischer Name (optional)</label><input type="text" id="latinName" name="latin_name"></div>
<div class="form-group"><label for="wateringInterval">Gießintervall (in Tagen)</label><input type="number" id="wateringInterval" name="watering_interval"></div>
<div class="form-group"><label for="fertilizingInterval">Düngeintervall (in Tagen)</label><input type="number" id="fertilizingInterval" name="fertilizing_interval"></div>
<div class="form-group"><label for="careTips">Pflegetipps</label><textarea id="careTips" name="care_tips" rows="4"></textarea></div>
<div class="form-actions"><button type="submit" class="btn">Pflanze speichern</button></div>
</form>
</div>
<div id="cameraView" class="modal-body" style="display: none;">
<div class="video-container"><video id="videoElement" autoplay></video><div class="viewfinder-overlay"><div class="viewfinder-corner top-left"></div><div class="viewfinder-corner top-right"></div><div class="viewfinder-corner bottom-left"></div><div class="viewfinder-corner bottom-right"></div><div class="viewfinder-focus-point"></div></div></div>
<div class="camera-controls"><button id="captureBtn" class="btn">Foto aufnehmen</button></div>
<canvas id="photoCanvas" style="display: none;"></canvas>
</div>
</div>
</div>
<div id="diaryModal" class="modal">
<div class="modal-content diary-modal-content">
<div class="modal-header"><h2 id="diaryPlantName">Tagebuch</h2><button class="close-btn">&times;</button></div>
<div id="diaryModalBody" class="modal-body"><div id="diaryContentWrapper"><div id="diaryContent" class="diary-timeline"></div></div><div id="diaryCameraView" style="display: none;"><div class="video-container"><video id="diaryVideoElement" autoplay></video><div class="viewfinder-overlay"><div class="viewfinder-corner top-left"></div><div class="viewfinder-corner top-right"></div><div class="viewfinder-corner bottom-left"></div><div class="viewfinder-corner bottom-right"></div><div class="viewfinder-focus-point"></div></div></div><div class="camera-controls"><button id="diaryCaptureBtn" class="btn">Foto aufnehmen</button></div><canvas id="diaryPhotoCanvas" style="display: none;"></canvas></div></div>
<div class="modal-footer"><form id="diaryForm" class="diary-form" enctype="multipart/form-data"><input type="hidden" id="diaryPlantId" name="plant_id"><div id="diaryFormContent"><div class="form-group"><textarea name="notes" class="diary-input" placeholder="Neue Notiz hinzufügen..." rows="2"></textarea></div><div class="diary-form-options"><div class="diary-form-attachments"><div id="diaryInitialUploadArea" class="upload-area small"><button type="button" id="startDiaryCameraBtn" class="btn"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"></path><circle cx="12" cy="13" r="4"></circle></svg></button><label for="diaryPhoto" class="btn btn-secondary"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 15 9 5 19"></polyline></svg></label></div><div id="diaryImagePreviewContainer" class="image-preview-wrapper small" style="display: none;"><img id="diaryImagePreview" src="#" alt="Vorschau"><button type="button" id="removeDiaryPhotoBtn" class="remove-photo-btn">&times;</button></div><input type="file" id="diaryPhoto" name="photo" accept="image/*" style="display:none;"></div><div class="diary-form-milestone"><input type="checkbox" id="is_milestone" name="is_milestone"><label for="is_milestone"> Meilenstein</label></div></div><button type="submit" class="btn">Eintragen</button></div></form></div>
</div>
</div>
<div id="settingsModal" class="modal">
<div class="modal-content settings-modal-content">
<div class="modal-header">
<h2>Einstellungen</h2>
<button class="close-btn">&times;</button>
</div>
<div class="modal-body">
<div class="settings-profile-banner">
<div class="profile-pic-container">
<img src="" alt="Profilbild" class="profile-pic" id="settings-profile-pic">
<button class="btn-change-pic" title="Bild ändern">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"></path><circle cx="12" cy="13" r="4"></circle></svg>
</button>
</div>
<div class="profile-info">
<span class="profile-username" id="settings-username"></span>
<button class="btn-edit-username" title="Namen ändern">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path></svg>
</button>
</div>
</div>
<div class="settings-card">
<h3>Statistiken</h3>
<div class="settings-row">
<span>Anzahl Pflanzen</span>
<span class="settings-value" id="stats-plant-count">...</span>
</div>
<div class="settings-row">
<span>Verbleibende KI-Aktionen</span>
<span class="settings-value" id="stats-ai-credits">...</span>
</div>
</div>
<div class="settings-card">
<h3>Automatisierung</h3>
<div class="settings-row">
<span>Gießintervall vorschlagen</span>
<label class="toggle-switch">
<input type="checkbox" id="auto-fill-watering">
<span class="slider"></span>
</label>
</div>
<div class="settings-row">
<span>Düngeintervall vorschlagen</span>
<label class="toggle-switch">
<input type="checkbox" id="auto-fill-fertilizing">
<span class="slider"></span>
</label>
</div>
<div class="settings-row">
<span>Pflegetipps vorschlagen</span>
<label class="toggle-switch">
<input type="checkbox" id="auto-fill-care-tips">
<span class="slider"></span>
</label>
</div>
</div>
<div class="settings-card">
<h3>Privatsphäre</h3>
<div class="settings-row">
<div>
<span>Fotos öffentlich teilen</span>
<p class="settings-description">Erlaube, dass deine schönsten Pflanzenfotos anonym im öffentlichen Lexikon gezeigt werden.</p>
</div>
<label class="toggle-switch">
<input type="checkbox" id="allow-photo-sharing">
<span class="slider"></span>
</label>
</div>
</div>
<div class="settings-card">
<h3>Benachrichtigungen</h3>
<div class="settings-row">
<span>Gießen (Push)</span>
<label class="toggle-switch"><input type="checkbox" id="notify-watering-push"><span class="slider"></span></label>
</div>
<div class="settings-row">
<span>Gießen (E-Mail)</span>
<label class="toggle-switch"><input type="checkbox" id="notify-watering-email"><span class="slider"></span></label>
</div>
<div class="settings-row">
<span>Düngen (Push)</span>
<label class="toggle-switch"><input type="checkbox" id="notify-fertilizing-push"><span class="slider"></span></label>
</div>
<div class="settings-row">
<span>Düngen (E-Mail)</span>
<label class="toggle-switch"><input type="checkbox" id="notify-fertilizing-email"><span class="slider"></span></label>
</div>
<div class="settings-row">
<span>Newsletter</span>
<label class="toggle-switch"><input type="checkbox" id="notify-newsletter"><span class="slider"></span></label>
</div>
</div>
<div class="settings-card">
<h3>Darstellung</h3>
<div class="settings-row">
<span>Theme</span>
<div class="theme-selector">
<button class="theme-btn" data-theme="light">Hell</button>
<button class="theme-btn" data-theme="dark">Dunkel</button>
<button class="theme-btn" data-theme="auto">Auto</button>
</div>
</div>
</div>
<div class="settings-card">
<h3>Konto verwalten</h3>
<button class="btn btn-secondary full-width" id="changePasswordBtn">Passwort ändern</button>
<button class="btn btn-secondary full-width" id="logoutBtn">Ausloggen</button>
<button class="btn btn-danger full-width" id="deleteAccountBtn">Konto löschen</button>
</div>
</div>
</div>
</div>
<div id="plantDetailOverlay" class="plant-detail-overlay">
<div id="plantDetailContent" class="plant-detail-content"></div>
<button id="plantDetailCloseBtn" class="plant-detail-close-btn">&times;</button>
</div>
<!-- =========== MODAL für LEXIKON-DETAILS (NEU) =========== -->
<div id="lexiconDetailModal" class="modal">
<div class="modal-content lexicon-detail-modal-content">
<div class="modal-header">
<h2 id="lexiconModalPlantName">Pflanzen-Detail</h2>
<button class="close-btn">&times;</button>
</div>
<div class="modal-body">
<div class="lexicon-modal-layout">
<div class="lexicon-modal-image-gallery">
<div class="lexicon-modal-image-container">
<img id="lexiconModalImage" src="" alt="Pflanzenbild">
</div>
<div class="lexicon-modal-thumbnails">
<!-- Thumbnails werden hier von lexikon.js eingefügt -->
</div>
</div>
<div class="lexicon-modal-info">
<span id="lexiconModalLatinName" class="lexicon-modal-latin"></span>
<div class="lexicon-modal-meta">
<div>
<strong>Familie:</strong>
<span id="lexiconModalFamily"></span>
</div>
<div>
<strong>Gattung:</strong>
<span id="lexiconModalGenus"></span>
</div>
</div>
<div class="lexicon-modal-description">
<!-- Beschreibung wird hier von lexikon.js eingefügt -->
</div>
</div>
</div>
</div>
</div>
</div>
<script type="module" src="main.js"></script>
<?php else: ?>
<div class="auth-container">
<div class="auth-box">
<div class="logo-title-container"><img src="img/logo.webp" alt="Plants App Logo" class="header-logo"><h1>Plants</h1></div>
<div id="login-form-container">
<h2>Willkommen zurück!</h2><p class="auth-subtitle">Bitte logge dich ein, um deine Pflanzen zu sehen.</p>
<form id="loginForm">
<div class="form-group"><label for="loginEmail">E-Mail</label><input type="email" id="loginEmail" name="email" required autocomplete="email"></div>
<div class="form-group"><label for="loginPassword">Passwort</label><input type="password" id="loginPassword" name="password" required autocomplete="current-password"></div>
<button type="submit" class="btn auth-btn">Einloggen</button>
</form>
<p class="auth-switch">Noch kein Konto? <a href="#" id="showRegister">Jetzt registrieren</a></p>
</div>
<div id="register-form-container" style="display: none;">
<h2>Erstelle dein Konto</h2><p class="auth-subtitle">Verwalte deine grünen Freunde an einem Ort.</p>
<form id="registerForm">
<div class="form-group"><label for="registerUsername">Dein Name</label><input type="text" id="registerUsername" name="username" required autocomplete="name"></div>
<div class="form-group"><label for="registerEmail">E-Mail</label><input type="email" id="registerEmail" name="email" required autocomplete="email"></div>
<div class="form-group"><label for="registerPassword">Passwort (min. 8 Zeichen)</label><input type="password" id="registerPassword" name="password" required autocomplete="new-password"></div>
<button type="submit" class="btn auth-btn">Konto erstellen</button>
</form>
<p class="auth-switch">Schon dabei? <a href="#" id="showLogin">Zum Login</a></p>
</div>
</div>
</div>
<script type="module" src="auth.js"></script>
<?php endif; ?>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show more