feat: inkubator-standard & modul-autodiscovery

- Dynamische Modulerkennung via module.json in ModuleService.php
- Kaskadierender Env-Loader fuer zentrale API-Keys (GEMINI_API_KEY)
- Graceful Auth fuer alle Subprojekte (dinos, plants, storymachine, test)
- Wiederverwendbares Starter-Template in _template/
- test-Modul auf philippurbschat.de Dark-Design modernisiert
This commit is contained in:
Philipp Urbschat 2026-09-13 00:23:16 +02:00
parent 7ebfb9cb63
commit f393beb7ff
Signed by: Phili
SSH key fingerprint: SHA256:ZSQWnldzrYiABzOV6vTICPe0h19pTpus7sCbm2S0po0
23 changed files with 758 additions and 306 deletions

41
_template/README.md Normal file
View file

@ -0,0 +1,41 @@
# 🚀 Modul-Starter-Template für philippurbschat.de
Dieses Verzeichnis dient als Kopiervorlage für neue Mini-Projekte und Experimente.
## In 30 Sekunden startklar:
1. **Ordner duplizieren:**
Kopiere diesen `_template`-Ordner und gib ihm den Namen deines neuen Projekts (z.B. `synth`, `todo`, `scanner`).
```bash
cp -r _template mein-projekt
```
2. **Metadaten in `module.json` anpassen:**
Öffne `mein-projekt/module.json` und passe Name, Badge und Beschreibung an:
```json
{
"name": "Mein Tolles Tool",
"badge": "AI Lab",
"description": "Was das Tool macht",
"adminOnly": false,
"order": 10
}
```
3. **Fertig!**
* Das Modul erscheint sofort automatisch in der Benutzerverwaltung unter `philippurbschat.de/admin/users`.
* Es erbt automatisch das Dark-Theme, Tailwind v4 und Typografie von `philippurbschat.de`.
* Es hat sofortigen Zugriff auf `GEMINI_API_KEY` aus der zentralen Konfiguration.
---
## Features & Architektur
* **Graceful Auth (`bootstrap.php`):**
* Auf `philippurbschat.de`: Vollständig geschützt durch Session & Rechteverwaltung.
* Standalone-Betrieb: Wenn du den Ordner als eigene Website auslagerst, läuft er ohne Änderungen direkt weiter.
* **Hierarchische API-Keys:**
* Rufe in PHP `module_env('GEMINI_API_KEY')` auf.
* Wenn im Modulordner eine eigene `.env` liegt, hat diese Vorrang. Andernfalls greift automatisch der globale Key aus `/home/.env`.
* **Sicherer API-Proxy (`api.php`):**
* Verhindert das Offenlegen deiner API-Schlüssel im Frontend.

75
_template/api.php Normal file
View file

@ -0,0 +1,75 @@
<?php
/**
* Modul-API-Proxy: Sicherer serverseitiger Relay für Gemini & externe APIs
*
* - Hält den API-Key auf dem Server geheim
* - Nutzt automatisch den zentralen Key aus /home/.env oder lokale .env
*/
require_once __DIR__ . '/bootstrap.php';
header('Content-Type: application/json; charset=UTF-8');
// Nur POST-Anfragen erlauben
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Nur POST-Anfragen sind erlaubt.']);
exit;
}
$apiKey = module_env('GEMINI_API_KEY');
if (empty($apiKey)) {
http_response_code(500);
echo json_encode(['error' => 'API-Schlüssel (GEMINI_API_KEY) ist weder lokal noch zentral konfiguriert.']);
exit;
}
// Request Payload lesen
$rawInput = file_get_contents('php://input');
$requestData = json_decode($rawInput, true);
if (json_last_error() !== JSON_ERROR_NONE || !isset($requestData['prompt'])) {
http_response_code(400);
echo json_encode(['error' => 'Ungültige Anfrage: "prompt" wird benötigt.']);
exit;
}
$prompt = trim($requestData['prompt']);
$model = $requestData['model'] ?? 'gemini-2.5-flash';
// Google Gemini API Aufruf
$endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . urlencode($model) . ':generateContent?key=' . $apiKey;
$payload = [
'contents' => [
[
'parts' => [
['text' => $prompt]
]
]
]
];
$ch = curl_init($endpoint);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_TIMEOUT => 30
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
http_response_code(500);
echo json_encode(['error' => 'Verbindungsfehler zur API: ' . $curlError]);
exit;
}
http_response_code($httpCode);
echo $response;

72
_template/bootstrap.php Normal file
View file

@ -0,0 +1,72 @@
<?php
/**
* Modul-Bootstrap: Graceful Auth & Universeller Env-Loader
*
* - Auf philippurbschat.de: Prüft zentralen Login & Benutzerrechte über ../auth.php
* - Standalone-Betrieb: Läuft autark weiter, wenn ../auth.php nicht existiert
* - API-Keys: Liest zuerst lokale .env, greift bei Bedarf auf ../home/.env zu
*/
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// 1. Graceful Auth (im Webbetrieb)
$authPath = __DIR__ . '/../auth.php';
$isHostedOnMainSite = file_exists($authPath);
if ($isHostedOnMainSite && php_sapi_name() !== 'cli') {
$current_project = basename(__DIR__);
require_once $authPath;
}
// 2. Nutzer-Kontext
$currentUser = $_SESSION['user_email'] ?? 'Gast / Standalone';
$isAdmin = !empty($_SESSION['is_admin']);
// 3. Hierarchischer Env-Loader
if (!function_exists('module_env')) {
function module_env(string $key, ?string $default = null): ?string {
static $envCache = null;
if ($envCache === null) {
$envCache = [];
$candidates = [
__DIR__ . '/.env', // 1. Lokale Modul-Konfiguration
__DIR__ . '/../home/.env', // 2. Zentrale philippurbschat.de Konfiguration
__DIR__ . '/../.env' // 3. Root-Konfiguration
];
foreach ($candidates as $file) {
if (file_exists($file)) {
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines !== false) {
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
if (strpos($line, '=') !== false) {
list($k, $v) = explode('=', $line, 2);
$cleanKey = trim($k);
// Frühere (spezifischere) Dateien haben Vorrang
if (!isset($envCache[$cleanKey])) {
$envCache[$cleanKey] = trim(trim($v), '"\'');
}
}
}
}
}
}
}
if (isset($envCache[$key])) {
return $envCache[$key];
}
$sysEnv = getenv($key);
if ($sysEnv !== false && $sysEnv !== '') {
return $sysEnv;
}
return $_ENV[$key] ?? $default;
}
}

152
_template/index.php Normal file
View file

@ -0,0 +1,152 @@
<?php
require_once __DIR__ . '/bootstrap.php';
// Modul-Metadaten laden
$moduleDescriptor = file_exists(__DIR__ . '/module.json')
? json_decode(file_get_contents(__DIR__ . '/module.json'), true)
: [];
$moduleName = $moduleDescriptor['name'] ?? 'Neues Projekt';
$moduleBadge = $moduleDescriptor['badge'] ?? 'Lab';
$moduleDesc = $moduleDescriptor['description'] ?? 'Experimentelles Web-Modul';
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($moduleName) ?> philippurbschat.de</title>
<!-- Zentrale Styles von philippurbschat.de -->
<link rel="stylesheet" href="/css/app.css">
<link rel="stylesheet" href="/css/style.css">
<!-- Standalone-Fallback: Falls außerhalb von philippurbschat.de gehostet -->
<script>
window.addEventListener('error', function(e) {
if (e.target && e.target.tagName === 'LINK' && e.target.href.includes('/css/app.css')) {
const cdn = document.createElement('script');
cdn.src = 'https://cdn.tailwindcss.com';
document.head.appendChild(cdn);
}
}, true);
</script>
</head>
<body class="bg-slate-950 text-slate-200 min-h-screen font-sans flex flex-col antialiased selection:bg-fuchsia-500 selection:text-white">
<!-- Top Navigation Bar -->
<header class="border-b border-slate-800/80 bg-slate-900/50 backdrop-blur-md sticky top-0 z-50">
<div class="max-w-5xl mx-auto px-4 sm:px-6 py-3.5 flex items-center justify-between">
<div class="flex items-center gap-3">
<a href="/" class="text-xs font-mono text-slate-400 hover:text-cyan-400 transition-colors flex items-center gap-1.5 group">
<span class="group-hover:-translate-x-0.5 transition-transform"></span> philippurbschat.de
</a>
<span class="text-slate-700">/</span>
<span class="text-xs font-mono text-slate-300 font-medium"><?= htmlspecialchars(basename(__DIR__)) ?></span>
</div>
<div class="flex items-center gap-3">
<span class="text-[11px] font-mono px-2 py-0.5 rounded-full border border-cyan-500/30 bg-cyan-500/10 text-cyan-400 uppercase tracking-wider">
<?= htmlspecialchars($moduleBadge) ?>
</span>
<span class="text-[11px] font-mono text-slate-500 hidden sm:inline-block">
<?= htmlspecialchars($currentUser) ?>
</span>
</div>
</div>
</header>
<!-- Main Content Container -->
<main class="grow max-w-5xl mx-auto px-4 sm:px-6 py-10 w-full">
<!-- Header Section -->
<div class="mb-10">
<div class="inline-flex items-center gap-2 mb-3">
<span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>
<span class="text-xs font-mono uppercase tracking-[0.2em] text-emerald-400">System Ready</span>
</div>
<h1 class="text-3xl sm:text-4xl font-bold font-mono tracking-tight text-white mb-2">
<?= htmlspecialchars($moduleName) ?>
</h1>
<p class="text-slate-400 text-sm max-w-2xl">
<?= htmlspecialchars($moduleDesc) ?>
</p>
</div>
<!-- App Sandbox Card -->
<div class="bg-slate-900/40 border border-slate-800 rounded-2xl p-6 sm:p-8 backdrop-blur shadow-2xl relative overflow-hidden">
<div class="absolute top-0 left-0 w-full h-[1px] bg-gradient-to-r from-transparent via-cyan-500/30 to-transparent"></div>
<div class="space-y-6">
<div>
<h2 class="text-xs font-mono uppercase tracking-widest text-slate-400 mb-2">Modul Workspace</h2>
<p class="text-sm text-slate-300 leading-relaxed">
Hier startet dein neues Mini-Projekt. Du hast vollen Zugriff auf Tailwind CSS, den zentralen Gemini API-Key und das Dark-Design der Hauptseite.
</p>
</div>
<!-- Interaktiver AI-Testbereich -->
<div class="pt-4 border-t border-slate-800/80">
<label for="promptInput" class="block text-xs font-mono uppercase text-slate-400 mb-2">
Schnelltest: Gemini API Relay
</label>
<div class="flex flex-col sm:flex-row gap-3">
<input type="text" id="promptInput" placeholder="Stelle eine kurze Frage..."
value="Erkläre Quantencomputer in einem prägnanten Satz."
class="grow bg-slate-950/80 border border-slate-700/80 rounded-xl px-4 py-3 text-sm text-slate-200 placeholder:text-slate-600 focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all font-mono">
<button id="sendBtn" class="bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 hover:border-cyan-500/60 font-mono text-xs uppercase tracking-wider px-6 py-3 rounded-xl transition-all duration-200 flex items-center justify-center gap-2 cursor-pointer active:scale-95">
<span>Ausführen</span>
</button>
</div>
<!-- Output Area -->
<div id="outputContainer" class="hidden mt-4 p-4 rounded-xl bg-slate-950/90 border border-slate-800/90 font-mono text-xs text-slate-300 whitespace-pre-wrap leading-relaxed"></div>
</div>
</div>
</div>
</main>
<!-- Footer -->
<footer class="border-t border-slate-900 py-6 text-center text-xs font-mono text-slate-600">
<?= htmlspecialchars($moduleName) ?> &bull; Philipp Urbschat Lab
</footer>
<script>
const sendBtn = document.getElementById('sendBtn');
const promptInput = document.getElementById('promptInput');
const outputContainer = document.getElementById('outputContainer');
sendBtn.addEventListener('click', async () => {
const prompt = promptInput.value.trim();
if (!prompt) return;
sendBtn.disabled = true;
sendBtn.innerHTML = 'Verarbeite...';
outputContainer.classList.remove('hidden');
outputContainer.textContent = 'Verbinde mit Gemini API...';
try {
const res = await fetch('api.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: prompt })
});
const data = await res.json();
if (data.error) {
outputContainer.textContent = 'Fehler: ' + data.error;
outputContainer.classList.add('text-rose-400');
} else {
outputContainer.classList.remove('text-rose-400');
const reply = data.candidates?.[0]?.content?.parts?.[0]?.text || JSON.stringify(data, null, 2);
outputContainer.textContent = reply;
}
} catch (err) {
outputContainer.textContent = 'Netzwerkfehler: ' + err.message;
outputContainer.classList.add('text-rose-400');
} finally {
sendBtn.disabled = false;
sendBtn.innerHTML = '<span>Ausführen</span> →';
}
});
</script>
</body>
</html>

7
_template/module.json Normal file
View file

@ -0,0 +1,7 @@
{
"name": "Neues Projekt",
"badge": "Labor",
"description": "Experimentelles Mini-Projekt",
"adminOnly": false,
"order": 10
}

View file

@ -1,8 +1,11 @@
<?php
// DATEI: dinos/api_proxy.php
$current_project = 'dinos';
require_once __DIR__ . '/../auth.php';
$authFile = __DIR__ . '/../auth.php';
if (file_exists($authFile)) {
$current_project = basename(__DIR__);
require_once $authFile;
}
header('Content-Type: application/json');

View file

@ -4,9 +4,12 @@
* 2026-02-23
*/
// 1. Projekt identifizieren und schützen
$current_project = 'dinos';
require_once '../auth.php'; // Falls du deine globale Auth hast, sonst auskommentieren
// 1. Projekt identifizieren und schützen (Graceful Auth)
$authFile = __DIR__ . '/../auth.php';
if (file_exists($authFile)) {
$current_project = basename(__DIR__);
require_once $authFile;
}
// 2. Nutzerdaten abrufen
$loggedInUser = isset($_SESSION['user_email']) ? $_SESSION['user_email'] : 'Gast';

View file

@ -4,9 +4,12 @@
* FEATURES: Robustere Fehlerbehandlung, Safety-Check für PromptFeedback, Konfigurierbare API-Modelle
*/
// 1. Projekt identifizieren und schützen
$current_project = 'dinos';
require_once '../auth.php'; // Falls du deine globale Auth hast, sonst auskommentieren
// 1. Projekt identifizieren und schützen (Graceful Auth)
$authFile = __DIR__ . '/../auth.php';
if (file_exists($authFile)) {
$current_project = basename(__DIR__);
require_once $authFile;
}
// 2. Nutzerdaten abrufen
$loggedInUser = isset($_SESSION['user_email']) ? $_SESSION['user_email'] : 'Gast';

7
dinos/module.json Normal file
View file

@ -0,0 +1,7 @@
{
"name": "Dino Generator",
"badge": "AI Art",
"description": "KI Dino-Bilderstellung & Google Photos Sync",
"adminOnly": false,
"order": 1
}

View file

@ -5,8 +5,11 @@
* Nimmt AJAX Request entgegen und sendet an Google Photos.
*/
$current_project = 'dinos';
require_once __DIR__ . '/../auth.php';
$authFile = __DIR__ . '/../auth.php';
if (file_exists($authFile)) {
$current_project = basename(__DIR__);
require_once $authFile;
}
require_once 'google_helper.php';
header('Content-Type: application/json');

View file

@ -1,13 +1,23 @@
<?php
/**
* ModuleService - Dynamische Auto-Discovery für Subprojekte & Module
*
* Jedes Verzeichnis im Root mit einer `module.json` wird automatisch als Modul registriert
* und steht sofort in der Benutzer- und Rechteverwaltung zur Verfügung.
*/
class ModuleService {
private static $modules = [
private static ?array $cachedModules = null;
// Fallback-Definitionen, falls Dateisystem-Zugriff blockiert ist
private static array $defaultFallback = [
'dinos' => [
'slug' => 'dinos',
'name' => 'Dino Generator',
'badge' => 'AI Art',
'description' => 'KI Dino-Bilderstellung & Google Photos Sync',
'path' => '/dinos',
'adminOnly' => false
'adminOnly' => false,
'order' => 1
],
'plants' => [
'slug' => 'plants',
@ -15,7 +25,8 @@ class ModuleService {
'badge' => 'AI Vision',
'description' => 'Pflanzen-Tracking, Pflege & KI-Erkennung',
'path' => '/plants',
'adminOnly' => false
'adminOnly' => false,
'order' => 2
],
'storymachine' => [
'slug' => 'storymachine',
@ -23,32 +34,110 @@ class ModuleService {
'badge' => 'Creative',
'description' => 'Interaktive Kinder-Geschichtenmaschine',
'path' => '/storymachine',
'adminOnly' => false
'adminOnly' => false,
'order' => 3
],
'test' => [
'slug' => 'test',
'name' => 'Test',
'badge' => 'Test',
'description' => 'Interaktive Web Audio API Synthese & Soundtest',
'name' => 'Test & Lab',
'badge' => 'Audio',
'description' => 'Modul-Showcase & Web Audio Synthesizer',
'path' => '/test',
'adminOnly' => false
'adminOnly' => false,
'order' => 4
]
];
public static function getAll(): array {
return self::$modules;
/**
* Ermittelt den Webroot-Pfad der Domain
*/
private static function getRootPath(): string {
return dirname(__DIR__, 3);
}
/**
* Liest alle Module dynamisch aus dem Dateisystem
*/
public static function getAll(bool $refresh = false): array {
if (self::$cachedModules !== null && !$refresh) {
return self::$cachedModules;
}
$rootPath = self::getRootPath();
$modules = [];
// Ignorierte Systemverzeichnisse
$ignoredDirs = ['.git', '.vscode', 'home', 'philcore', 'node_modules', 'vendor', '_template'];
if (is_dir($rootPath)) {
$entries = scandir($rootPath);
if ($entries !== false) {
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..' || in_array($entry, $ignoredDirs, true)) {
continue;
}
$moduleDir = $rootPath . '/' . $entry;
if (!is_dir($moduleDir)) {
continue;
}
$descriptorFile = $moduleDir . '/module.json';
if (file_exists($descriptorFile)) {
$jsonContent = @file_get_contents($descriptorFile);
$data = $jsonContent ? json_decode($jsonContent, true) : null;
if (is_array($data)) {
// Ignorieren, falls explizit deaktiviert
if (isset($data['enabled']) && $data['enabled'] === false) {
continue;
}
$modules[$entry] = [
'slug' => $entry,
'name' => $data['name'] ?? ucfirst($entry),
'badge' => $data['badge'] ?? 'Tool',
'description' => $data['description'] ?? '',
'path' => '/' . $entry,
'adminOnly' => !empty($data['adminOnly']),
'order' => isset($data['order']) ? (int)$data['order'] : 999
];
}
}
}
}
}
// Falls keine Module gefunden wurden, Fallback nutzen
if (empty($modules)) {
$modules = self::$defaultFallback;
}
// Sortierung nach 'order', dann nach Name
uasort($modules, function ($a, $b) {
$orderA = $a['order'] ?? 999;
$orderB = $b['order'] ?? 999;
if ($orderA !== $orderB) {
return $orderA <=> $orderB;
}
return strcasecmp($a['name'] ?? '', $b['name'] ?? '');
});
self::$cachedModules = $modules;
return self::$cachedModules;
}
public static function getBySlug(string $slug): ?array {
return self::$modules[$slug] ?? null;
$all = self::getAll();
return $all[$slug] ?? null;
}
public static function getAccessibleForUser(array $userProjects, bool $isAdmin = false): array {
$accessible = [];
foreach (self::$modules as $slug => $module) {
foreach (self::getAll() as $slug => $module) {
if ($isAdmin) {
$accessible[$slug] = $module;
} elseif (in_array($slug, $userProjects, true) && !$module['adminOnly']) {
} elseif (in_array($slug, $userProjects, true) && empty($module['adminOnly'])) {
$accessible[$slug] = $module;
}
}
@ -56,6 +145,7 @@ class ModuleService {
}
public static function isValidModule(string $slug): bool {
return isset(self::$modules[$slug]);
$all = self::getAll();
return isset($all[$slug]);
}
}

View file

@ -1,13 +1,22 @@
<?php
$current_project = 'plants';
require_once __DIR__ . '/../auth.php';
$authFile = __DIR__ . '/../auth.php';
if (file_exists($authFile)) {
$current_project = basename(__DIR__);
require_once $authFile;
}
session_start();
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require_once 'db.php';
require_once __DIR__ . '/vendor/autoload.php';
if (file_exists(__DIR__ . '/vendor/autoload.php')) {
require_once __DIR__ . '/vendor/autoload.php';
}
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->load();
if (file_exists(__DIR__ . '/.env') && class_exists('Dotenv\Dotenv')) {
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->safeLoad();
}
if (!isset($_SESSION['user_id'])) {
http_response_code(401);
@ -107,8 +116,21 @@ try {
$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']);
$geminiKey = $_ENV['GEMINI_API_KEY'] ?? getenv('GEMINI_API_KEY') ?: '';
if (empty($geminiKey) && file_exists(__DIR__ . '/../home/.env')) {
foreach (file(__DIR__ . '/../home/.env', 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') {
$geminiKey = trim(trim($v), '"\'');
break;
}
}
}
}
$response_data = callGeminiAPI($json_data['payload'], $geminiKey);
$pdo->commit();
echo json_encode($response_data);
break;

View file

@ -1,11 +1,19 @@
<?php
$current_project = 'plants';
require_once __DIR__ . '/../auth.php';
$authFile = __DIR__ . '/../auth.php';
if (file_exists($authFile)) {
$current_project = basename(__DIR__);
require_once $authFile;
}
// Lade die Abhängigkeiten und die .env-Variablen
require_once __DIR__ . '/vendor/autoload.php';
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->load();
// Lade Abhängigkeiten und .env-Variablen
if (file_exists(__DIR__ . '/vendor/autoload.php')) {
require_once __DIR__ . '/vendor/autoload.php';
}
if (file_exists(__DIR__ . '/.env') && class_exists('Dotenv\Dotenv')) {
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->safeLoad();
}
header('Content-Type: application/json');
@ -15,8 +23,21 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
exit;
}
// Dein geheimer API-Schlüssel wird sicher aus der .env-Datei geladen
$apiKey = $_ENV['GEMINI_API_KEY'];
// API-Schlüssel: Erst lokal prüfen, Fallback auf ../home/.env
$apiKey = $_ENV['GEMINI_API_KEY'] ?? getenv('GEMINI_API_KEY') ?: '';
if (empty($apiKey) && file_exists(__DIR__ . '/../home/.env')) {
foreach (file(__DIR__ . '/../home/.env', 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;
}
}
}
}
$json_input = file_get_contents('php://input');
$request_data = json_decode($json_input, true);

View file

@ -1,6 +1,9 @@
<?php
$current_project = 'plants';
require_once __DIR__ . '/../auth.php';
$authFile = __DIR__ . '/../auth.php';
if (file_exists($authFile)) {
$current_project = basename(__DIR__);
require_once $authFile;
}
require_once 'db.php'; // Stellt die $pdo-Verbindung her

View file

@ -1,7 +1,10 @@
<?php
// Version: 01.10.2025 16:15 (FINAL & COMPLETE)
$current_project = 'plants';
require_once '../auth.php';
$authFile = __DIR__ . '/../auth.php';
if (file_exists($authFile)) {
$current_project = basename(__DIR__);
require_once $authFile;
}
session_start();

View file

@ -1,7 +1,10 @@
<?php
// Version: 01.10.2025 12:15 (Layout restored)
$current_project = 'plants';
require_once '../auth.php';
$authFile = __DIR__ . '/../auth.php';
if (file_exists($authFile)) {
$current_project = basename(__DIR__);
require_once $authFile;
}
session_start();

7
plants/module.json Normal file
View file

@ -0,0 +1,7 @@
{
"name": "Plant Tracker",
"badge": "AI Vision",
"description": "Pflanzen-Tracking, Pflege & KI-Erkennung",
"adminOnly": false,
"order": 2
}

View file

@ -2,8 +2,11 @@
<?php
// api_proxy.php
$current_project = 'storymachine';
require_once __DIR__ . '/../auth.php';
$authFile = __DIR__ . '/../auth.php';
if (file_exists($authFile)) {
$current_project = basename(__DIR__);
require_once $authFile;
}
// 1. Setze den Antwort-Header auf JSON, damit der Browser weiß, was er empfängt.
header('Content-Type: application/json');

View file

@ -1,6 +1,9 @@
<?php
$current_project = 'storymachine';
require_once '../auth.php';
$authFile = __DIR__ . '/../auth.php';
if (file_exists($authFile)) {
$current_project = basename(__DIR__);
require_once $authFile;
}
?>
<!DOCTYPE html>

7
storymachine/module.json Normal file
View file

@ -0,0 +1,7 @@
{
"name": "Story Machine",
"badge": "Creative",
"description": "Interaktive Kinder-Geschichtenmaschine",
"adminOnly": false,
"order": 3
}

58
test/bootstrap.php Normal file
View file

@ -0,0 +1,58 @@
<?php
/**
* Modul-Bootstrap für test
*/
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$authPath = __DIR__ . '/../auth.php';
$isHostedOnMainSite = file_exists($authPath);
if ($isHostedOnMainSite && php_sapi_name() !== 'cli') {
$current_project = basename(__DIR__);
require_once $authPath;
}
$currentUser = $_SESSION['user_email'] ?? 'Gast / Standalone';
$isAdmin = !empty($_SESSION['is_admin']);
if (!function_exists('module_env')) {
function module_env(string $key, ?string $default = null): ?string {
static $envCache = null;
if ($envCache === null) {
$envCache = [];
$candidates = [
__DIR__ . '/.env',
__DIR__ . '/../home/.env',
__DIR__ . '/../.env'
];
foreach ($candidates as $file) {
if (file_exists($file)) {
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines !== false) {
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
if (strpos($line, '=') !== false) {
list($k, $v) = explode('=', $line, 2);
$cleanKey = trim($k);
if (!isset($envCache[$cleanKey])) {
$envCache[$cleanKey] = trim(trim($v), '"\'');
}
}
}
}
}
}
}
if (isset($envCache[$key])) {
return $envCache[$key];
}
$sysEnv = getenv($key);
if ($sysEnv !== false && $sysEnv !== '') {
return $sysEnv;
}
return $_ENV[$key] ?? $default;
}
}

View file

@ -1,268 +1,143 @@
<?php
// Session starten, falls noch nicht geschehen
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require_once __DIR__ . '/bootstrap.php';
// Einfache Logout-Logik direkt in dieser Datei
if (isset($_GET['logout'])) {
session_destroy();
header("Location: /");
exit;
}
// Modul-Metadaten
$moduleDescriptor = file_exists(__DIR__ . '/module.json')
? json_decode(file_get_contents(__DIR__ . '/module.json'), true)
: [];
// 1. Projekt identifizieren und schützen
$current_project = 'test';
require_once __DIR__ . '/../auth.php';
// 2. Nutzerdaten abrufen
$loggedInUser = isset($_SESSION['user_email']) ? $_SESSION['user_email'] : 'neuankoemmling@beispiel.de';
$isAdmin = !empty($_SESSION['is_admin']);
$moduleName = $moduleDescriptor['name'] ?? 'Test & Lab';
$moduleBadge = $moduleDescriptor['badge'] ?? 'Audio';
$moduleDesc = $moduleDescriptor['description'] ?? 'Modul-Showcase & Web Audio Synthesizer';
?>
<!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;
}
<title><?= htmlspecialchars($moduleName) ?> philippurbschat.de</title>
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;
position: relative;
}
<link rel="stylesheet" href="/css/app.css">
<link rel="stylesheet" href="/css/style.css">
.top-bar {
position: fixed;
top: 15px;
left: 15px;
z-index: 100;
display: flex;
gap: 10px;
font-family: sans-serif;
font-size: 13px;
<!-- Standalone-Fallback -->
<script>
window.addEventListener('error', function(e) {
if (e.target && e.target.tagName === 'LINK' && e.target.href.includes('/css/app.css')) {
const cdn = document.createElement('script');
cdn.src = 'https://cdn.tailwindcss.com';
document.head.appendChild(cdn);
}
.top-bar a {
color: #fff;
background: #000;
padding: 8px 14px;
border: 2px solid var(--accent);
border-radius: 8px;
text-decoration: none;
font-weight: bold;
transition: transform 0.1s;
}
.top-bar a:hover {
transform: scale(1.05);
}
.top-bar a.admin-btn {
color: #000;
background: var(--secondary);
border-color: #000;
}
.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>
}, true);
</script>
</head>
<body>
<div class="top-bar">
<a href="/"> Website</a>
<?php if ($isAdmin): ?>
<a href="/admin" class="admin-btn">Admin-Bereich</a>
<a href="/admin/users" class="admin-btn">Benutzerverwaltung</a>
<?php endif; ?>
</div>
<body class="bg-slate-950 text-slate-200 min-h-screen font-sans flex flex-col antialiased selection:bg-cyan-500 selection:text-white">
<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>
<!-- Top Navigation Bar -->
<header class="border-b border-slate-800/80 bg-slate-900/50 backdrop-blur-md sticky top-0 z-50">
<div class="max-w-5xl mx-auto px-4 sm:px-6 py-3.5 flex items-center justify-between">
<div class="flex items-center gap-3">
<a href="/" class="text-xs font-mono text-slate-400 hover:text-cyan-400 transition-colors flex items-center gap-1.5 group">
<span class="group-hover:-translate-x-0.5 transition-transform"></span> philippurbschat.de
</a>
<span class="text-slate-700">/</span>
<span class="text-xs font-mono text-slate-300 font-medium">test</span>
</div>
<div class="flex items-center gap-3">
<span class="text-[11px] font-mono px-2 py-0.5 rounded-full border border-cyan-500/30 bg-cyan-500/10 text-cyan-400 uppercase tracking-wider">
<?= htmlspecialchars($moduleBadge) ?>
</span>
<?php if ($isAdmin): ?>
<a href="/admin/users" class="text-[11px] font-mono px-2 py-0.5 rounded-full border border-fuchsia-500/30 bg-fuchsia-500/10 text-fuchsia-400 hover:bg-fuchsia-500/20 transition-colors uppercase tracking-wider">
Benutzer
</a>
<?php endif; ?>
<span class="text-[11px] font-mono text-slate-500 hidden sm:inline-block">
<?= htmlspecialchars($currentUser) ?>
</span>
</div>
</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>
<!-- Main Content -->
<main class="grow max-w-5xl mx-auto px-4 sm:px-6 py-10 w-full">
<!-- Header -->
<div class="mb-10">
<div class="inline-flex items-center gap-2 mb-3">
<span class="w-2 h-2 rounded-full bg-cyan-400 animate-pulse"></span>
<span class="text-xs font-mono uppercase tracking-[0.2em] text-cyan-400">Audio Lab</span>
</div>
<h1 class="text-3xl sm:text-4xl font-bold font-mono tracking-tight text-white mb-2">
<?= htmlspecialchars($moduleName) ?>
</h1>
<p class="text-slate-400 text-sm max-w-2xl">
<?= htmlspecialchars($moduleDesc) ?>
</p>
</div>
<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>
<!-- Sandbox & Synth Card -->
<div id="synth-container" class="bg-slate-900/40 border border-slate-800 rounded-2xl p-6 sm:p-8 backdrop-blur shadow-2xl relative overflow-hidden transition-transform duration-200">
<div class="absolute top-0 left-0 w-full h-[1px] bg-gradient-to-r from-transparent via-cyan-500/40 to-transparent"></div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 items-center">
<!-- Info Section -->
<div class="space-y-4">
<h2 class="text-xs font-mono uppercase tracking-widest text-slate-400">Web Audio API Test</h2>
<p class="text-sm text-slate-300 leading-relaxed">
Dieses Modul dient als Referenzimplementierung für das neue Modulsystem. Es demonstriert dynamische Web Audio Synthese (Oszillatoren, Rauschgeneratoren und Biquad-Filter) im Browser.
</p>
<div class="p-4 rounded-xl bg-slate-950/70 border border-slate-800/80 font-mono text-xs space-y-2">
<div class="text-slate-400">STATUS: <span id="audio-status" class="text-emerald-400">Bereit für Signaltest</span></div>
<div class="text-slate-500 text-[11px]">System: Graceful Auth &bull; Auto-Discovered</div>
</div>
</div>
<!-- Interactive Trigger -->
<div class="flex flex-col items-center justify-center p-8 rounded-xl bg-slate-950/60 border border-slate-800/60 text-center">
<button id="trigger-sound" class="w-32 h-32 rounded-full bg-gradient-to-br from-cyan-500/20 to-fuchsia-500/20 border-2 border-cyan-500/50 hover:border-cyan-400 hover:shadow-[0_0_30px_rgba(6,182,212,0.3)] transition-all duration-300 flex flex-col items-center justify-center gap-2 group cursor-pointer active:scale-95 select-none">
<svg class="w-8 h-8 text-cyan-400 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
</svg>
<span class="text-[10px] font-mono uppercase tracking-widest text-slate-300 group-hover:text-white">Signal Starten</span>
</button>
<p class="text-xs font-mono text-slate-500 mt-4">Klicken zum Erzeugen eines synthetischen Klanges</p>
</div>
</div>
</div>
</main>
<footer>
<!-- Link angepasst auf Parameter-Logout -->
<a href="?logout=1" class="logout-btn">Test beendet? Hier abmelden!</a>
<!-- Footer -->
<footer class="border-t border-slate-900 py-6 text-center text-xs font-mono text-slate-600">
<?= htmlspecialchars($moduleName) ?> &bull; Philipp Urbschat Lab
</footer>
</div>
<script>
const cushion = document.getElementById('whoopee-cushion');
const container = document.getElementById('app-container');
const statusMsg = document.getElementById('status-msg');
const triggerBtn = document.getElementById('trigger-sound');
const synthContainer = document.getElementById('synth-container');
const statusMsg = document.getElementById('audio-status');
function playOrganicFart() {
function playSynthesizerTone() {
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (audioCtx.state === 'suspended') audioCtx.resume();
const duration = 0.4 + Math.random() * 0.5;
const duration = 0.4 + Math.random() * 0.4;
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);
osc.frequency.setValueAtTime(55 + Math.random() * 25, audioCtx.currentTime);
osc.frequency.exponentialRampToValueAtTime(25, 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.frequency.setValueAtTime(12 + Math.random() * 6, audioCtx.currentTime);
lfoGain.gain.setValueAtTime(0.7, audioCtx.currentTime);
lfo.connect(lfoGain);
lfoGain.connect(oscGain.gain);
const bufferSize = audioCtx.sampleRate * duration;
const bufferSize = Math.floor(audioCtx.sampleRate * duration);
const noiseBuffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
const noiseData = noiseBuffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
@ -272,15 +147,15 @@ $isAdmin = !empty($_SESSION['is_admin']);
noise.buffer = noiseBuffer;
const noiseFilter = audioCtx.createBiquadFilter();
noiseFilter.type = 'bandpass';
noiseFilter.frequency.setValueAtTime(400, audioCtx.currentTime);
noiseFilter.frequency.setValueAtTime(380, audioCtx.currentTime);
const lowPass = audioCtx.createBiquadFilter();
lowPass.type = 'lowpass';
lowPass.frequency.setValueAtTime(450, audioCtx.currentTime);
lowPass.frequency.exponentialRampToValueAtTime(80, audioCtx.currentTime + duration);
lowPass.frequency.setValueAtTime(420, audioCtx.currentTime);
lowPass.frequency.exponentialRampToValueAtTime(70, audioCtx.currentTime + duration);
mainGain.gain.setValueAtTime(0, audioCtx.currentTime);
mainGain.gain.linearRampToValueAtTime(0.5, audioCtx.currentTime + 0.05);
mainGain.gain.linearRampToValueAtTime(0.4, audioCtx.currentTime + 0.05);
mainGain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + duration);
osc.connect(oscGain);
@ -298,29 +173,13 @@ $isAdmin = !empty($_SESSION['is_admin']);
lfo.stop(audioCtx.currentTime + duration);
noise.stop(audioCtx.currentTime + duration);
statusMsg.textContent = "Interaktionstest: Bestanden! 👍";
statusMsg.textContent = "Signal generiert (Synthese erfolgreich)";
}
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);
}
triggerBtn.addEventListener('click', () => {
playSynthesizerTone();
synthContainer.classList.add('ring-1', 'ring-cyan-500/50');
setTimeout(() => synthContainer.classList.remove('ring-1', 'ring-cyan-500/50'), 300);
});
</script>
</body>

7
test/module.json Normal file
View file

@ -0,0 +1,7 @@
{
"name": "Test & Lab",
"badge": "Audio",
"description": "Modul-Showcase & Web Audio Synthesizer",
"adminOnly": false,
"order": 4
}