New version
This commit is contained in:
@@ -13,11 +13,11 @@ Die App ist so aufgebaut, dass UI und Datenzugriff getrennt sind. Je nach Reimpo
|
|||||||
|
|
||||||
- Einstiegspunkt (z. B. `public/index.php`): Lädt eine Seite/Ansicht, die das Layout und die Matrix enthält.
|
- Einstiegspunkt (z. B. `public/index.php`): Lädt eine Seite/Ansicht, die das Layout und die Matrix enthält.
|
||||||
- Matrix-Ansicht (z. B. `partials/landing/main/material-matrix.php`): Enthält das Markup + JS, das die Daten lädt und die Tabelle rendert.
|
- Matrix-Ansicht (z. B. `partials/landing/main/material-matrix.php`): Enthält das Markup + JS, das die Daten lädt und die Tabelle rendert.
|
||||||
- API-Endpoints (z. B. `public/api/*`): Stellen JSON bereit für
|
- API-Endpoints (z. B. `/api/*` über das Routing): Stellen JSON bereit für
|
||||||
- Drucker-Liste
|
- Drucker-Liste
|
||||||
- Material-Liste
|
- Material-Liste
|
||||||
- Drucker-spezifische Material-Kompatibilität
|
- Drucker-spezifische Material-Kompatibilität
|
||||||
- DB-Zugriff (z. B. `tools/db.php`): Baut eine DB-Verbindung und wird von den API-Endpunkten genutzt.
|
- DB-Zugriff (z. B. `src/App/Database.php`): Baut eine DB-Verbindung und wird von den API-Endpunkten genutzt.
|
||||||
|
|
||||||
Aktuelle DB-Struktur (Schema-Orientierung)
|
Aktuelle DB-Struktur (Schema-Orientierung)
|
||||||
Das Schema besteht aus drei Kern-Tabellen, die für die Materialmatrix benötigt werden:
|
Das Schema besteht aus drei Kern-Tabellen, die für die Materialmatrix benötigt werden:
|
||||||
@@ -47,6 +47,7 @@ Hinweise für Reimport/Neuaufbau
|
|||||||
- `printer`: Drucker-Datensatz
|
- `printer`: Drucker-Datensatz
|
||||||
- `materials`: Liste der Materialien mit optionalem `support_level` + Zusatzinfos.
|
- `materials`: Liste der Materialien mit optionalem `support_level` + Zusatzinfos.
|
||||||
- Die Datenbank kann migriert werden, solange Material-, Drucker- und Zuordnungsdaten semantisch erhalten bleiben.
|
- Die Datenbank kann migriert werden, solange Material-, Drucker- und Zuordnungsdaten semantisch erhalten bleiben.
|
||||||
|
- Für DB-Zugriff muss `APP_DB_ENABLED` aktiviert sein; die Zugangsdaten sollten per ENV-Variablen kommen.
|
||||||
|
|
||||||
Sicherheit
|
Sicherheit
|
||||||
- Zugangsdaten sollten nicht im Repo liegen. Nutze ENV-Variablen oder separate Configs pro Environment.
|
- Zugangsdaten sollten nicht im Repo liegen. Nutze ENV-Variablen oder separate Configs pro Environment.
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<?php // TODO
|
|
||||||
@@ -1,6 +1,67 @@
|
|||||||
<?php
|
<?php
|
||||||
$env = getenv('APP_ENV') ?: 'staging';
|
declare(strict_types=1);
|
||||||
$env = strtolower($env);
|
|
||||||
$path = __DIR__ . 'db.php';
|
|
||||||
|
|
||||||
return require $path;
|
// Basic error reporting (keep strict in dev)
|
||||||
|
ini_set('display_errors', '1');
|
||||||
|
ini_set('display_startup_errors', '1');
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
// Determine APP_ENV consistently:
|
||||||
|
// 1) Prefer environment variable.
|
||||||
|
// 2) If not set, try root config files to define APP_ENV.
|
||||||
|
$appEnvFromEnv = getenv('APP_ENV');
|
||||||
|
if (!$appEnvFromEnv) {
|
||||||
|
$rootDomain = __DIR__ . '/domaindata.php';
|
||||||
|
$rootSettings = __DIR__ . '/settings.php';
|
||||||
|
if (file_exists($rootDomain)) {
|
||||||
|
require_once $rootDomain;
|
||||||
|
}
|
||||||
|
if (file_exists($rootSettings)) {
|
||||||
|
require_once $rootSettings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$appEnv = $appEnvFromEnv ?: (defined('APP_ENV') ? (string) APP_ENV : 'prod');
|
||||||
|
$envDir = rtrim(__DIR__, '/\\') . '/' . $appEnv;
|
||||||
|
|
||||||
|
foreach (['domaindata.php','settings.php'] as $cfgFile) {
|
||||||
|
$rootPath = __DIR__ . '/' . $cfgFile;
|
||||||
|
$envPath = $envDir . '/' . $cfgFile;
|
||||||
|
|
||||||
|
if (file_exists($rootPath)) {
|
||||||
|
require_once $rootPath;
|
||||||
|
} elseif (file_exists($envPath)) {
|
||||||
|
require_once $envPath;
|
||||||
|
} else {
|
||||||
|
throw new \RuntimeException("Missing required config file: $cfgFile (looked for $rootPath or $envPath)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Environment: staging|prod|local (example)
|
||||||
|
if (!defined('APP_ENV')) {
|
||||||
|
define('APP_ENV', 'local');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asset versioning
|
||||||
|
if (!defined('ASSET_VERSION')) {
|
||||||
|
define('ASSET_VERSION', 'dev-' . date('Ymd-His'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primary domain + URL
|
||||||
|
if (!defined('APP_DOMAIN_PRIMARY')) {
|
||||||
|
define('APP_DOMAIN_PRIMARY', APP_DOMAIN_NAME);
|
||||||
|
}
|
||||||
|
if (!defined('APP_URL_PRIMARY')) {
|
||||||
|
define('APP_URL_PRIMARY', 'https://' . APP_DOMAIN_PRIMARY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// API base (example)
|
||||||
|
if (!defined('APP_API_BASE')) {
|
||||||
|
define('APP_API_BASE', 'https://api.' . APP_DOMAIN_PRIMARY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feature toggles
|
||||||
|
if (!defined('APP_DB_ENABLED')) {
|
||||||
|
define('APP_DB_ENABLED', false); // set true to enable DB connection
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,41 @@
|
|||||||
<?php // TODO
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// 1) Load config (constants, env, domains)
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
|
||||||
|
// 2) Composer Autoloader (falls vorhanden)
|
||||||
|
$composerAutoload = __DIR__ . '/../vendor/autoload.php';
|
||||||
|
if (file_exists($composerAutoload)) {
|
||||||
|
require_once $composerAutoload;
|
||||||
|
} else {
|
||||||
|
// 2b) Fallback: minimaler Autoloader
|
||||||
|
spl_autoload_register(function (string $class): void {
|
||||||
|
$prefix = 'App\\';
|
||||||
|
if (!str_starts_with($class, $prefix)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rel = substr($class, strlen($prefix));
|
||||||
|
$path = __DIR__ . '/../src/App/' . str_replace('\\', '/', $rel) . '.php';
|
||||||
|
|
||||||
|
if (file_exists($path)) {
|
||||||
|
require_once $path;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Global helper functions (tpl(), t(), asset_*())
|
||||||
|
require_once __DIR__ . '/../src/helpers.php';
|
||||||
|
|
||||||
|
// 4) Initialize App (services)
|
||||||
|
$config = \App\Config::fromPhpConstants(__DIR__ . '/../config');
|
||||||
|
\App\App::init($config);
|
||||||
|
|
||||||
|
// 5) Start session + create client-id cookie
|
||||||
|
$app = \App\App::get();
|
||||||
|
$app->session()->start();
|
||||||
|
$clientId = $app->session()->ensureClientId();
|
||||||
|
|
||||||
|
// Optionally expose a single global for templates if desired
|
||||||
|
$GLOBALS['client_id'] = $clientId;
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1 +1,94 @@
|
|||||||
<?php // TODO
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* config/db.php
|
||||||
|
*
|
||||||
|
* - Choose ONE driver below (others stay commented).
|
||||||
|
* - Each driver has its own config section.
|
||||||
|
* - The file returns ONE normalized array used by Database::createPdo().
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 1) Driver selection (choose one)
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
//$driver = 'pgsql';
|
||||||
|
$driver = 'mysql';
|
||||||
|
// $driver = 'sqlite';
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 2) Driver-specific configuration sections
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
// ---- PostgreSQL (PDO driver: pgsql) -------------------------
|
||||||
|
$pgsql = [
|
||||||
|
'driver' => 'pgsql',
|
||||||
|
'host' => 'localhost',
|
||||||
|
'port' => 5432,
|
||||||
|
'dbname' => 'mydb',
|
||||||
|
|
||||||
|
// optional: schema/search_path (commonly "public")
|
||||||
|
'schema' => 'public',
|
||||||
|
|
||||||
|
'user' => 'myuser',
|
||||||
|
'password' => 'secret',
|
||||||
|
|
||||||
|
'options' => [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---- MySQL / MariaDB (PDO driver: mysql) -------------------
|
||||||
|
$mysql = [
|
||||||
|
'driver' => 'mysql',
|
||||||
|
'host' => 'localhost',
|
||||||
|
'port' => 3306,
|
||||||
|
'dbname' => 'd0453540',
|
||||||
|
'charset' => 'utf8mb4',
|
||||||
|
|
||||||
|
// Alternative to host/port:
|
||||||
|
// 'unix_socket' => '/var/run/mysqld/mysqld.sock',
|
||||||
|
|
||||||
|
'user' => 'd0453540',
|
||||||
|
'password' => 'P6jGRrSaX8QSiBMEJBL7',
|
||||||
|
|
||||||
|
'options' => [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---- SQLite (PDO driver: sqlite) ---------------------------
|
||||||
|
$sqlite = [
|
||||||
|
'driver' => 'sqlite',
|
||||||
|
|
||||||
|
// Use an absolute path in production, e.g. /var/app/data/app.sqlite
|
||||||
|
// For demo/dev you can use a relative path.
|
||||||
|
'path' => __DIR__ . '/../var/app.sqlite',
|
||||||
|
|
||||||
|
// SQLite ignores host/port/user/pass
|
||||||
|
'options' => [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 3) Select and return config
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
switch ($driver) {
|
||||||
|
case 'pgsql':
|
||||||
|
return $pgsql;
|
||||||
|
|
||||||
|
case 'mysql':
|
||||||
|
return $mysql;
|
||||||
|
|
||||||
|
case 'sqlite':
|
||||||
|
return $sqlite;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new RuntimeException('Unsupported DB driver in config/db.php: ' . $driver);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,12 @@
|
|||||||
<?php // TODO
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Example: a single "brand" domain name.
|
||||||
|
// In real deployments you might derive this from ENV or hostnames.
|
||||||
|
if (!defined('APP_DOMAIN_NAME')) {
|
||||||
|
define('APP_DOMAIN_NAME', 'shape3d.it');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defined('APP_PREFIX')) {
|
||||||
|
define('APP_PREFIX', 'miniapp');
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<?php // TODO
|
|
||||||
@@ -1 +1,8 @@
|
|||||||
<?php // TODO
|
<?php
|
||||||
|
define('APP_ENV', 'prod');
|
||||||
|
define('ASSET_VERSION', 'dev-' . date('Ymd-His'));
|
||||||
|
define('APP_DOMAIN_PRIMARY', APP_DOMAIN_NAME);
|
||||||
|
define('APP_URL_PRIMARY', 'https://' . APP_DOMAIN_PRIMARY);
|
||||||
|
define('APP_API_BASE', 'https://api.' . APP_DOMAIN_PRIMARY);
|
||||||
|
define('APP_DB_ENABLED', true); // set true to enable DB connection
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,93 @@
|
|||||||
<?php
|
<?php
|
||||||
return [
|
declare(strict_types=1);
|
||||||
'db_host' => 'localhost',
|
|
||||||
'db_name' => 'd0453540',
|
/**
|
||||||
'db_user' => 'd0453540',
|
* config/db.php
|
||||||
'db_pass' => 'P6jGRrSaX8QSiBMEJBL7',
|
*
|
||||||
'db_charset' => 'utf8mb4',
|
* - Choose ONE driver below (others stay commented).
|
||||||
|
* - Each driver has its own config section.
|
||||||
|
* - The file returns ONE normalized array used by Database::createPdo().
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 1) Driver selection (choose one)
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
//$driver = 'pqsql';
|
||||||
|
$driver = 'mysql';
|
||||||
|
// $driver = 'sqlite';
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 2) Driver-specific configuration sections
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
// ---- PostgreSQL (PDO driver: pgsql) -------------------------
|
||||||
|
$pgsql = [
|
||||||
|
'driver' => 'pgsql',
|
||||||
|
'host' => 'localhost',
|
||||||
|
'port' => 5432,
|
||||||
|
'dbname' => 'mydb',
|
||||||
|
|
||||||
|
// optional: schema/search_path (commonly "public")
|
||||||
|
'schema' => 'public',
|
||||||
|
|
||||||
|
'user' => 'myuser',
|
||||||
|
'password' => 'secret',
|
||||||
|
|
||||||
|
'options' => [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ---- MySQL / MariaDB (PDO driver: mysql) -------------------
|
||||||
|
$mysql = [
|
||||||
|
'driver' => 'mysql',
|
||||||
|
'host' => 'localhost',
|
||||||
|
'port' => 3306,
|
||||||
|
'dbname' => 'd0453540',
|
||||||
|
'charset' => 'utf8mb4',
|
||||||
|
|
||||||
|
// Alternative to host/port:
|
||||||
|
// 'unix_socket' => '/var/run/mysqld/mysqld.sock',
|
||||||
|
|
||||||
|
'user' => 'd0453540',
|
||||||
|
'password' => 'P6jGRrSaX8QSiBMEJBL7',
|
||||||
|
|
||||||
|
'options' => [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---- SQLite (PDO driver: sqlite) ---------------------------
|
||||||
|
$sqlite = [
|
||||||
|
'driver' => 'sqlite',
|
||||||
|
|
||||||
|
// Use an absolute path in production, e.g. /var/app/data/app.sqlite
|
||||||
|
// For demo/dev you can use a relative path.
|
||||||
|
'path' => __DIR__ . '/../var/app.sqlite',
|
||||||
|
|
||||||
|
// SQLite ignores host/port/user/pass
|
||||||
|
'options' => [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 3) Select and return config
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
switch ($driver) {
|
||||||
|
case 'pgsql':
|
||||||
|
return $pgsql;
|
||||||
|
|
||||||
|
case 'mysql':
|
||||||
|
return $mysql;
|
||||||
|
|
||||||
|
case 'sqlite':
|
||||||
|
return $sqlite;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new RuntimeException('Unsupported DB driver in config/db.php: ' . $driver);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,12 @@
|
|||||||
<?php // TODO
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Example: a single "brand" domain name.
|
||||||
|
// In real deployments you might derive this from ENV or hostnames.
|
||||||
|
if (!defined('APP_DOMAIN_NAME')) {
|
||||||
|
define('APP_DOMAIN_NAME', 'staging.shape3d.it');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defined('APP_PREFIX')) {
|
||||||
|
define('APP_PREFIX', 'miniapp');
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<?php // TODO
|
|
||||||
@@ -1 +1,8 @@
|
|||||||
<?php // TODO
|
<?php
|
||||||
|
define('APP_ENV', 'staging');
|
||||||
|
define('ASSET_VERSION', 'dev-' . date('Ymd-His'));
|
||||||
|
define('APP_DOMAIN_PRIMARY', APP_DOMAIN_NAME);
|
||||||
|
define('APP_URL_PRIMARY', 'https://' . APP_DOMAIN_PRIMARY);
|
||||||
|
define('APP_API_BASE', 'https://api.' . APP_DOMAIN_PRIMARY);
|
||||||
|
define('APP_DB_ENABLED', true); // set true to enable DB connection
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
Demo männ
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1,237 +1,69 @@
|
|||||||
<div class="max-w-6xl mx-auto py-6 space-y-6 px-4">
|
<?php
|
||||||
<header class="flex items-center justify-between gap-4">
|
$app = app();
|
||||||
|
$app->assets()->addStyle('/assets/app.css', 'early');
|
||||||
|
$app->assets()->addScript('/assets/app.js', 'footer', true);
|
||||||
|
?>
|
||||||
|
|
||||||
|
<section class="mm-shell">
|
||||||
|
<header class="mm-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-slate-900">3D-Druck Materialmatrix</h1>
|
<p class="mm-kicker">Materialmatrix</p>
|
||||||
<p class="text-xs text-slate-500">Schnell prüfen, welche Filamente auf welchen Druckern laufen.</p>
|
<h1 class="mm-title"><?= htmlspecialchars(t('common.title'), ENT_QUOTES) ?></h1>
|
||||||
|
<p class="mm-subtitle">Schnell prüfen, welche Filamente auf welchen Druckern laufen.</p>
|
||||||
</div>
|
</div>
|
||||||
<span id="status" class="text-xs text-slate-500"></span>
|
<div class="mm-status" id="status">Bereit</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="bg-white/80 backdrop-blur rounded-lg shadow flex gap-6 p-4 min-h-[420px]">
|
<div class="mm-card">
|
||||||
<!-- Sidebar -->
|
<aside class="mm-sidebar">
|
||||||
<aside class="w-72 space-y-5 border-r pr-4 border-slate-100">
|
<div class="mm-panel">
|
||||||
<div>
|
<h2>Drucker auswählen</h2>
|
||||||
<h2 class="text-sm font-semibold text-slate-700 mb-2">Drucker auswählen</h2>
|
<label for="printerSelect">Einzelansicht</label>
|
||||||
<label class="block text-xs font-medium mb-1 text-slate-600">Einzelansicht</label>
|
<select id="printerSelect">
|
||||||
<select id="printerSelect" class="w-full border rounded px-2 py-1 mb-2 text-sm">
|
|
||||||
<option value="">– wird geladen –</option>
|
<option value="">– wird geladen –</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="text-xs text-slate-500">
|
<p>Zeigt die Kompatibilität nur für diesen Drucker.</p>
|
||||||
Zeigt die Kompatibilität nur für diesen Drucker.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="mm-panel">
|
||||||
<label class="block text-xs font-medium mb-1 text-slate-600">Vergleich (mehrere)</label>
|
<label for="printerCompare">Vergleich (mehrere)</label>
|
||||||
<select id="printerCompare" multiple class="w-full border rounded px-2 py-1 h-32 text-sm"></select>
|
<select id="printerCompare" multiple></select>
|
||||||
<p class="text-xs text-slate-500">
|
<p>Strg/⌘ gedrückt halten, um mehrere zu wählen.</p>
|
||||||
Strg/⌘ gedrückt halten, um mehrere zu wählen.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-xs text-slate-400">
|
<div class="mm-tip">
|
||||||
Tipp: Im Vergleich werden die ausgewählten Drucker rechts als separate Spalten eingefärbt.
|
Tipp: Im Vergleich werden die ausgewählten Drucker rechts als separate Spalten hervorgehoben.
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Main -->
|
<main class="mm-main">
|
||||||
<main class="flex-1 flex flex-col gap-3">
|
<div class="mm-table-wrap" id="tableWrap">
|
||||||
<div class="overflow-auto max-h-[70vh] rounded border bg-white" id="tableWrap">
|
<table id="matTable" class="mm-table">
|
||||||
<table class="min-w-full text-sm" id="matTable">
|
<thead>
|
||||||
<thead class="bg-slate-50">
|
|
||||||
<tr id="tableHead">
|
<tr id="tableHead">
|
||||||
<th class="px-3 py-2 text-left">Material</th>
|
<th>Material</th>
|
||||||
<th class="px-3 py-2 text-left">Eigenschaften</th>
|
<th>Eigenschaften</th>
|
||||||
<th class="px-3 py-2 text-left">Tg °C</th>
|
<th>Tg °C</th>
|
||||||
<th class="px-3 py-2 text-left">Düse</th>
|
<th>Düse</th>
|
||||||
<th class="px-3 py-2 text-left">Platte</th>
|
<th>Platte</th>
|
||||||
<th class="px-3 py-2 text-left">Zusatz</th>
|
<th>Zusatz</th>
|
||||||
<th class="px-3 py-2 text-left">Anwendung</th>
|
<th>Anwendung</th>
|
||||||
<th class="px-3 py-2 text-left">Kinder</th>
|
<th>Kinder</th>
|
||||||
<th class="px-3 py-2 text-left">Emission</th>
|
<th>Emission</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="matBody"></tbody>
|
<tbody id="matBody"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="errorBox" class="hidden rounded bg-rose-50 border border-rose-200 text-rose-700 text-sm px-3 py-2"></div>
|
<div id="errorBox" class="mm-error" hidden></div>
|
||||||
|
|
||||||
<!-- Hinweisblock unten -->
|
<section class="mm-disclaimer">
|
||||||
<section id="disclaimer" class="mt-2 rounded-lg border border-slate-200 bg-slate-50 p-4 text-xs text-slate-700 leading-snug">
|
<p><strong>Hinweis:</strong> Dieses Projekt wird privat betrieben und befindet sich im Aufbau.
|
||||||
<p>
|
|
||||||
<strong>Hinweis:</strong> Dieses Projekt wird privat betrieben und befindet sich im Aufbau.
|
|
||||||
Es sind noch nicht alle Drucker und Materialien eingetragen.
|
Es sind noch nicht alle Drucker und Materialien eingetragen.
|
||||||
Alle Angaben erfolgen nach bestem Wissen, jedoch <u>ohne Gewähr auf Vollständigkeit oder Richtigkeit</u>.
|
Alle Angaben erfolgen nach bestem Wissen, jedoch <u>ohne Gewähr auf Vollständigkeit oder Richtigkeit</u>.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<script>
|
|
||||||
const API_BASE = './api';
|
|
||||||
const printerSelect = document.getElementById('printerSelect');
|
|
||||||
const printerCompare = document.getElementById('printerCompare');
|
|
||||||
const matBody = document.getElementById('matBody');
|
|
||||||
const tableHead = document.getElementById('tableHead');
|
|
||||||
const statusEl = document.getElementById('status');
|
|
||||||
const errorBox = document.getElementById('errorBox');
|
|
||||||
|
|
||||||
function showError(msg) {
|
|
||||||
errorBox.textContent = msg;
|
|
||||||
errorBox.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
function clearError() {
|
|
||||||
errorBox.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchJSON(url) {
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) throw new Error('HTTP ' + res.status + ' for ' + url);
|
|
||||||
return await res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadPrinters() {
|
|
||||||
try {
|
|
||||||
statusEl.textContent = 'Lade Drucker …';
|
|
||||||
const data = await fetchJSON(`${API_BASE}/printers.php`);
|
|
||||||
printerSelect.innerHTML = '';
|
|
||||||
printerCompare.innerHTML = '';
|
|
||||||
if (!data.length) {
|
|
||||||
printerSelect.innerHTML = '<option value="">(keine Drucker gefunden)</option>';
|
|
||||||
statusEl.textContent = 'Keine Drucker gefunden';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
data.forEach(p => {
|
|
||||||
printerSelect.appendChild(new Option(p.name, p.id));
|
|
||||||
printerCompare.appendChild(new Option(p.name, p.id));
|
|
||||||
});
|
|
||||||
// ersten Drucker anzeigen
|
|
||||||
loadSinglePrinter(data[0].id);
|
|
||||||
statusEl.textContent = 'Drucker geladen';
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
showError('Konnte Drucker nicht laden. Prüfe public/api/printers.php.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSinglePrinter(id) {
|
|
||||||
if (!id) return;
|
|
||||||
clearError();
|
|
||||||
statusEl.textContent = 'Lade Materialien …';
|
|
||||||
try {
|
|
||||||
const data = await fetchJSON(`${API_BASE}/printer-materials.php?id=${id}`);
|
|
||||||
renderTable([data]);
|
|
||||||
statusEl.textContent = 'Fertig';
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
showError('Konnte Materialien für Drucker nicht laden.');
|
|
||||||
statusEl.textContent = 'Fehler';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMultiplePrinters(ids) {
|
|
||||||
if (!ids.length) return;
|
|
||||||
clearError();
|
|
||||||
statusEl.textContent = 'Lade Vergleich …';
|
|
||||||
try {
|
|
||||||
const datasets = await Promise.all(ids.map(id => fetchJSON(`${API_BASE}/printer-materials.php?id=${id}`)));
|
|
||||||
renderTable(datasets);
|
|
||||||
statusEl.textContent = 'Vergleich geladen';
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
showError('Konnte einen der gewählten Drucker nicht laden.');
|
|
||||||
statusEl.textContent = 'Fehler';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTable(datasets) {
|
|
||||||
// Kopf neu aufbauen
|
|
||||||
const baseHead = `
|
|
||||||
<th class="px-3 py-2 text-left">Material</th>
|
|
||||||
<th class="px-3 py-2 text-left">Eigenschaften</th>
|
|
||||||
<th class="px-3 py-2 text-left">Tg °C</th>
|
|
||||||
<th class="px-3 py-2 text-left">Düse</th>
|
|
||||||
<th class="px-3 py-2 text-left">Platte</th>
|
|
||||||
<th class="px-3 py-2 text-left">Zusatz</th>
|
|
||||||
<th class="px-3 py-2 text-left">Anwendung</th>
|
|
||||||
<th class="px-3 py-2 text-left">Kinder</th>
|
|
||||||
<th class="px-3 py-2 text-left">Emission</th>
|
|
||||||
`;
|
|
||||||
let printerCols = '';
|
|
||||||
datasets.forEach(ds => {
|
|
||||||
printerCols += `<th class="px-3 py-2 text-left bg-slate-100" data-printer="${ds.printer.id}">${ds.printer.name}</th>`;
|
|
||||||
});
|
|
||||||
tableHead.innerHTML = baseHead + printerCols;
|
|
||||||
|
|
||||||
const materials = datasets[0]?.materials ?? [];
|
|
||||||
matBody.innerHTML = '';
|
|
||||||
|
|
||||||
materials.forEach((m, idx) => {
|
|
||||||
const tr = document.createElement('tr');
|
|
||||||
tr.className = idx % 2 === 0 ? 'bg-white' : 'bg-slate-50/60';
|
|
||||||
|
|
||||||
const kid = m.kid_safety === 'safe' ? '🌿' : (m.kid_safety === 'limited' ? '🟡' : '🔴');
|
|
||||||
const em = m.emission === 'low' ? '✅' : (m.emission === 'medium' ? '⚠️' : '⛔');
|
|
||||||
|
|
||||||
let html = `
|
|
||||||
<td class="px-3 py-2 font-medium">${m.code}<div class="text-xs text-slate-500">${m.short_desc ?? ''}</div></td>
|
|
||||||
<td class="px-3 py-2">${m.properties ?? ''}</td>
|
|
||||||
<td class="px-3 py-2">${m.tg_celsius ?? '–'}</td>
|
|
||||||
<td class="px-3 py-2">${m.nozzle_req ?? ''}</td>
|
|
||||||
<td class="px-3 py-2">${m.plate_req ?? ''}</td>
|
|
||||||
<td class="px-3 py-2">${m.extra_req ?? ''}</td>
|
|
||||||
<td class="px-3 py-2">${m.application ?? ''}</td>
|
|
||||||
<td class="px-3 py-2">${kid}</td>
|
|
||||||
<td class="px-3 py-2">${em}</td>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Drucker-Spalten
|
|
||||||
datasets.forEach(ds => {
|
|
||||||
const match = ds.materials.find(x => x.id === m.id || x.code === m.code);
|
|
||||||
if (!match || !match.support_level) {
|
|
||||||
html += `<td class="px-3 py-2" data-printer="${ds.printer.id}"><span class="px-2 py-1 rounded bg-slate-200 text-xs">unbekannt</span></td>`;
|
|
||||||
} else {
|
|
||||||
let badge = '';
|
|
||||||
if (match.support_level === 'full') {
|
|
||||||
badge = '<span class="px-2 py-1 rounded bg-green-100 text-green-800 text-xs">✓ voll</span>';
|
|
||||||
} else if (match.support_level === 'partial') {
|
|
||||||
badge = '<span class="px-2 py-1 rounded bg-amber-100 text-amber-800 text-xs">⚠ teilw.</span>';
|
|
||||||
} else if (match.support_level === 'with_addon') {
|
|
||||||
badge = '<span class="px-2 py-1 rounded bg-sky-100 text-sky-800 text-xs">⚙ Zusatz</span>';
|
|
||||||
} else {
|
|
||||||
badge = '<span class="px-2 py-1 rounded bg-rose-100 text-rose-800 text-xs">✗ nein</span>';
|
|
||||||
}
|
|
||||||
const note = match.partial_reason
|
|
||||||
? `<div class="text-xs text-slate-400">${match.partial_reason}</div>`
|
|
||||||
: (match.extra_info ? `<div class="text-xs text-slate-400">${match.extra_info}</div>` : '');
|
|
||||||
html += `<td class="px-3 py-2" data-printer="${ds.printer.id}">${badge}${note}</td>`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tr.innerHTML = html;
|
|
||||||
matBody.appendChild(tr);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Events
|
|
||||||
printerSelect.addEventListener('change', e => {
|
|
||||||
const id = e.target.value;
|
|
||||||
if (id) {
|
|
||||||
loadSinglePrinter(id);
|
|
||||||
// Vergleich leeren
|
|
||||||
printerCompare.selectedIndex = -1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
printerCompare.addEventListener('change', e => {
|
|
||||||
const ids = Array.from(e.target.selectedOptions).map(o => o.value);
|
|
||||||
if (ids.length) {
|
|
||||||
loadMultiplePrinters(ids);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start
|
|
||||||
loadPrinters();
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
<?php
|
<?php asset_scripts('footer'); ?>
|
||||||
// Basic layout end.
|
</body>
|
||||||
?>
|
|
||||||
<footer class="mt-10 border-t border-slate-200 py-6 text-center text-xs text-slate-500">
|
|
||||||
3D-Druck Materialmatrix · intern
|
|
||||||
</footer>
|
|
||||||
<?php require __DIR__ . '/matomo.php'; ?>
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,29 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
// Basic layout start.
|
/** @var \App\App $app */
|
||||||
?><!doctype html>
|
$app = app();
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>3D-Druck Materialmatrix</title>
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<title><?= htmlspecialchars(t('common.title'), ENT_QUOTES) ?></title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<?php asset_styles(); ?>
|
||||||
<style>
|
<?php asset_scripts('header'); ?>
|
||||||
body {
|
</head>
|
||||||
background: radial-gradient(circle at top, #e2e8f0 0%, #f8fafc 45%, #e2e8f0 90%);
|
<body class="app-body">
|
||||||
}
|
|
||||||
thead th {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
}
|
|
||||||
th[data-printer],
|
|
||||||
td[data-printer] {
|
|
||||||
background: rgba(148, 163, 184, 0.05);
|
|
||||||
}
|
|
||||||
#disclaimer {
|
|
||||||
box-shadow: inset 0 1px 0 rgba(148, 163, 184, 0.25);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="bg-slate-100 min-h-screen">
|
|
||||||
<?php require __DIR__ . '/nav.php'; ?>
|
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
<?php
|
|
||||||
// Matomo tracking can be added here if needed.
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
$activePage = $activePage ?? '';
|
|
||||||
?>
|
|
||||||
<header class="bg-white/80 backdrop-blur border-b border-slate-200">
|
|
||||||
<div class="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between">
|
|
||||||
<div class="text-sm font-semibold text-slate-800">3D-Druck Materialmatrix</div>
|
|
||||||
<nav class="text-xs text-slate-500">
|
|
||||||
<span class="<?= $activePage === 'dashboard' ? 'text-slate-900 font-semibold' : '' ?>">Dashboard</span>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
@@ -1 +1,32 @@
|
|||||||
# TODO
|
# -------------------------------------------------
|
||||||
|
# Apache Front Controller Setup (public/.htaccess)
|
||||||
|
# -------------------------------------------------
|
||||||
|
|
||||||
|
RewriteEngine On
|
||||||
|
|
||||||
|
# Sicherheit: keine Directory Listings
|
||||||
|
Options -Indexes
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# 1) Assets DIREKT ausliefern
|
||||||
|
# -------------------------------------------------
|
||||||
|
RewriteRule ^assets/ - [L]
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# 2) page/ von außen sperren (nur intern per require nutzbar)
|
||||||
|
# -------------------------------------------------
|
||||||
|
RewriteRule ^page/ - [F,L]
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# 3) Alles andere an den Front Controller
|
||||||
|
# -------------------------------------------------
|
||||||
|
RewriteRule ^ index.php [L]
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# 4) (Optional) Zusätzliche Sicherheits-Header
|
||||||
|
# -------------------------------------------------
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
Header set X-Frame-Options "SAMEORIGIN"
|
||||||
|
Header set X-Content-Type-Options "nosniff"
|
||||||
|
Header set Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
</IfModule>
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
<?php
|
|
||||||
try {
|
|
||||||
require_once __DIR__ . '/../../tools/db.php';
|
|
||||||
$pdo = tools_build_pdo();
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
http_response_code(500);
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
|
||||||
$payload = ['error' => 'DB connection failed'];
|
|
||||||
if (getenv('APP_DEBUG') === '1') {
|
|
||||||
$payload['detail'] = $e->getMessage();
|
|
||||||
}
|
|
||||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?php
|
|
||||||
// public/api/materials.php
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
|
||||||
require __DIR__ . '/_db.php';
|
|
||||||
$stmt = $pdo->query("SELECT * FROM materials WHERE is_active = 1 ORDER BY code");
|
|
||||||
echo json_encode($stmt->fetchAll(), JSON_UNESCAPED_UNICODE);
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<?php
|
|
||||||
// public/api/printer-materials.php?id={printer_id}
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
|
||||||
require __DIR__ . '/_db.php';
|
|
||||||
|
|
||||||
$printer_id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
|
||||||
if ($printer_id <= 0) {
|
|
||||||
http_response_code(400);
|
|
||||||
echo json_encode(['error' => 'printer id missing']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$printerStmt = $pdo->prepare("SELECT * FROM printers WHERE id = ?");
|
|
||||||
$printerStmt->execute([$printer_id]);
|
|
||||||
$printer = $printerStmt->fetch();
|
|
||||||
if (!$printer) {
|
|
||||||
http_response_code(404);
|
|
||||||
echo json_encode(['error' => 'printer not found']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sql = "SELECT m.*, pms.support_level, pms.partial_reason, pms.extra_info
|
|
||||||
FROM materials m
|
|
||||||
LEFT JOIN printer_material_support pms
|
|
||||||
ON pms.material_id = m.id AND pms.printer_id = :pid
|
|
||||||
WHERE m.is_active = 1
|
|
||||||
ORDER BY m.code";
|
|
||||||
$stmt = $pdo->prepare($sql);
|
|
||||||
$stmt->execute([':pid' => $printer_id]);
|
|
||||||
$materials = $stmt->fetchAll();
|
|
||||||
|
|
||||||
echo json_encode([
|
|
||||||
'printer' => $printer,
|
|
||||||
'materials' => $materials
|
|
||||||
], JSON_UNESCAPED_UNICODE);
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<?php
|
|
||||||
// public/api/printers.php
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
|
||||||
require __DIR__ . '/_db.php';
|
|
||||||
require_once __DIR__ . '/../../tools/printers.php';
|
|
||||||
$printers = tools_fetch_active_printers($pdo);
|
|
||||||
echo json_encode($printers, JSON_UNESCAPED_UNICODE);
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
235
public/assets/app.css
Normal file
235
public/assets/app.css
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #f5efe6;
|
||||||
|
--bg-accent: #eef1f8;
|
||||||
|
--ink: #1d1d1f;
|
||||||
|
--muted: #5b5b64;
|
||||||
|
--card: #ffffff;
|
||||||
|
--line: #e2e2e8;
|
||||||
|
--accent: #ffb454;
|
||||||
|
--accent-dark: #eb7b1c;
|
||||||
|
--ok: #1f8a4c;
|
||||||
|
--warn: #d97706;
|
||||||
|
--no: #b91c1c;
|
||||||
|
--shadow: 0 22px 60px rgba(20, 20, 45, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
.app-body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Space Grotesk', system-ui, sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background: radial-gradient(circle at 20% 10%, rgba(255, 210, 150, 0.35), transparent 55%),
|
||||||
|
radial-gradient(circle at 90% 5%, rgba(185, 221, 255, 0.35), transparent 60%),
|
||||||
|
var(--bg);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-shell {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 3rem 2rem 4rem;
|
||||||
|
animation: fadeUp 0.6s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-kicker {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0 0 0.6rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-title {
|
||||||
|
font-size: clamp(2rem, 2.6vw, 2.8rem);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-subtitle {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
max-width: 36rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-status {
|
||||||
|
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 1.75rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
border-right: 1px dashed var(--line);
|
||||||
|
padding-right: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-panel h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-panel label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-panel select {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-panel p {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-tip {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--bg-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-table-wrap {
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fff;
|
||||||
|
max-height: 70vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-table thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: #f8f8fb;
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-table tbody td {
|
||||||
|
padding: 0.7rem 0.75rem;
|
||||||
|
border-bottom: 1px solid #f0f0f4;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-table tbody tr:nth-child(even) {
|
||||||
|
background: #fcfcff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-table tbody tr.is-alt {
|
||||||
|
background: #fcfcff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-table [data-printer] {
|
||||||
|
background: rgba(248, 247, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-tag.ok { background: rgba(31, 138, 76, 0.12); color: var(--ok); }
|
||||||
|
.mm-tag.warn { background: rgba(217, 119, 6, 0.12); color: var(--warn); }
|
||||||
|
.mm-tag.no { background: rgba(185, 28, 28, 0.12); color: var(--no); }
|
||||||
|
.mm-tag.addon { background: rgba(59, 130, 246, 0.12); color: #1d4ed8; }
|
||||||
|
|
||||||
|
.mm-sub {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-error {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(185, 28, 28, 0.25);
|
||||||
|
background: rgba(185, 28, 28, 0.08);
|
||||||
|
color: var(--no);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-disclaimer {
|
||||||
|
background: #f9fafc;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeUp {
|
||||||
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.mm-card {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.mm-sidebar {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px dashed var(--line);
|
||||||
|
padding-right: 0;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.mm-shell { padding: 2rem 1.25rem 3rem; }
|
||||||
|
.mm-header { flex-direction: column; align-items: flex-start; }
|
||||||
|
}
|
||||||
200
public/assets/app.js
Normal file
200
public/assets/app.js
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
(function () {
|
||||||
|
const API_BASE = '/api';
|
||||||
|
const printerSelect = document.getElementById('printerSelect');
|
||||||
|
const printerCompare = document.getElementById('printerCompare');
|
||||||
|
const matBody = document.getElementById('matBody');
|
||||||
|
const tableHead = document.getElementById('tableHead');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const errorBox = document.getElementById('errorBox');
|
||||||
|
|
||||||
|
if (!printerSelect || !printerCompare || !matBody || !tableHead) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(text) {
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.textContent = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
if (!errorBox) return;
|
||||||
|
errorBox.hidden = false;
|
||||||
|
errorBox.textContent = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearError() {
|
||||||
|
if (!errorBox) return;
|
||||||
|
errorBox.hidden = true;
|
||||||
|
errorBox.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJSON(path) {
|
||||||
|
const res = await fetch(API_BASE + path, { headers: { 'Accept': 'application/json' } });
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status + ' for ' + path);
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPrinters() {
|
||||||
|
try {
|
||||||
|
setStatus('Lade Drucker ...');
|
||||||
|
const data = await fetchJSON('/printers');
|
||||||
|
printerSelect.innerHTML = '';
|
||||||
|
printerCompare.innerHTML = '';
|
||||||
|
|
||||||
|
if (!Array.isArray(data) || data.length === 0) {
|
||||||
|
printerSelect.innerHTML = '<option value="">(keine Drucker gefunden)</option>';
|
||||||
|
setStatus('Keine Drucker gefunden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
data.forEach(p => {
|
||||||
|
printerSelect.appendChild(new Option(p.name, p.id));
|
||||||
|
printerCompare.appendChild(new Option(p.name, p.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
loadSinglePrinter(data[0].id);
|
||||||
|
setStatus('Drucker geladen');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
showError('Konnte Drucker nicht laden.');
|
||||||
|
setStatus('Fehler');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSinglePrinter(id) {
|
||||||
|
if (!id) return;
|
||||||
|
clearError();
|
||||||
|
setStatus('Lade Materialien ...');
|
||||||
|
try {
|
||||||
|
const data = await fetchJSON('/printer-materials?id=' + encodeURIComponent(id));
|
||||||
|
renderTable([data]);
|
||||||
|
setStatus('Fertig');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
showError('Konnte Materialien für den Drucker nicht laden.');
|
||||||
|
setStatus('Fehler');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMultiplePrinters(ids) {
|
||||||
|
if (!ids.length) return;
|
||||||
|
clearError();
|
||||||
|
setStatus('Lade Vergleich ...');
|
||||||
|
try {
|
||||||
|
const datasets = await Promise.all(
|
||||||
|
ids.map(id => fetchJSON('/printer-materials?id=' + encodeURIComponent(id)))
|
||||||
|
);
|
||||||
|
renderTable(datasets);
|
||||||
|
setStatus('Vergleich geladen');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
showError('Konnte einen der gewählten Drucker nicht laden.');
|
||||||
|
setStatus('Fehler');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable(datasets) {
|
||||||
|
const baseHead = [
|
||||||
|
'Material',
|
||||||
|
'Eigenschaften',
|
||||||
|
'Tg °C',
|
||||||
|
'Düse',
|
||||||
|
'Platte',
|
||||||
|
'Zusatz',
|
||||||
|
'Anwendung',
|
||||||
|
'Kinder',
|
||||||
|
'Emission'
|
||||||
|
].map(label => `<th>${label}</th>`).join('');
|
||||||
|
|
||||||
|
let printerCols = '';
|
||||||
|
datasets.forEach(ds => {
|
||||||
|
if (ds && ds.printer) {
|
||||||
|
printerCols += `<th data-printer="${escapeHtml(ds.printer.id)}">${escapeHtml(ds.printer.name)}</th>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tableHead.innerHTML = baseHead + printerCols;
|
||||||
|
|
||||||
|
const materials = (datasets[0] && datasets[0].materials) ? datasets[0].materials : [];
|
||||||
|
matBody.innerHTML = '';
|
||||||
|
|
||||||
|
if (!materials.length) {
|
||||||
|
const empty = document.createElement('tr');
|
||||||
|
empty.innerHTML = '<td colspan="12">Keine Materialien gefunden.</td>';
|
||||||
|
matBody.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
materials.forEach((m, idx) => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.className = idx % 2 === 0 ? '' : 'is-alt';
|
||||||
|
|
||||||
|
const kid = m.kid_safety === 'safe' ? 'grün' : (m.kid_safety === 'limited' ? 'gelb' : 'rot');
|
||||||
|
const em = m.emission === 'low' ? 'niedrig' : (m.emission === 'medium' ? 'mittel' : 'hoch');
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
html += `<td><strong>${escapeHtml(m.code)}</strong><div class="mm-sub">${escapeHtml(m.short_desc || '')}</div></td>`;
|
||||||
|
html += `<td>${escapeHtml(m.properties || '')}</td>`;
|
||||||
|
html += `<td>${escapeHtml(m.tg_celsius || '–')}</td>`;
|
||||||
|
html += `<td>${escapeHtml(m.nozzle_req || '')}</td>`;
|
||||||
|
html += `<td>${escapeHtml(m.plate_req || '')}</td>`;
|
||||||
|
html += `<td>${escapeHtml(m.extra_req || '')}</td>`;
|
||||||
|
html += `<td>${escapeHtml(m.application || '')}</td>`;
|
||||||
|
html += `<td>${escapeHtml(kid)}</td>`;
|
||||||
|
html += `<td>${escapeHtml(em)}</td>`;
|
||||||
|
|
||||||
|
datasets.forEach(ds => {
|
||||||
|
const printerId = ds && ds.printer ? ds.printer.id : '';
|
||||||
|
const match = ds.materials.find(x => x.id === m.id || x.code === m.code);
|
||||||
|
if (!match || !match.support_level) {
|
||||||
|
html += `<td data-printer="${escapeHtml(printerId)}"><span class="mm-tag">unbekannt</span></td>`;
|
||||||
|
} else {
|
||||||
|
let badge = '';
|
||||||
|
if (match.support_level === 'full') {
|
||||||
|
badge = '<span class="mm-tag ok">voll</span>';
|
||||||
|
} else if (match.support_level === 'partial') {
|
||||||
|
badge = '<span class="mm-tag warn">teilw.</span>';
|
||||||
|
} else if (match.support_level === 'with_addon') {
|
||||||
|
badge = '<span class="mm-tag addon">Zusatz</span>';
|
||||||
|
} else {
|
||||||
|
badge = '<span class="mm-tag no">nein</span>';
|
||||||
|
}
|
||||||
|
const note = match.partial_reason
|
||||||
|
? `<div class="mm-sub">${escapeHtml(match.partial_reason)}</div>`
|
||||||
|
: (match.extra_info ? `<div class="mm-sub">${escapeHtml(match.extra_info)}</div>` : '');
|
||||||
|
html += `<td data-printer="${escapeHtml(printerId)}">${badge}${note}</td>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tr.innerHTML = html;
|
||||||
|
matBody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
printerSelect.addEventListener('change', e => {
|
||||||
|
const id = e.target.value;
|
||||||
|
if (id) {
|
||||||
|
loadSinglePrinter(id);
|
||||||
|
printerCompare.selectedIndex = -1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
printerCompare.addEventListener('change', e => {
|
||||||
|
const ids = Array.from(e.target.selectedOptions).map(o => o.value);
|
||||||
|
if (ids.length) {
|
||||||
|
loadMultiplePrinters(ids);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loadPrinters();
|
||||||
|
})();
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1,2 +1,88 @@
|
|||||||
<?php
|
<?php
|
||||||
require __DIR__ . '/page/dashboard.php';
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// boot application (config, autoload, services)
|
||||||
|
require_once __DIR__ . '/../config/fileload.php';
|
||||||
|
|
||||||
|
// Staging-Access-Protection (Basic Auth)
|
||||||
|
$uriPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||||
|
$uriPath = preg_replace('~/{2,}~', '/', $uriPath);
|
||||||
|
$uriPath = trim($uriPath, '/');
|
||||||
|
$isRetoolPath = ($uriPath === 'retool' || str_starts_with($uriPath, 'retool/'));
|
||||||
|
if (defined('APP_ENV') && APP_ENV === 'staging' && !$isRetoolPath) {
|
||||||
|
$authUser = getenv('STAGING_AUTH_USER') ?: 'staging';
|
||||||
|
$authPass = getenv('STAGING_AUTH_PASS') ?: 'staging123';
|
||||||
|
$user = $_SERVER['PHP_AUTH_USER'] ?? null;
|
||||||
|
$pass = $_SERVER['PHP_AUTH_PW'] ?? null;
|
||||||
|
if ($user !== $authUser || $pass !== $authPass) {
|
||||||
|
header('WWW-Authenticate: Basic realm="Staging"');
|
||||||
|
header('HTTP/1.0 401 Unauthorized');
|
||||||
|
echo 'Unauthorized';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sicherheitscheck
|
||||||
|
if (str_contains($uriPath, '..')) {
|
||||||
|
http_response_code(400);
|
||||||
|
exit('Bad request');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root → page/index.php
|
||||||
|
if ($uriPath === '' || $uriPath === 'index' || $uriPath === 'index.php') {
|
||||||
|
$target = __DIR__ . '/page/index.php';
|
||||||
|
} else {
|
||||||
|
$base = __DIR__ . '/page/' . $uriPath;
|
||||||
|
// 1) Verzeichnis mit index.php
|
||||||
|
if (is_dir($base) && is_file($base . '/index.php')) {
|
||||||
|
$target = $base . '/index.php';
|
||||||
|
}
|
||||||
|
// 2) Datei
|
||||||
|
elseif (is_file($base . '.php')) {
|
||||||
|
|
||||||
|
$target = $base . '.php';
|
||||||
|
}
|
||||||
|
// 3) 404
|
||||||
|
elseif (is_file($base)) {
|
||||||
|
|
||||||
|
$target = $base;
|
||||||
|
}
|
||||||
|
// 3) 404
|
||||||
|
else {
|
||||||
|
http_response_code(404);
|
||||||
|
$target = __DIR__ . '/page/404.php';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ------------------------------------
|
||||||
|
// Layout-Regel
|
||||||
|
// ------------------------------------
|
||||||
|
$skipLayout = false;
|
||||||
|
$targetReal = realpath($target);
|
||||||
|
|
||||||
|
// Beispiel: alles unter /page/raw/* ohne Layout
|
||||||
|
if ($targetReal && str_starts_with($targetReal, realpath(__DIR__ . '/page/retool'))) {
|
||||||
|
$skipLayout = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------
|
||||||
|
// Ausgabe
|
||||||
|
// ------------------------------------
|
||||||
|
// Erst Inhalt laden (ohne Ausgabe), damit Header/Redirects vor HTML funktionieren
|
||||||
|
ob_start();
|
||||||
|
require $target;
|
||||||
|
$content = ob_get_clean();
|
||||||
|
|
||||||
|
// Wenn bereits Header gesendet wurden (z. B. eigener Redirect/Content-Type), Layout überspringen
|
||||||
|
if (headers_sent()) {
|
||||||
|
$skipLayout = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$skipLayout) {
|
||||||
|
tpl('layout_start', 'structure');
|
||||||
|
}
|
||||||
|
|
||||||
|
echo $content;
|
||||||
|
|
||||||
|
if (!$skipLayout) {
|
||||||
|
tpl('layout_end', 'structure');
|
||||||
|
}
|
||||||
|
|||||||
7
public/page/404.php
Normal file
7
public/page/404.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
http_response_code(404);
|
||||||
|
?>
|
||||||
|
<section class="mm-shell">
|
||||||
|
<h1 class="mm-title">Seite nicht gefunden</h1>
|
||||||
|
<p class="mm-subtitle">Die angeforderte Seite existiert nicht.</p>
|
||||||
|
</section>
|
||||||
19
public/page/api/materials.php
Normal file
19
public/page/api/materials.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$app = app();
|
||||||
|
$pdo = $app->pdo();
|
||||||
|
|
||||||
|
if (!$pdo) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => 'DB disabled or unavailable'], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$repo = new \App\Repository\MaterialMatrixRepository($pdo);
|
||||||
|
$materials = $repo->listActiveMaterials();
|
||||||
|
|
||||||
|
echo json_encode($materials, JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
37
public/page/api/printer-materials.php
Normal file
37
public/page/api/printer-materials.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$printerId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||||
|
if ($printerId <= 0) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'printer id missing'], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$app = app();
|
||||||
|
$pdo = $app->pdo();
|
||||||
|
|
||||||
|
if (!$pdo) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => 'DB disabled or unavailable'], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$repo = new \App\Repository\MaterialMatrixRepository($pdo);
|
||||||
|
$printer = $repo->getPrinterById($printerId);
|
||||||
|
|
||||||
|
if (!$printer) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['error' => 'printer not found'], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$materials = $repo->listMaterialsForPrinter($printerId);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'printer' => $printer,
|
||||||
|
'materials' => $materials,
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
19
public/page/api/printers.php
Normal file
19
public/page/api/printers.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$app = app();
|
||||||
|
$pdo = $app->pdo();
|
||||||
|
|
||||||
|
if (!$pdo) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => 'DB disabled or unavailable'], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$repo = new \App\Repository\MaterialMatrixRepository($pdo);
|
||||||
|
$printers = $repo->listActivePrinters();
|
||||||
|
|
||||||
|
echo json_encode($printers, JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?php
|
|
||||||
$activePage = 'dashboard';
|
|
||||||
require __DIR__ . '/../../partials/structure/layout_start.php';
|
|
||||||
require __DIR__ . '/../../partials/landing/main/material-matrix.php';
|
|
||||||
require __DIR__ . '/../../partials/structure/layout_end.php';
|
|
||||||
2
public/page/index.php
Normal file
2
public/page/index.php
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<?php
|
||||||
|
tpl('material-matrix', 'landing', 'main');
|
||||||
@@ -1 +0,0 @@
|
|||||||
dfdfassa
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
|
|||||||
@@ -1 +1,398 @@
|
|||||||
<?php // TODO
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class AccountPages
|
||||||
|
{
|
||||||
|
public static function register(App $app): array
|
||||||
|
{
|
||||||
|
$flash = $app->flash()->get();
|
||||||
|
$isLoggedIn = isset($_SESSION['user_id']);
|
||||||
|
$error = '';
|
||||||
|
$displayName = '';
|
||||||
|
$email = '';
|
||||||
|
|
||||||
|
if ($isLoggedIn) {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$displayName = trim((string)($_POST['display_name'] ?? ''));
|
||||||
|
$email = trim((string)($_POST['email'] ?? ''));
|
||||||
|
$password = (string)($_POST['password'] ?? '');
|
||||||
|
$password2 = (string)($_POST['password_confirm'] ?? '');
|
||||||
|
|
||||||
|
if ($password !== $password2) {
|
||||||
|
$error = 'Passwörter stimmen nicht überein.';
|
||||||
|
} elseif (strlen($password) < 8) {
|
||||||
|
$error = 'Passwort muss mindestens 8 Zeichen haben.';
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
$auth = new Auth($app);
|
||||||
|
$userId = $auth->register($displayName, $email, $password);
|
||||||
|
$code = $auth->createVerifyCode($userId, $email);
|
||||||
|
$mailer = new Mailer($app);
|
||||||
|
$mailer->sendTemplate('registration_confirm', $email, [
|
||||||
|
'code' => $code,
|
||||||
|
'display_name' => $displayName,
|
||||||
|
]);
|
||||||
|
$_SESSION['verify_email'] = $email;
|
||||||
|
$app->flash()->set('info', 'Bitte bestätige deine Registrierung mit dem Code aus der E-Mail.');
|
||||||
|
redirect('/verify');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$error = $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return compact('flash', 'error', 'displayName', 'email');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function login(App $app): array
|
||||||
|
{
|
||||||
|
$flash = $app->flash()->get();
|
||||||
|
$isLoggedIn = isset($_SESSION['user_id']);
|
||||||
|
$error = '';
|
||||||
|
$emailPrefill = '';
|
||||||
|
|
||||||
|
if ($isLoggedIn) {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$email = trim((string)($_POST['email'] ?? ''));
|
||||||
|
$emailPrefill = $email;
|
||||||
|
$password = (string)($_POST['password'] ?? '');
|
||||||
|
try {
|
||||||
|
$auth = new Auth($app);
|
||||||
|
$res = $auth->login($email, $password);
|
||||||
|
if ($res['status'] === 'pending') {
|
||||||
|
$code = $auth->createVerifyCode($res['id'], $email);
|
||||||
|
$mailer = new Mailer($app);
|
||||||
|
$mailer->sendTemplate('registration_confirm', $email, [
|
||||||
|
'code' => $code,
|
||||||
|
'display_name' => $email,
|
||||||
|
]);
|
||||||
|
$_SESSION['verify_email'] = $email;
|
||||||
|
$app->flash()->set('info', 'Bitte bestätige deine Registrierung mit dem Code aus der E-Mail.');
|
||||||
|
redirect('/verify');
|
||||||
|
}
|
||||||
|
$_SESSION['user_id'] = $res['id'];
|
||||||
|
$app->flash()->set('success', 'Erfolgreich angemeldet.');
|
||||||
|
redirect('/dashboard');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$error = $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return compact('flash', 'error', 'emailPrefill', 'isLoggedIn');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function verify(App $app): array
|
||||||
|
{
|
||||||
|
$pdo = $app->pdo();
|
||||||
|
$flash = $app->flash()->get();
|
||||||
|
$error = '';
|
||||||
|
$info = '';
|
||||||
|
$email = $_SESSION['verify_email'] ?? '';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$action = $_POST['action'] ?? 'verify';
|
||||||
|
$email = trim((string)($_POST['email'] ?? ''));
|
||||||
|
$code = strtoupper(trim((string)($_POST['code'] ?? '')));
|
||||||
|
$auth = new Auth($app);
|
||||||
|
$mailer = new Mailer($app);
|
||||||
|
|
||||||
|
if ($action === 'resend') {
|
||||||
|
try {
|
||||||
|
$stmt = $pdo?->prepare('SELECT id, display_name, status FROM users u JOIN user_profiles p ON p.user_id = u.id WHERE u.email = :email LIMIT 1');
|
||||||
|
$stmt?->execute(['email' => $email]);
|
||||||
|
$row = $stmt?->fetch(\PDO::FETCH_ASSOC);
|
||||||
|
if (!$row) {
|
||||||
|
throw new \RuntimeException('E-Mail nicht gefunden.');
|
||||||
|
}
|
||||||
|
$userId = (int)$row['id'];
|
||||||
|
$codeNew = $auth->createVerifyCode($userId, $email);
|
||||||
|
$mailer->sendTemplate('registration_resend_code', $email, [
|
||||||
|
'code' => $codeNew,
|
||||||
|
'display_name' => $row['display_name'] ?? '',
|
||||||
|
]);
|
||||||
|
$info = 'Neuer Code wurde versendet.';
|
||||||
|
$_SESSION['verify_email'] = $email;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$error = $e->getMessage();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
$userId = $auth->verifyCode($email, $code);
|
||||||
|
$_SESSION['user_id'] = $userId;
|
||||||
|
unset($_SESSION['verify_email']);
|
||||||
|
$mailer->sendTemplate('registration_welcome', $email, ['display_name' => $email]);
|
||||||
|
$app->flash()->set('success', 'Registrierung bestätigt. Willkommen!');
|
||||||
|
redirect('/dashboard');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$error = $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return compact('flash', 'error', 'info', 'email');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function dashboard(App $app): array
|
||||||
|
{
|
||||||
|
if (!isset($_SESSION['user_id'])) {
|
||||||
|
redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = $app->pdo();
|
||||||
|
$flash = $app->flash()->get();
|
||||||
|
$userId = (int)$_SESSION['user_id'];
|
||||||
|
$error = '';
|
||||||
|
$info = '';
|
||||||
|
$crypto = null;
|
||||||
|
try { $crypto = new Crypto($app->config()); } catch (\Throwable) {}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$action = $_POST['action'] ?? '';
|
||||||
|
try {
|
||||||
|
if ($action === 'profile') {
|
||||||
|
$languages = $_POST['languages'] ?? '';
|
||||||
|
if (is_array($languages)) {
|
||||||
|
$languages = implode(', ', array_map('trim', $languages));
|
||||||
|
}
|
||||||
|
$phoneEnc = $crypto ? $crypto->encrypt(trim((string)$_POST['contact_phone'])) : trim((string)$_POST['contact_phone']);
|
||||||
|
$stmt = $pdo?->prepare('UPDATE user_profiles SET display_name=:name, first_name=:fname, last_name=:lname, zip=:zip, city=:city, profession=:prof, languages=:langs, about=:about, contact_phone=:phone, updated_at=NOW() WHERE user_id=:id');
|
||||||
|
$stmt?->execute([
|
||||||
|
'name' => trim((string)$_POST['display_name']),
|
||||||
|
'fname' => trim((string)$_POST['first_name']),
|
||||||
|
'lname' => trim((string)$_POST['last_name']),
|
||||||
|
'zip' => trim((string)$_POST['zip']),
|
||||||
|
'city' => trim((string)$_POST['city']),
|
||||||
|
'prof' => trim((string)$_POST['profession']),
|
||||||
|
'langs' => trim((string)$languages),
|
||||||
|
'about' => trim((string)$_POST['about']),
|
||||||
|
'phone' => $phoneEnc,
|
||||||
|
'id' => $userId,
|
||||||
|
]);
|
||||||
|
$info = 'Profil gespeichert.';
|
||||||
|
} elseif ($action === 'child_add') {
|
||||||
|
$firstNameEnc = $crypto ? $crypto->encrypt(trim((string)$_POST['first_name'])) : trim((string)$_POST['first_name']);
|
||||||
|
$noteEnc = $crypto ? $crypto->encrypt(trim((string)$_POST['note'])) : trim((string)$_POST['note']);
|
||||||
|
$stmt = $pdo?->prepare('INSERT INTO children (user_id, gender, birthdate, age_years, encrypted_first_name, note, created_at, updated_at) VALUES (:uid, :gender, :birthdate, :age, :name, :note, NOW(), NOW())');
|
||||||
|
$stmt?->execute([
|
||||||
|
'uid' => $userId,
|
||||||
|
'gender' => $_POST['gender'] ?? 'unknown',
|
||||||
|
'birthdate' => $_POST['birthdate'] ?: null,
|
||||||
|
'age' => $_POST['age_years'] ?: null,
|
||||||
|
'name' => $firstNameEnc,
|
||||||
|
'note' => $noteEnc,
|
||||||
|
]);
|
||||||
|
$info = 'Kind hinzugefügt.';
|
||||||
|
} elseif ($action === 'event_add' || $action === 'event_update') {
|
||||||
|
$street = trim((string)($_POST['street'] ?? ''));
|
||||||
|
$zip = trim((string)($_POST['zip'] ?? ''));
|
||||||
|
$city = trim((string)($_POST['city'] ?? ''));
|
||||||
|
$region = trim((string)($_POST['region'] ?? ''));
|
||||||
|
$lat = isset($_POST['lat']) && $_POST['lat'] !== '' ? (float)$_POST['lat'] : null;
|
||||||
|
$lng = isset($_POST['lng']) && $_POST['lng'] !== '' ? (float)$_POST['lng'] : null;
|
||||||
|
$needsGeocode = ($lat === null || $lng === null || $region === '');
|
||||||
|
if ($needsGeocode) {
|
||||||
|
[$geoLat, $geoLng, $geoRegion] = self::geocodeAddress($street, $zip, $city, $region);
|
||||||
|
if ($lat === null) { $lat = $geoLat; }
|
||||||
|
if ($lng === null) { $lng = $geoLng; }
|
||||||
|
if ($region === '' && $geoRegion) { $region = $geoRegion; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'event_add') {
|
||||||
|
$stmt = $pdo?->prepare('INSERT INTO events (created_by, title, teaser_public, description, location_label, street, zip, city, region, lat, lng, starts_at, allow_kids, visibility, status, created_at, updated_at) VALUES (:uid, :title, :teaser, :descr, :loc, :street, :zip, :city, :region, :lat, :lng, :start, :allow, :vis, :status, NOW(), NOW())');
|
||||||
|
$stmt?->execute([
|
||||||
|
'uid' => $userId,
|
||||||
|
'title' => trim((string)$_POST['title']),
|
||||||
|
'teaser' => trim((string)$_POST['teaser']),
|
||||||
|
'descr' => trim((string)$_POST['description']),
|
||||||
|
'loc' => trim((string)$_POST['location_label']),
|
||||||
|
'street' => $street ?: null,
|
||||||
|
'zip' => $zip,
|
||||||
|
'city' => $city,
|
||||||
|
'region' => $region,
|
||||||
|
'lat' => $lat,
|
||||||
|
'lng' => $lng,
|
||||||
|
'start' => $_POST['starts_at'] ?? null,
|
||||||
|
'allow' => isset($_POST['allow_kids']) ? 0 : 1,
|
||||||
|
'vis' => $_POST['visibility'] ?? 'public',
|
||||||
|
'status' => 'published',
|
||||||
|
]);
|
||||||
|
$info = 'Event gespeichert.';
|
||||||
|
// Punkte für Event-Erstellung vergeben
|
||||||
|
try {
|
||||||
|
$cfgPath = dirname(__DIR__, 2) . '/config/community.php';
|
||||||
|
$communityCfg = file_exists($cfgPath) ? require $cfgPath : [];
|
||||||
|
$community = new Community($pdo, $communityCfg);
|
||||||
|
$community->addPoints($userId, 'event', 'create', ['event_id' => $pdo?->lastInsertId()]);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// still continue, points optional
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$eventId = (int)($_POST['event_id'] ?? 0);
|
||||||
|
$stmt = $pdo?->prepare('UPDATE events SET title=:title, teaser_public=:teaser, description=:descr, location_label=:loc, street=:street, zip=:zip, city=:city, region=:region, lat=:lat, lng=:lng, starts_at=:start, allow_kids=:allow, visibility=:vis, updated_at=NOW() WHERE id=:id AND created_by=:uid');
|
||||||
|
$stmt?->execute([
|
||||||
|
'id' => $eventId,
|
||||||
|
'uid' => $userId,
|
||||||
|
'title' => trim((string)$_POST['title']),
|
||||||
|
'teaser' => trim((string)$_POST['teaser']),
|
||||||
|
'descr' => trim((string)$_POST['description']),
|
||||||
|
'loc' => trim((string)$_POST['location_label']),
|
||||||
|
'street' => $street ?: null,
|
||||||
|
'zip' => $zip,
|
||||||
|
'city' => $city,
|
||||||
|
'region' => $region,
|
||||||
|
'lat' => $lat,
|
||||||
|
'lng' => $lng,
|
||||||
|
'start' => $_POST['starts_at'] ?? null,
|
||||||
|
'allow' => isset($_POST['allow_kids']) ? 0 : 1,
|
||||||
|
'vis' => $_POST['visibility'] ?? 'public',
|
||||||
|
]);
|
||||||
|
$info = 'Event aktualisiert.';
|
||||||
|
}
|
||||||
|
} elseif ($action === 'event_delete') {
|
||||||
|
$eventId = (int)($_POST['event_id'] ?? 0);
|
||||||
|
$stmt = $pdo?->prepare('SELECT id, created_by, status, (SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = events.id) AS participant_count FROM events WHERE id = :id LIMIT 1');
|
||||||
|
$stmt?->execute(['id' => $eventId]);
|
||||||
|
$ev = $stmt?->fetch(\PDO::FETCH_ASSOC);
|
||||||
|
if (!$ev || (int)$ev['created_by'] !== $userId) {
|
||||||
|
throw new \RuntimeException('Event nicht gefunden.');
|
||||||
|
}
|
||||||
|
if ((int)$ev['participant_count'] > 0) {
|
||||||
|
throw new \RuntimeException('Event hat Anmeldungen und kann nicht gelöscht werden.');
|
||||||
|
}
|
||||||
|
$pdo?->prepare('DELETE FROM events WHERE id = :id')->execute(['id' => $eventId]);
|
||||||
|
$info = 'Event gelöscht.';
|
||||||
|
} elseif ($action === 'event_cancel') {
|
||||||
|
$eventId = (int)($_POST['event_id'] ?? 0);
|
||||||
|
$stmt = $pdo?->prepare('SELECT id, created_by FROM events WHERE id = :id LIMIT 1');
|
||||||
|
$stmt?->execute(['id' => $eventId]);
|
||||||
|
$ev = $stmt?->fetch(\PDO::FETCH_ASSOC);
|
||||||
|
if (!$ev || (int)$ev['created_by'] !== $userId) {
|
||||||
|
throw new \RuntimeException('Event nicht gefunden.');
|
||||||
|
}
|
||||||
|
$pdo?->prepare('UPDATE events SET status = :st, updated_at = NOW() WHERE id = :id')->execute([
|
||||||
|
'st' => 'cancelled',
|
||||||
|
'id' => $eventId,
|
||||||
|
]);
|
||||||
|
$info = 'Event wurde abgesagt.';
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$error = $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Daten laden
|
||||||
|
$profile = [
|
||||||
|
'display_name' => '',
|
||||||
|
'first_name' => '',
|
||||||
|
'last_name' => '',
|
||||||
|
'zip' => '',
|
||||||
|
'city' => '',
|
||||||
|
'profession' => '',
|
||||||
|
'languages' => '',
|
||||||
|
'about' => '',
|
||||||
|
'email' => '',
|
||||||
|
'contact_phone' => '',
|
||||||
|
];
|
||||||
|
$stmt = $pdo?->prepare('SELECT u.email, u.status, p.display_name, p.first_name, p.last_name, p.zip, p.city, p.profession, p.languages, p.about, p.contact_phone FROM users u LEFT JOIN user_profiles p ON p.user_id = u.id WHERE u.id = :id LIMIT 1');
|
||||||
|
$stmt?->execute(['id' => $userId]);
|
||||||
|
$row = $stmt?->fetch(\PDO::FETCH_ASSOC);
|
||||||
|
if ($row) {
|
||||||
|
$profile = array_merge($profile, array_filter($row, fn($v) => $v !== null));
|
||||||
|
if ($crypto && !empty($profile['contact_phone'])) {
|
||||||
|
$profile['contact_phone'] = $crypto->decrypt((string)$profile['contact_phone']) ?: '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$children = [];
|
||||||
|
$stmt = $pdo?->prepare('SELECT id, encrypted_first_name AS first_name, note, gender, birthdate, age_years FROM children WHERE user_id = :id ORDER BY id DESC');
|
||||||
|
$stmt?->execute(['id' => $userId]);
|
||||||
|
$childrenRaw = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: [];
|
||||||
|
foreach ($childrenRaw as $c) {
|
||||||
|
if ($crypto) {
|
||||||
|
$c['first_name'] = $crypto->decrypt((string)$c['first_name']) ?: '';
|
||||||
|
$c['note'] = $crypto->decrypt((string)($c['note'] ?? '')) ?: '';
|
||||||
|
}
|
||||||
|
$children[] = $c;
|
||||||
|
}
|
||||||
|
|
||||||
|
$eventsUpcoming = [];
|
||||||
|
$eventsPast = [];
|
||||||
|
$editEvent = null;
|
||||||
|
$stmt = $pdo?->prepare(
|
||||||
|
'SELECT e.id, e.title, e.teaser_public, e.description, e.location_label, e.street, e.zip, e.city, e.region, e.starts_at, e.allow_kids, e.visibility, e.status, e.lat, e.lng,
|
||||||
|
(SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = e.id) AS participant_count
|
||||||
|
FROM events e
|
||||||
|
WHERE e.created_by = :id AND e.starts_at >= NOW()
|
||||||
|
ORDER BY e.starts_at ASC'
|
||||||
|
);
|
||||||
|
$stmt?->execute(['id' => $userId]);
|
||||||
|
$eventsUpcoming = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: [];
|
||||||
|
|
||||||
|
$stmt = $pdo?->prepare(
|
||||||
|
'SELECT e.id, e.title, e.teaser_public, e.starts_at, e.city, e.visibility, e.status,
|
||||||
|
(SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = e.id) AS participant_count
|
||||||
|
FROM events e
|
||||||
|
WHERE e.created_by = :id AND e.starts_at < NOW()
|
||||||
|
ORDER BY e.starts_at DESC'
|
||||||
|
);
|
||||||
|
$stmt?->execute(['id' => $userId]);
|
||||||
|
$eventsPast = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: [];
|
||||||
|
|
||||||
|
if (isset($_GET['edit_event'])) {
|
||||||
|
$editId = (int)$_GET['edit_event'];
|
||||||
|
$stmt = $pdo?->prepare('SELECT * FROM events WHERE id = :id AND created_by = :uid AND starts_at >= NOW() LIMIT 1');
|
||||||
|
$stmt?->execute(['id' => $editId, 'uid' => $userId]);
|
||||||
|
$editEvent = $stmt?->fetch(\PDO::FETCH_ASSOC) ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return compact('flash','info','error','profile','children','eventsUpcoming','eventsPast','editEvent');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function geocodeAddress(?string $street, ?string $zip, ?string $city, ?string $region): array
|
||||||
|
{
|
||||||
|
$parts = array_filter([
|
||||||
|
$street ?: null,
|
||||||
|
$zip ?: null,
|
||||||
|
$city ?: null,
|
||||||
|
$region ?: null,
|
||||||
|
]);
|
||||||
|
if (!$parts) {
|
||||||
|
return [null, null, null];
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = implode(', ', $parts);
|
||||||
|
$url = 'https://nominatim.openstreetmap.org/search?' . http_build_query([
|
||||||
|
'format' => 'jsonv2',
|
||||||
|
'limit' => 1,
|
||||||
|
'q' => $query,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ctx = stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'method' => 'GET',
|
||||||
|
'header' => "User-Agent: papa-kind-treff/1.0\r\nAccept-Language: de\r\n",
|
||||||
|
'timeout' => 6,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resp = @file_get_contents($url, false, $ctx);
|
||||||
|
if ($resp === false) {
|
||||||
|
return [null, null, null];
|
||||||
|
}
|
||||||
|
$json = json_decode($resp, true);
|
||||||
|
if (!is_array($json) || empty($json[0]['lat']) || empty($json[0]['lon'])) {
|
||||||
|
return [null, null, null];
|
||||||
|
}
|
||||||
|
$addr = $json[0]['address'] ?? [];
|
||||||
|
$regionGuess = $addr['city_district'] ?? $addr['suburb'] ?? $addr['state'] ?? $addr['county'] ?? $addr['region'] ?? $addr['state_district'] ?? null;
|
||||||
|
return [round((float)$json[0]['lat'], 7), round((float)$json[0]['lon'], 7), $regionGuess];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,50 @@
|
|||||||
<?php // TODO
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class App
|
||||||
|
{
|
||||||
|
private static ?self $instance = null;
|
||||||
|
|
||||||
|
private Request $request;
|
||||||
|
private SessionManager $session;
|
||||||
|
private Assets $assets;
|
||||||
|
private I18n $i18n;
|
||||||
|
private Flash $flash;
|
||||||
|
private ?\PDO $pdo;
|
||||||
|
|
||||||
|
private function __construct(private Config $config)
|
||||||
|
{
|
||||||
|
$this->request = new Request();
|
||||||
|
$this->session = new SessionManager($config);
|
||||||
|
$this->assets = new Assets($config);
|
||||||
|
$this->i18n = new I18n($config, 'de');
|
||||||
|
$this->flash = new Flash($this->session);
|
||||||
|
$this->pdo = Database::createPdo($config);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function init(Config $config): self
|
||||||
|
{
|
||||||
|
if (self::$instance === null) {
|
||||||
|
self::$instance = new self($config);
|
||||||
|
}
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function get(): self
|
||||||
|
{
|
||||||
|
if (self::$instance === null) {
|
||||||
|
throw new \RuntimeException('App not initialized. Call App::init() in bootstrap.');
|
||||||
|
}
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function config(): Config { return $this->config; }
|
||||||
|
public function request(): Request { return $this->request; }
|
||||||
|
public function session(): SessionManager { return $this->session; }
|
||||||
|
public function assets(): Assets { return $this->assets; }
|
||||||
|
public function i18n(): I18n { return $this->i18n; }
|
||||||
|
public function flash(): Flash { return $this->flash; }
|
||||||
|
public function pdo(): ?\PDO { return $this->pdo; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,44 @@
|
|||||||
<?php // TODO
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class Assets
|
||||||
|
{
|
||||||
|
private array $styles = [];
|
||||||
|
private array $scriptsHeader = [];
|
||||||
|
private array $scriptsFooter = [];
|
||||||
|
|
||||||
|
public function __construct(private Config $config) {}
|
||||||
|
|
||||||
|
public function addStyle(string $href, string $priority = 'normal', ?string $version = null): void
|
||||||
|
{
|
||||||
|
$version ??= $this->config->assetVersion;
|
||||||
|
$this->styles[] = [
|
||||||
|
'href' => $href,
|
||||||
|
'priority' => $priority,
|
||||||
|
'version' => $version,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addScript(string $src, string $pos = 'footer', bool $defer = true, bool $async = false, ?string $version = null): void
|
||||||
|
{
|
||||||
|
$version ??= $this->config->assetVersion;
|
||||||
|
$row = [
|
||||||
|
'src' => $src,
|
||||||
|
'defer' => $defer,
|
||||||
|
'async' => $async,
|
||||||
|
'version' => $version,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($pos === 'header') {
|
||||||
|
$this->scriptsHeader[] = $row;
|
||||||
|
} else {
|
||||||
|
$this->scriptsFooter[] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function styles(): array { return $this->styles; }
|
||||||
|
public function headerScripts(): array { return $this->scriptsHeader; }
|
||||||
|
public function footerScripts(): array { return $this->scriptsFooter; }
|
||||||
|
}
|
||||||
|
|||||||
218
src/App/Auth.php
218
src/App/Auth.php
@@ -1 +1,217 @@
|
|||||||
<?php // TODO
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class Auth
|
||||||
|
{
|
||||||
|
public function __construct(private App $app) {}
|
||||||
|
|
||||||
|
private function pdo(): \PDO
|
||||||
|
{
|
||||||
|
$pdo = $this->app->pdo();
|
||||||
|
if (!$pdo) {
|
||||||
|
throw new \RuntimeException('Database connection not available.');
|
||||||
|
}
|
||||||
|
return $pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register(string $displayName, string $email, string $password): int
|
||||||
|
{
|
||||||
|
$pdo = $this->pdo();
|
||||||
|
$email = strtolower(trim($email));
|
||||||
|
$displayName = trim($displayName);
|
||||||
|
|
||||||
|
if ($displayName === '' || $email === '' || $password === '') {
|
||||||
|
throw new \InvalidArgumentException('Display-Name, E-Mail und Passwort sind erforderlich.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
|
||||||
|
$stmt->execute(['email' => $email]);
|
||||||
|
if ($stmt->fetchColumn()) {
|
||||||
|
throw new \RuntimeException('E-Mail ist bereits registriert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$hash = password_hash($password, PASSWORD_ARGON2ID);
|
||||||
|
$stmt = $pdo->prepare('INSERT INTO users (email, password_hash, status, created_at, updated_at) VALUES (:email, :pw, :status, NOW(), NOW())');
|
||||||
|
$stmt->execute([
|
||||||
|
'email' => $email,
|
||||||
|
'pw' => $hash,
|
||||||
|
'status' => 'pending',
|
||||||
|
]);
|
||||||
|
$userId = (int)$pdo->lastInsertId();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('INSERT INTO user_profiles (user_id, display_name, share_level, children_visibility, created_at, updated_at) VALUES (:uid, :name, :share, :childvis, NOW(), NOW())');
|
||||||
|
$stmt->execute([
|
||||||
|
'uid' => $userId,
|
||||||
|
'name' => $displayName,
|
||||||
|
'share' => 'basic',
|
||||||
|
'childvis' => 'hidden',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
return $userId;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createVerifyCode(int $userId, string $email): string
|
||||||
|
{
|
||||||
|
$pdo = $this->pdo();
|
||||||
|
$code = $this->generateCode(6);
|
||||||
|
$hash = hash('sha256', $code);
|
||||||
|
|
||||||
|
$pdo->prepare('DELETE FROM user_tokens WHERE user_id = :uid AND type = :t')->execute(['uid' => $userId, 't' => 'verify']);
|
||||||
|
$stmt = $pdo->prepare('INSERT INTO user_tokens (user_id, type, code, token_hash, expires_at, created_at) VALUES (:uid, :type, :code, :hash, DATE_ADD(NOW(), INTERVAL 48 HOUR), NOW())');
|
||||||
|
$stmt->execute([
|
||||||
|
'uid' => $userId,
|
||||||
|
'type' => 'verify',
|
||||||
|
'code' => $code,
|
||||||
|
'hash' => $hash,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyCode(string $email, string $code): int
|
||||||
|
{
|
||||||
|
$pdo = $this->pdo();
|
||||||
|
$email = strtolower(trim($email));
|
||||||
|
$hash = hash('sha256', $code);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT u.id, u.status, t.id AS tid, t.token_hash FROM users u JOIN user_tokens t ON t.user_id = u.id AND t.type = :type WHERE u.email = :email AND (t.used_at IS NULL) AND t.expires_at > NOW() ORDER BY t.expires_at DESC LIMIT 1');
|
||||||
|
$stmt->execute(['type' => 'verify', 'email' => $email]);
|
||||||
|
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||||
|
if (!$row || !hash_equals((string)$row['token_hash'], $hash)) {
|
||||||
|
throw new \RuntimeException('Code ist ungültig oder abgelaufen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = (int)$row['id'];
|
||||||
|
$tid = (int)$row['tid'];
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
$pdo->prepare('UPDATE user_tokens SET used_at = NOW() WHERE id = :id')->execute(['id' => $tid]);
|
||||||
|
$pdo->prepare('UPDATE users SET status = :st, email_verified_at = NOW() WHERE id = :id')->execute(['st' => 'active', 'id' => $userId]);
|
||||||
|
$pdo->commit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createResetCode(string $email): array
|
||||||
|
{
|
||||||
|
$pdo = $this->pdo();
|
||||||
|
$email = strtolower(trim($email));
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT u.id, p.display_name FROM users u LEFT JOIN user_profiles p ON p.user_id = u.id WHERE u.email = :email LIMIT 1');
|
||||||
|
$stmt->execute(['email' => $email]);
|
||||||
|
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||||
|
if (!$row) {
|
||||||
|
throw new \RuntimeException('E-Mail ist nicht registriert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = (int)$row['id'];
|
||||||
|
$displayName = (string)($row['display_name'] ?? $email);
|
||||||
|
$code = $this->generateCode(6);
|
||||||
|
$hash = hash('sha256', $code);
|
||||||
|
|
||||||
|
$pdo->prepare('DELETE FROM user_tokens WHERE user_id = :uid AND type = :t')->execute(['uid' => $userId, 't' => 'reset']);
|
||||||
|
$stmt = $pdo->prepare('INSERT INTO user_tokens (user_id, type, code, token_hash, expires_at, created_at) VALUES (:uid, :type, :code, :hash, DATE_ADD(NOW(), INTERVAL 2 HOUR), NOW())');
|
||||||
|
$stmt->execute([
|
||||||
|
'uid' => $userId,
|
||||||
|
'type' => 'reset',
|
||||||
|
'code' => $code,
|
||||||
|
'hash' => $hash,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ['user_id' => $userId, 'code' => $code, 'display_name' => $displayName];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyResetCode(string $email, string $code): int
|
||||||
|
{
|
||||||
|
$pdo = $this->pdo();
|
||||||
|
$email = strtolower(trim($email));
|
||||||
|
$hash = hash('sha256', $code);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT u.id, t.id AS tid, t.token_hash FROM users u JOIN user_tokens t ON t.user_id = u.id AND t.type = :type WHERE u.email = :email AND (t.used_at IS NULL) AND t.expires_at > NOW() ORDER BY t.expires_at DESC LIMIT 1');
|
||||||
|
$stmt->execute(['type' => 'reset', 'email' => $email]);
|
||||||
|
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||||
|
if (!$row || !hash_equals((string)$row['token_hash'], $hash)) {
|
||||||
|
throw new \RuntimeException('Code ist ungültig oder abgelaufen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = (int)$row['id'];
|
||||||
|
$tid = (int)$row['tid'];
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
$pdo->prepare('UPDATE user_tokens SET used_at = NOW() WHERE id = :id')->execute(['id' => $tid]);
|
||||||
|
$pdo->commit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetPassword(int $userId, string $password): void
|
||||||
|
{
|
||||||
|
$pdo = $this->pdo();
|
||||||
|
if ($password === '' || strlen($password) < 8) {
|
||||||
|
throw new \InvalidArgumentException('Passwort muss mindestens 8 Zeichen haben.');
|
||||||
|
}
|
||||||
|
$hash = password_hash($password, PASSWORD_ARGON2ID);
|
||||||
|
$stmt = $pdo->prepare('UPDATE users SET password_hash = :pw, status = :status, updated_at = NOW() WHERE id = :id');
|
||||||
|
$stmt->execute([
|
||||||
|
'pw' => $hash,
|
||||||
|
'status' => 'active',
|
||||||
|
'id' => $userId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateCode(int $len = 6): string
|
||||||
|
{
|
||||||
|
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||||
|
$out = '';
|
||||||
|
for ($i = 0; $i < $len; $i++) {
|
||||||
|
$out .= $chars[random_int(0, strlen($chars) - 1)];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function login(string $email, string $password): array
|
||||||
|
{
|
||||||
|
$pdo = $this->pdo();
|
||||||
|
$email = strtolower(trim($email));
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT id, password_hash, status FROM users WHERE email = :email LIMIT 1');
|
||||||
|
$stmt->execute(['email' => $email]);
|
||||||
|
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (!$row) {
|
||||||
|
throw new \RuntimeException('E-Mail oder Passwort ist falsch.');
|
||||||
|
}
|
||||||
|
if (!password_verify($password, (string)$row['password_hash'])) {
|
||||||
|
throw new \RuntimeException('E-Mail oder Passwort ist falsch.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = (int)$row['id'];
|
||||||
|
$status = (string)$row['status'];
|
||||||
|
|
||||||
|
if ($status === 'active') {
|
||||||
|
$upd = $pdo->prepare('UPDATE users SET last_login_at = NOW() WHERE id = :id');
|
||||||
|
$upd->execute(['id' => $userId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['id' => $userId, 'status' => $status];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,196 @@
|
|||||||
<?php // TODO
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class Community
|
||||||
|
{
|
||||||
|
public function __construct(private \PDO $pdo, private array $config)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createThread(int $userId, string $title, string $body): void
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare('INSERT INTO forum_threads (user_id, title, body) VALUES (:uid, :title, :body)');
|
||||||
|
$stmt->execute([
|
||||||
|
':uid' => $userId,
|
||||||
|
':title' => trim($title),
|
||||||
|
':body' => trim($body),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createPost(int $userId, int $threadId, string $body): void
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare('INSERT INTO forum_posts (thread_id, user_id, body) VALUES (:tid, :uid, :body)');
|
||||||
|
$stmt->execute([
|
||||||
|
':tid' => $threadId,
|
||||||
|
':uid' => $userId,
|
||||||
|
':body' => trim($body),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function searchThreads(string $query, int $limit = 50): array
|
||||||
|
{
|
||||||
|
$conditions = [];
|
||||||
|
$params = [];
|
||||||
|
$tokens = array_filter(preg_split('/\s+/', trim($query)) ?: [], fn($t) => $t !== '');
|
||||||
|
$i = 0;
|
||||||
|
foreach ($tokens as $tok) {
|
||||||
|
$ph1 = ':t' . $i . 'a';
|
||||||
|
$ph2 = ':t' . $i . 'b';
|
||||||
|
$conditions[] = "(ft.title LIKE $ph1 OR ft.body LIKE $ph2)";
|
||||||
|
$params[$ph1] = '%' . $tok . '%';
|
||||||
|
$params[$ph2] = '%' . $tok . '%';
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
$where = $conditions ? ('AND ' . implode(' AND ', $conditions)) : '';
|
||||||
|
|
||||||
|
$sql = "SELECT ft.id, ft.title, ft.body, ft.created_at,
|
||||||
|
u.id as uid, u.created_at as user_created,
|
||||||
|
p.display_name,
|
||||||
|
(SELECT COUNT(*) FROM forum_posts fp WHERE fp.thread_id = ft.id) AS answers,
|
||||||
|
(SELECT COUNT(*) FROM forum_posts fp2 WHERE fp2.user_id = u.id) +
|
||||||
|
(SELECT COUNT(*) FROM forum_threads ft2 WHERE ft2.user_id = u.id) AS user_posts
|
||||||
|
FROM forum_threads ft
|
||||||
|
JOIN users u ON u.id = ft.user_id
|
||||||
|
LEFT JOIN user_profiles p ON p.user_id = u.id
|
||||||
|
WHERE 1=1 $where
|
||||||
|
ORDER BY ft.created_at DESC
|
||||||
|
LIMIT :lim";
|
||||||
|
$stmt = $this->pdo->prepare($sql);
|
||||||
|
foreach ($params as $k => $v) {
|
||||||
|
$stmt->bindValue($k, $v, \PDO::PARAM_STR);
|
||||||
|
}
|
||||||
|
$stmt->bindValue(':lim', $limit, \PDO::PARAM_INT);
|
||||||
|
$stmt->execute();
|
||||||
|
return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listThreads(int $limit = 50): array
|
||||||
|
{
|
||||||
|
return $this->searchThreads('', $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getThread(int $id): ?array
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare('SELECT ft.*, p.display_name FROM forum_threads ft LEFT JOIN user_profiles p ON p.user_id = ft.user_id WHERE ft.id = :id');
|
||||||
|
$stmt->execute([':id' => $id]);
|
||||||
|
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||||
|
return $row ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listPosts(int $threadId): array
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare('SELECT fp.*, p.display_name FROM forum_posts fp LEFT JOIN user_profiles p ON p.user_id = fp.user_id WHERE fp.thread_id = :id ORDER BY fp.created_at ASC');
|
||||||
|
$stmt->execute([':id' => $threadId]);
|
||||||
|
return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function computePoints(int $userId): float
|
||||||
|
{
|
||||||
|
// Primär: aggregierte Werte aus user_points_totals, Fallback: Summe aus user_points
|
||||||
|
$stmt = $this->pdo->prepare('SELECT total FROM user_points_totals WHERE user_id = :uid');
|
||||||
|
$stmt->execute([':uid' => $userId]);
|
||||||
|
$total = $stmt->fetchColumn();
|
||||||
|
if ($total !== false && $total !== null) {
|
||||||
|
return (float)$total;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $this->pdo->prepare('SELECT COALESCE(SUM(amount),0) FROM user_points WHERE user_id = :uid');
|
||||||
|
$stmt->execute([':uid' => $userId]);
|
||||||
|
return (float)$stmt->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vergibt Punkte persistent und berücksichtigt Caps/Bonis gemäß config actions.
|
||||||
|
*/
|
||||||
|
public function addPoints(int $userId, string $group, string $key, array $meta = []): float
|
||||||
|
{
|
||||||
|
$actions = $this->config['actions'][$group][$key] ?? null;
|
||||||
|
if (!$actions || empty($actions['points'])) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
$basePoints = (float)$actions['points'];
|
||||||
|
|
||||||
|
// Boni (einfacher first-Check)
|
||||||
|
$bonusPoints = 0.0;
|
||||||
|
if (!empty($actions['bonuses'])) {
|
||||||
|
if (isset($actions['bonuses']['first'])) {
|
||||||
|
$bonusPoints += (float)$actions['bonuses']['first'];
|
||||||
|
}
|
||||||
|
if (isset($actions['bonuses']['first_helpful_5']) && isset($meta['helpful_count']) && (int)$meta['helpful_count'] >= 5) {
|
||||||
|
$bonusPoints += (float)$actions['bonuses']['first_helpful_5'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$amount = $basePoints + $bonusPoints;
|
||||||
|
if ($amount <= 0) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$caps = $actions['caps'] ?? [];
|
||||||
|
$capDaily = $caps['daily'] ?? null;
|
||||||
|
$capTotal = $caps['total'] ?? null;
|
||||||
|
|
||||||
|
$todayStart = (new \DateTimeImmutable('today'))->format('Y-m-d 00:00:00');
|
||||||
|
$todayEnd = (new \DateTimeImmutable('today'))->format('Y-m-d 23:59:59');
|
||||||
|
|
||||||
|
$actionKey = $group . '.' . $key;
|
||||||
|
|
||||||
|
if ($capDaily !== null) {
|
||||||
|
$stmt = $this->pdo->prepare("SELECT COALESCE(SUM(amount),0) FROM user_points WHERE user_id = :uid AND action = :action AND created_at BETWEEN :s AND :e");
|
||||||
|
$stmt->execute([
|
||||||
|
':uid' => $userId,
|
||||||
|
':action' => $actionKey,
|
||||||
|
':s' => $todayStart,
|
||||||
|
':e' => $todayEnd,
|
||||||
|
]);
|
||||||
|
$usedToday = (float)$stmt->fetchColumn();
|
||||||
|
$remaining = max(0.0, (float)$capDaily - $usedToday);
|
||||||
|
if ($remaining <= 0) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
$amount = min($amount, $remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($capTotal !== null) {
|
||||||
|
$stmt = $this->pdo->prepare("SELECT COALESCE(SUM(amount),0) FROM user_points WHERE user_id = :uid AND action = :action");
|
||||||
|
$stmt->execute([':uid' => $userId, ':action' => $actionKey]);
|
||||||
|
$usedTotal = (float)$stmt->fetchColumn();
|
||||||
|
$remaining = max(0.0, (float)$capTotal - $usedTotal);
|
||||||
|
if ($remaining <= 0) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
$amount = min($amount, $remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $this->pdo->prepare('INSERT INTO user_points (user_id, action, amount, meta) VALUES (:uid, :action, :amount, :meta)');
|
||||||
|
$stmt->execute([
|
||||||
|
':uid' => $userId,
|
||||||
|
':action' => $actionKey,
|
||||||
|
':amount' => $amount,
|
||||||
|
':meta' => $meta ? json_encode($meta) : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$stmt = $this->pdo->prepare('INSERT INTO user_points_totals (user_id, total) VALUES (:uid, :amt) ON DUPLICATE KEY UPDATE total = total + VALUES(total)');
|
||||||
|
$stmt->execute([':uid' => $userId, ':amt' => $amount]);
|
||||||
|
|
||||||
|
return $amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function membershipLevel(float $points): array
|
||||||
|
{
|
||||||
|
$levels = $this->config['levels'] ?? [];
|
||||||
|
usort($levels, fn($a,$b) => ($b['min'] ?? 0) <=> ($a['min'] ?? 0));
|
||||||
|
foreach ($levels as $lvl) {
|
||||||
|
if ($points >= (float)($lvl['min'] ?? 0)) {
|
||||||
|
return [
|
||||||
|
'label' => $lvl['label'] ?? 'New Daddy',
|
||||||
|
'icon' => $lvl['icon'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$fallback = $levels ? $levels[count($levels)-1] : ['label' => 'New Daddy','icon' => ''];
|
||||||
|
return ['label' => $fallback['label'], 'icon' => $fallback['icon'] ?? ''];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,62 @@
|
|||||||
<?php // TODO
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class Config
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $env,
|
||||||
|
public readonly string $prefix,
|
||||||
|
public readonly string $primaryDomain,
|
||||||
|
public readonly string $primaryUrl,
|
||||||
|
public readonly string $apiBase,
|
||||||
|
public readonly string $assetVersion,
|
||||||
|
public readonly bool $dbEnabled,
|
||||||
|
public readonly array $db,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function fromPhpConstants(string $configDir): self
|
||||||
|
{
|
||||||
|
// config.php defines these constants.
|
||||||
|
$env = defined('APP_ENV') ? (string) APP_ENV : 'prod';
|
||||||
|
$prefix = defined('APP_PREFIX') ? (string) APP_PREFIX : 'app';
|
||||||
|
$primaryDom = defined('APP_DOMAIN_PRIMARY') ? (string) APP_DOMAIN_PRIMARY : 'example.test';
|
||||||
|
$primaryUrl = defined('APP_URL_PRIMARY') ? (string) APP_URL_PRIMARY : 'https://example.test';
|
||||||
|
$apiBase = defined('APP_API_BASE') ? (string) APP_API_BASE : ($primaryUrl . '/api');
|
||||||
|
$assetVersion = defined('ASSET_VERSION') ? (string) ASSET_VERSION : '';
|
||||||
|
|
||||||
|
$dbEnabled = defined('APP_DB_ENABLED') ? (bool) APP_DB_ENABLED : false;
|
||||||
|
|
||||||
|
$dbFileRoot = rtrim($configDir, '/\\') . DIRECTORY_SEPARATOR . 'db.php';
|
||||||
|
$dbFileEnv = rtrim($configDir, '/\\') . DIRECTORY_SEPARATOR . $env . DIRECTORY_SEPARATOR . 'db.php';
|
||||||
|
$dbFile = file_exists($dbFileRoot) ? $dbFileRoot : (file_exists($dbFileEnv) ? $dbFileEnv : null);
|
||||||
|
$db = $dbFile ? (array) require $dbFile : [];
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
env: $env,
|
||||||
|
prefix: $prefix,
|
||||||
|
primaryDomain: $primaryDom,
|
||||||
|
primaryUrl: rtrim($primaryUrl, '/'),
|
||||||
|
apiBase: rtrim($apiBase, '/'),
|
||||||
|
assetVersion: $assetVersion,
|
||||||
|
dbEnabled: $dbEnabled,
|
||||||
|
db: $db
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cookiePrefix(): string
|
||||||
|
{
|
||||||
|
// Example: add suffix for staging
|
||||||
|
if ($this->env === 'staging') {
|
||||||
|
return $this->prefix . '_stg_';
|
||||||
|
}
|
||||||
|
return $this->prefix . '_';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cookieDomain(): string
|
||||||
|
{
|
||||||
|
// Leading dot for subdomain-wide cookies
|
||||||
|
return '.' . ltrim($this->primaryDomain, '.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,68 @@
|
|||||||
<?php // TODO
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class Crypto
|
||||||
|
{
|
||||||
|
private string $key;
|
||||||
|
|
||||||
|
public function __construct(Config $config)
|
||||||
|
{
|
||||||
|
if (!extension_loaded('sodium')) {
|
||||||
|
throw new \RuntimeException('libsodium extension not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = getenv('DATA_KEY') ?: '';
|
||||||
|
$raw = trim($raw);
|
||||||
|
if ($raw === '') {
|
||||||
|
throw new \RuntimeException('DATA_KEY env not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
// base64?
|
||||||
|
if (str_starts_with($raw, 'base64:')) {
|
||||||
|
$raw = substr($raw, 7);
|
||||||
|
}
|
||||||
|
$decoded = base64_decode($raw, true);
|
||||||
|
if ($decoded !== false && strlen($decoded) >= SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES) {
|
||||||
|
$raw = $decoded;
|
||||||
|
} elseif (ctype_xdigit($raw) && strlen($raw) >= SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES * 2) {
|
||||||
|
$raw = hex2bin($raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($raw) < SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES) {
|
||||||
|
throw new \RuntimeException('DATA_KEY invalid length');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->key = substr($raw, 0, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function encrypt(string $plaintext): string
|
||||||
|
{
|
||||||
|
if ($plaintext === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
|
||||||
|
$cipher = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($plaintext, '', $nonce, $this->key);
|
||||||
|
return base64_encode($nonce . $cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function decrypt(?string $blob): string
|
||||||
|
{
|
||||||
|
if ($blob === null || $blob === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$raw = base64_decode($blob, true);
|
||||||
|
if ($raw === false || strlen($raw) <= SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$nonce = substr($raw, 0, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
|
||||||
|
$cipher = substr($raw, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
|
||||||
|
try {
|
||||||
|
$plain = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($cipher, '', $nonce, $this->key);
|
||||||
|
return $plain === false ? '' : $plain;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,123 @@
|
|||||||
<?php // TODO
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class Database
|
||||||
|
{
|
||||||
|
public static function createPdo(Config $config): ?\PDO
|
||||||
|
{
|
||||||
|
if (!$config->dbEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = $config->db;
|
||||||
|
$driver = (string)($db['driver'] ?? '');
|
||||||
|
|
||||||
|
if ($driver === '') {
|
||||||
|
throw new \RuntimeException('DB enabled but config/db.php missing "driver"');
|
||||||
|
}
|
||||||
|
|
||||||
|
$dsn = match ($driver) {
|
||||||
|
'mysql' => self::buildMysqlDsn($db),
|
||||||
|
'pgsql' => self::buildPgsqlDsn($db),
|
||||||
|
'sqlite' => self::buildSqliteDsn($db),
|
||||||
|
default => throw new \RuntimeException('Unsupported PDO driver: ' . $driver),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = new \PDO(
|
||||||
|
$dsn,
|
||||||
|
// sqlite braucht user/pass nicht, PDO ignoriert es aber; wir geben leer zurück
|
||||||
|
(string)($db['user'] ?? ''),
|
||||||
|
(string)($db['password'] ?? ''),
|
||||||
|
(array)($db['options'] ?? [])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Optional: PostgreSQL schema/search_path setzen
|
||||||
|
if ($driver === 'pgsql' && !empty($db['schema'])) {
|
||||||
|
// Minimaler Schutz gegen Injection über schema
|
||||||
|
$schema = preg_replace('/[^a-zA-Z0-9_]/', '', (string)$db['schema']);
|
||||||
|
if ($schema !== '') {
|
||||||
|
$pdo->exec('SET search_path TO ' . $schema);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pdo;
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
// In Prod würdest du loggen; hier minimal
|
||||||
|
http_response_code(500);
|
||||||
|
echo 'Database connection error.';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function buildMysqlDsn(array $db): string
|
||||||
|
{
|
||||||
|
if (empty($db['dbname'])) {
|
||||||
|
throw new \RuntimeException('MySQL config missing "dbname"');
|
||||||
|
}
|
||||||
|
|
||||||
|
$charset = (string)($db['charset'] ?? 'utf8mb4');
|
||||||
|
|
||||||
|
// Unix socket takes precedence
|
||||||
|
if (!empty($db['unix_socket'])) {
|
||||||
|
return sprintf(
|
||||||
|
'mysql:unix_socket=%s;dbname=%s;charset=%s',
|
||||||
|
(string)$db['unix_socket'],
|
||||||
|
(string)$db['dbname'],
|
||||||
|
$charset
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = (string)($db['host'] ?? 'localhost');
|
||||||
|
$port = (int)($db['port'] ?? 3306);
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||||
|
$host,
|
||||||
|
$port,
|
||||||
|
(string)$db['dbname'],
|
||||||
|
$charset
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function buildPgsqlDsn(array $db): string
|
||||||
|
{
|
||||||
|
if (empty($db['dbname'])) {
|
||||||
|
throw new \RuntimeException('PostgreSQL config missing "dbname"');
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = (string)($db['host'] ?? 'localhost');
|
||||||
|
$port = (int)($db['port'] ?? 5432);
|
||||||
|
|
||||||
|
// Hinweis: charset gehört bei pgsql nicht in den DSN
|
||||||
|
return sprintf(
|
||||||
|
'pgsql:host=%s;port=%d;dbname=%s',
|
||||||
|
$host,
|
||||||
|
$port,
|
||||||
|
(string)$db['dbname']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function buildSqliteDsn(array $db): string
|
||||||
|
{
|
||||||
|
// SQLite kann :memory: oder einen Pfad nutzen
|
||||||
|
$path = (string)($db['path'] ?? '');
|
||||||
|
|
||||||
|
if ($path === '') {
|
||||||
|
// Default: Memory-DB
|
||||||
|
$path = ':memory:';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn es ein Pfad ist, stelle sicher, dass das Verzeichnis existiert.
|
||||||
|
if ($path !== ':memory:') {
|
||||||
|
$dir = \dirname($path);
|
||||||
|
if ($dir && !is_dir($dir)) {
|
||||||
|
@mkdir($dir, 0775, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'sqlite:' . $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,33 @@
|
|||||||
<?php // TODO
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class Flash
|
||||||
|
{
|
||||||
|
public function __construct(private SessionManager $session) {}
|
||||||
|
|
||||||
|
public function set(string $type, string $message): void
|
||||||
|
{
|
||||||
|
$this->session->start();
|
||||||
|
$_SESSION['flash'] = [
|
||||||
|
'type' => $type,
|
||||||
|
'message' => $message,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(): ?array
|
||||||
|
{
|
||||||
|
$this->session->start();
|
||||||
|
if (empty($_SESSION['flash']) || !is_array($_SESSION['flash'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$f = $_SESSION['flash'];
|
||||||
|
unset($_SESSION['flash']);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => (string)($f['type'] ?? 'info'),
|
||||||
|
'message' => (string)($f['message'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,59 @@
|
|||||||
<?php // TODO
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class I18n
|
||||||
|
{
|
||||||
|
private array $current = [];
|
||||||
|
private array $fallback = [];
|
||||||
|
|
||||||
|
public function __construct(private Config $config, private string $lang = 'en')
|
||||||
|
{
|
||||||
|
// Minimal example translations (normally load JSON/PHP arrays from disk)
|
||||||
|
$this->fallback = [
|
||||||
|
'common' => [
|
||||||
|
'title' => '3D-Druck Materialmatrix',
|
||||||
|
'intro' => 'Schnell prüfen, welche Filamente auf welchen Druckern laufen.',
|
||||||
|
],
|
||||||
|
'cta' => [
|
||||||
|
'primary' => 'Weiter',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->current = $this->fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function traverse(array $data, string $key): mixed
|
||||||
|
{
|
||||||
|
$node = $data;
|
||||||
|
foreach (explode('.', $key) as $seg) {
|
||||||
|
if (!is_array($node) || !array_key_exists($seg, $node)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$node = $node[$seg];
|
||||||
|
}
|
||||||
|
return $node;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $key, $default = '', array $vars = []): string
|
||||||
|
{
|
||||||
|
$val = $this->traverse($this->current, $key);
|
||||||
|
if ($val === null) {
|
||||||
|
$val = $this->traverse($this->fallback, $key);
|
||||||
|
}
|
||||||
|
if (!is_string($val)) {
|
||||||
|
$val = (string)($default ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built-ins
|
||||||
|
$val = str_replace('{year}', date('Y'), $val);
|
||||||
|
$val = str_replace('{{primary_url}}', $this->config->primaryUrl, $val);
|
||||||
|
|
||||||
|
foreach ($vars as $k => $v) {
|
||||||
|
$val = str_replace('{' . $k . '}', (string)$v, $val);
|
||||||
|
$val = str_replace('{{' . $k . '}}', (string)$v, $val);
|
||||||
|
}
|
||||||
|
return $val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,352 @@
|
|||||||
<?php // TODO
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class Mailer
|
||||||
|
{
|
||||||
|
private string $logFile;
|
||||||
|
private bool $logCleared = false;
|
||||||
|
|
||||||
|
public function __construct(private App $app)
|
||||||
|
{
|
||||||
|
$base = dirname(__DIR__, 2);
|
||||||
|
$this->logFile = $base . '/debug/mailer_debug.log';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function log(string $msg, array $ctx = []): void
|
||||||
|
{
|
||||||
|
if (!defined('APP_DEBUG') || APP_DEBUG !== true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$line = '[' . date('Y-m-d H:i:s') . '] ' . $msg;
|
||||||
|
if ($ctx) {
|
||||||
|
$line .= ' ' . json_encode($ctx, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
$line .= "\n";
|
||||||
|
$dir = dirname($this->logFile);
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
@mkdir($dir, 0775, true);
|
||||||
|
}
|
||||||
|
// For clarity keep only the latest run in the log: truncate once per request
|
||||||
|
if ($this->logCleared === false) {
|
||||||
|
@file_put_contents($this->logFile, '');
|
||||||
|
$this->logCleared = true;
|
||||||
|
}
|
||||||
|
@file_put_contents($this->logFile, $line, FILE_APPEND);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function templates(): array
|
||||||
|
{
|
||||||
|
$env = $this->app->config()->env;
|
||||||
|
$root = __DIR__ . '/../../config/emailtemplates.php';
|
||||||
|
$envPath = __DIR__ . "/../../config/{$env}/emailtemplates.php";
|
||||||
|
$file = is_file($root) ? $root : $envPath;
|
||||||
|
$emailtemplates = [];
|
||||||
|
if (is_file($file)) {
|
||||||
|
/** @noinspection PhpIncludeInspection */
|
||||||
|
include $file; // populates $emailtemplates variable from included file
|
||||||
|
}
|
||||||
|
return is_array($emailtemplates ?? null) ? $emailtemplates : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderTemplate(string $key, array $vars): array
|
||||||
|
{
|
||||||
|
$templates = $this->templates();
|
||||||
|
$id = $templates[$key] ?? $key;
|
||||||
|
$this->log('template_resolved_id', ['key' => $key, 'id' => $id]);
|
||||||
|
|
||||||
|
$apiBase = getenv('EMAILTEMPLATE_API_BASE') ?: '';
|
||||||
|
$apiToken = getenv('EMAILTEMPLATE_API_TOKEN') ?: '';
|
||||||
|
|
||||||
|
if ($apiBase && $apiToken) {
|
||||||
|
$payload = [
|
||||||
|
'template' => $id,
|
||||||
|
'placeholders' => $vars,
|
||||||
|
];
|
||||||
|
$payload['token'] = $apiToken;
|
||||||
|
|
||||||
|
$payloadForLog = $payload;
|
||||||
|
$payloadForLog['token'] = '[hidden length ' . strlen((string)$apiToken) . ']';
|
||||||
|
$this->log('template_api_request_payload', [
|
||||||
|
'url' => $apiBase,
|
||||||
|
'payload' => $payloadForLog,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->log('template_api_request', ['template' => $id, 'placeholders' => array_keys($vars)]);
|
||||||
|
$ctx = stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'method' => 'POST',
|
||||||
|
'header' => "Content-Type: application/json\r\n",
|
||||||
|
'timeout' => 15,
|
||||||
|
'content' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$resp = @file_get_contents($apiBase, false, $ctx);
|
||||||
|
if ($resp !== false) {
|
||||||
|
$status = null;
|
||||||
|
if (isset($http_response_header) && is_array($http_response_header)) {
|
||||||
|
foreach ($http_response_header as $hdr) {
|
||||||
|
if (preg_match('~^HTTP/\\S+\\s+(\\d+)~i', $hdr, $m)) {
|
||||||
|
$status = (int)$m[1];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->log('template_api_response_raw', [
|
||||||
|
'status' => $status,
|
||||||
|
'body' => $resp,
|
||||||
|
]);
|
||||||
|
$decoded = json_decode($resp, true);
|
||||||
|
if (is_array($decoded) && !empty($decoded['ok']) && !empty($decoded['html'])) {
|
||||||
|
$this->log('template_api_success', ['template' => $id, 'subject' => $decoded['subject'] ?? null, 'html_len' => strlen((string)$decoded['html'])]);
|
||||||
|
return [
|
||||||
|
'id' => $id,
|
||||||
|
'subject' => $decoded['subject'] ?? '3D-Druck Materialmatrix',
|
||||||
|
'html' => $decoded['html'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$this->log('template_api_response_invalid', ['template' => $id, 'response' => $decoded]);
|
||||||
|
} else {
|
||||||
|
$this->log('template_api_unreachable', ['template' => $id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: einfacher Text
|
||||||
|
$subject = '3D-Druck Materialmatrix';
|
||||||
|
$body = $id;
|
||||||
|
foreach ($vars as $k => $v) {
|
||||||
|
$body = str_replace(['{' . $k . '}', '{{' . $k . '}}'], (string)$v, $body);
|
||||||
|
}
|
||||||
|
$this->log('template_fallback_used', ['template' => $id]);
|
||||||
|
return [
|
||||||
|
'id' => $id,
|
||||||
|
'subject' => $subject,
|
||||||
|
'html' => nl2br(htmlspecialchars($body, ENT_QUOTES)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sendTemplate(string $templateKey, string $to, array $vars = []): void
|
||||||
|
{
|
||||||
|
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
throw new \InvalidArgumentException('Invalid recipient email.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tpl = $this->renderTemplate($templateKey, $vars);
|
||||||
|
$resolvedId = $tpl['id'] ?? $templateKey;
|
||||||
|
$subject = $tpl['subject'] ?? '3D-Druck Materialmatrix';
|
||||||
|
$html = $tpl['html'] ?? '';
|
||||||
|
|
||||||
|
$this->log('mail_rendered_template', [
|
||||||
|
'template_key' => $templateKey,
|
||||||
|
'template_id' => $resolvedId,
|
||||||
|
'subject' => $subject,
|
||||||
|
'html_len' => strlen((string)$html),
|
||||||
|
'html_preview' => substr((string)$html, 0, 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$transport = getenv('MAIL_TRANSPORT') ?: 'mail';
|
||||||
|
$fromEmail = getenv('MAIL_FROM') ?: 'no-reply@' . $this->app->config()->primaryDomain;
|
||||||
|
$fromName = getenv('MAIL_FROM_NAME') ?: '3D-Druck Materialmatrix';
|
||||||
|
|
||||||
|
$this->log('mail_send_start', [
|
||||||
|
'template_key' => $templateKey,
|
||||||
|
'template_id' => $resolvedId,
|
||||||
|
'to' => $to,
|
||||||
|
'transport' => $transport,
|
||||||
|
'subject' => $subject
|
||||||
|
]);
|
||||||
|
if ($transport === 'smtp') {
|
||||||
|
$this->sendSmtp($to, $subject, $html, $fromEmail, $fromName);
|
||||||
|
} else {
|
||||||
|
$this->sendMailFn($to, $subject, $html, $fromEmail, $fromName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendMailFn(string $to, string $subject, string $html, string $from, string $fromName): void
|
||||||
|
{
|
||||||
|
$headers = [];
|
||||||
|
if ($from) {
|
||||||
|
$headers[] = 'From: ' . sprintf('"%s" <%s>', addslashes($fromName), $from);
|
||||||
|
}
|
||||||
|
$headers[] = 'Content-Type: text/html; charset=utf-8';
|
||||||
|
$ok = @mail($to, $subject, $html, implode("\r\n", $headers));
|
||||||
|
$this->log('mail_mail_transport', ['to' => $to, 'ok' => $ok]);
|
||||||
|
if (!$ok) {
|
||||||
|
throw new \RuntimeException('mail() transport failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendSmtp(string $to, string $subject, string $html, string $from, string $fromName): void
|
||||||
|
{
|
||||||
|
$host = getenv('SMTP_HOST') ?: '';
|
||||||
|
$port = (int)(getenv('SMTP_PORT') ?: 587);
|
||||||
|
$user = getenv('SMTP_USER') ?: '';
|
||||||
|
$pass = getenv('SMTP_PASS') ?: '';
|
||||||
|
$secure = strtolower(getenv('SMTP_SECURE') ?: 'tls'); // tls|ssl|none
|
||||||
|
|
||||||
|
if (!$host) {
|
||||||
|
$this->log('mail_smtp_missing_host_fallback_mail', []);
|
||||||
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$proto = ($secure === 'ssl') ? 'ssl://' : '';
|
||||||
|
$fp = @stream_socket_client($proto . $host . ':' . $port, $errno, $errstr, 15, STREAM_CLIENT_CONNECT);
|
||||||
|
if (!$fp) {
|
||||||
|
$this->log('mail_smtp_connect_failed', ['host' => $host, 'port' => $port, 'error' => $errstr]);
|
||||||
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stream_set_timeout($fp, 15);
|
||||||
|
|
||||||
|
$transcript = [];
|
||||||
|
$readResponse = function (array $expectCodes = [], string $label = 'read') use ($fp, &$transcript): array {
|
||||||
|
$lines = [];
|
||||||
|
while (($line = fgets($fp, 515)) !== false) {
|
||||||
|
$line = rtrim($line, "\r\n");
|
||||||
|
$lines[] = $line;
|
||||||
|
$transcript[] = $label . ': ' . $line;
|
||||||
|
// SMTP multiline: code + '-' means more lines, code + ' ' means end
|
||||||
|
if (strlen($line) >= 4 && $line[3] === ' ') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$code = 0;
|
||||||
|
if ($lines) {
|
||||||
|
$code = (int)substr($lines[0], 0, 3);
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'ok' => !$expectCodes || in_array($code, $expectCodes, true),
|
||||||
|
'code' => $code,
|
||||||
|
'lines' => $lines,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
$write = function (string $cmd, string $label = 'write', bool $mask = false) use ($fp, &$transcript): void {
|
||||||
|
$transcript[] = $label . ': ' . ($mask ? '[omitted]' : $cmd);
|
||||||
|
fwrite($fp, $cmd . "\r\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
$resp = $readResponse([220], 'greeting');
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
fclose($fp);
|
||||||
|
$this->log('mail_smtp_greeting_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
||||||
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
||||||
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$write('EHLO ' . $this->app->config()->primaryDomain);
|
||||||
|
$resp = $readResponse([250], 'ehlo');
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
fclose($fp);
|
||||||
|
$this->log('mail_smtp_ehlo_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
||||||
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
||||||
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($secure === 'tls') {
|
||||||
|
$write('STARTTLS');
|
||||||
|
$resp = $readResponse([220], 'starttls');
|
||||||
|
if (!$resp['ok'] || !stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
||||||
|
fclose($fp);
|
||||||
|
$this->log('mail_smtp_starttls_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
||||||
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
||||||
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$write('EHLO ' . $this->app->config()->primaryDomain);
|
||||||
|
$resp = $readResponse([250], 'ehlo-tls');
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
fclose($fp);
|
||||||
|
$this->log('mail_smtp_ehlo_tls_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
||||||
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
||||||
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user !== '') {
|
||||||
|
$write('AUTH LOGIN');
|
||||||
|
$resp = $readResponse([334], 'auth-login');
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
fclose($fp);
|
||||||
|
$this->log('mail_smtp_auth_login_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
||||||
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
||||||
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$write(base64_encode($user), 'auth-user', true);
|
||||||
|
$resp = $readResponse([334], 'auth-user');
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
fclose($fp);
|
||||||
|
$this->log('mail_smtp_auth_user_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
||||||
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
||||||
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$write(base64_encode($pass), 'auth-pass', true);
|
||||||
|
$resp = $readResponse([235], 'auth-pass');
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
fclose($fp);
|
||||||
|
$this->log('mail_smtp_auth_pass_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
||||||
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
||||||
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$write('MAIL FROM: <' . $from . '>');
|
||||||
|
$resp = $readResponse([250], 'mail-from');
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
fclose($fp);
|
||||||
|
$this->log('mail_smtp_mailfrom_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
||||||
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
||||||
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$write('RCPT TO: <' . $to . '>');
|
||||||
|
$resp = $readResponse([250, 251], 'rcpt-to');
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
fclose($fp);
|
||||||
|
$this->log('mail_smtp_rcpt_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
||||||
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
||||||
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$write('DATA');
|
||||||
|
$resp = $readResponse([354], 'data-start');
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
fclose($fp);
|
||||||
|
$this->log('mail_smtp_data_start_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
||||||
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
||||||
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$msg = "From: {$fromName} <{$from}>\r\n";
|
||||||
|
$msg .= "To: <{$to}>\r\n";
|
||||||
|
$msg .= "Subject: {$subject}\r\n";
|
||||||
|
$msg .= "MIME-Version: 1.0\r\n";
|
||||||
|
$msg .= "Content-Type: text/html; charset=utf-8\r\n\r\n";
|
||||||
|
$msg .= $html . "\r\n.\r\n";
|
||||||
|
$write($msg, 'data', false);
|
||||||
|
$resp = $readResponse([250], 'data-end');
|
||||||
|
|
||||||
|
$write('QUIT');
|
||||||
|
$readResponse([221], 'quit');
|
||||||
|
fclose($fp);
|
||||||
|
|
||||||
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
||||||
|
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
$this->log('mail_smtp_send_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
||||||
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->log('mail_smtp_sent', ['to' => $to, 'host' => $host, 'port' => $port, 'secure' => $secure]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
44
src/App/Repository/MaterialMatrixRepository.php
Normal file
44
src/App/Repository/MaterialMatrixRepository.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
final class MaterialMatrixRepository
|
||||||
|
{
|
||||||
|
public function __construct(private PDO $pdo) {}
|
||||||
|
|
||||||
|
public function listActivePrinters(): array
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->query('SELECT * FROM printers WHERE is_active = 1 ORDER BY name');
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listActiveMaterials(): array
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->query('SELECT * FROM materials WHERE is_active = 1 ORDER BY code');
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPrinterById(int $printerId): ?array
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare('SELECT * FROM printers WHERE id = :id');
|
||||||
|
$stmt->execute(['id' => $printerId]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
return $row ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listMaterialsForPrinter(int $printerId): array
|
||||||
|
{
|
||||||
|
$sql = 'SELECT m.*, pms.support_level, pms.partial_reason, pms.extra_info
|
||||||
|
FROM materials m
|
||||||
|
LEFT JOIN printer_material_support pms
|
||||||
|
ON pms.material_id = m.id AND pms.printer_id = :pid
|
||||||
|
WHERE m.is_active = 1
|
||||||
|
ORDER BY m.code';
|
||||||
|
$stmt = $this->pdo->prepare($sql);
|
||||||
|
$stmt->execute(['pid' => $printerId]);
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,47 @@
|
|||||||
<?php // TODO
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class Request
|
||||||
|
{
|
||||||
|
public function scheme(): string
|
||||||
|
{
|
||||||
|
// Proxy / LB
|
||||||
|
if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
|
||||||
|
$proto = strtolower((string)$_SERVER['HTTP_X_FORWARDED_PROTO']);
|
||||||
|
if ($proto === 'https' || $proto === 'http') {
|
||||||
|
return $proto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||||
|
return 'https';
|
||||||
|
}
|
||||||
|
return 'http';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function host(): string
|
||||||
|
{
|
||||||
|
return (string)($_SERVER['HTTP_HOST'] ?? 'localhost');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function baseUrl(): string
|
||||||
|
{
|
||||||
|
return $this->scheme() . '://' . $this->host();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function path(): string
|
||||||
|
{
|
||||||
|
return (string) strtok((string)($_SERVER['REQUEST_URI'] ?? '/'), '?');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currentUrl(bool $withQuery = true): string
|
||||||
|
{
|
||||||
|
$base = $this->baseUrl();
|
||||||
|
$uri = (string)($_SERVER['REQUEST_URI'] ?? '/');
|
||||||
|
if ($withQuery) {
|
||||||
|
return $base . $uri;
|
||||||
|
}
|
||||||
|
return $base . (string) strtok($uri, '?');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,241 @@
|
|||||||
<?php // TODO
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class Search
|
||||||
|
{
|
||||||
|
public function __construct(private ?\PDO $pdo) {}
|
||||||
|
|
||||||
|
public function searchEvents(string $query, int $limit = 100, ?array $geo = null): array
|
||||||
|
{
|
||||||
|
if (!$this->pdo) return [];
|
||||||
|
|
||||||
|
$q = trim($query);
|
||||||
|
$hasGeo = isset($geo['lat'], $geo['lng']) && is_numeric($geo['lat']) && is_numeric($geo['lng']);
|
||||||
|
if ($q === '' && !$hasGeo) return [];
|
||||||
|
|
||||||
|
$tokens = array_filter(preg_split('/\s+/', $q) ?: [], fn($t) => $t !== '');
|
||||||
|
if (!$tokens) {
|
||||||
|
$tokens = [$q];
|
||||||
|
}
|
||||||
|
// Nur Tokens ab 3 Zeichen für fuzzy/LIKE berücksichtigen
|
||||||
|
$tokens = array_values(array_filter($tokens, fn($t) => mb_strlen($t) >= 3));
|
||||||
|
if (!$tokens && !$hasGeo) return [];
|
||||||
|
|
||||||
|
$conditions = [];
|
||||||
|
$bindTokens = [];
|
||||||
|
$i = 0;
|
||||||
|
foreach ($tokens as $tok) {
|
||||||
|
$tok = trim($tok);
|
||||||
|
if ($tok === '') continue;
|
||||||
|
// LIKE + phonetic (SOUNDEX) to allow partial and typo-tolerant matches
|
||||||
|
$conditions[] = "(title LIKE CONCAT('%', ?, '%') OR teaser_public LIKE CONCAT('%', ?, '%') OR description LIKE CONCAT('%', ?, '%') OR city LIKE CONCAT('%', ?, '%') OR region LIKE CONCAT('%', ?, '%') OR zip LIKE CONCAT('%', ?, '%') OR SOUNDEX(title)=SOUNDEX(?) OR SOUNDEX(teaser_public)=SOUNDEX(?) OR SOUNDEX(description)=SOUNDEX(?) OR SOUNDEX(city)=SOUNDEX(?) OR SOUNDEX(region)=SOUNDEX(?))";
|
||||||
|
// LIKE bindings
|
||||||
|
$bindTokens[] = $tok;
|
||||||
|
$bindTokens[] = $tok;
|
||||||
|
$bindTokens[] = $tok;
|
||||||
|
$bindTokens[] = $tok;
|
||||||
|
$bindTokens[] = $tok;
|
||||||
|
$bindTokens[] = $tok;
|
||||||
|
// SOUNDEX bindings
|
||||||
|
$bindTokens[] = $tok;
|
||||||
|
$bindTokens[] = $tok;
|
||||||
|
$bindTokens[] = $tok;
|
||||||
|
$bindTokens[] = $tok;
|
||||||
|
$bindTokens[] = $tok;
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$whereParts = [
|
||||||
|
"starts_at >= NOW()",
|
||||||
|
"status != 'cancelled'",
|
||||||
|
];
|
||||||
|
if ($conditions) {
|
||||||
|
// "OR" so that partial matches across tokens are allowed
|
||||||
|
$whereParts[] = '(' . implode(' OR ', $conditions) . ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
$distanceFiltering = false;
|
||||||
|
$bind = [];
|
||||||
|
if ($hasGeo) {
|
||||||
|
$sql = "SELECT id, title, teaser_public, description, city, region, zip, starts_at, visibility, allow_kids, location_label, lat, lng,
|
||||||
|
(6371 * ACOS(LEAST(1,
|
||||||
|
COS(RADIANS(?)) * COS(RADIANS(lat)) * COS(RADIANS(lng) - RADIANS(?)) +
|
||||||
|
SIN(RADIANS(?)) * SIN(RADIANS(lat))
|
||||||
|
))) AS distance_km";
|
||||||
|
$lat = (float)$geo['lat'];
|
||||||
|
$lng = (float)$geo['lng'];
|
||||||
|
$radius = isset($geo['radius']) && is_numeric($geo['radius']) ? max(0.1, (float)$geo['radius']) : 5.0;
|
||||||
|
$distanceFiltering = true;
|
||||||
|
|
||||||
|
$latRange = $radius / 111.0;
|
||||||
|
$lngRange = $radius / (111.0 * max(0.1, cos($lat * M_PI / 180)));
|
||||||
|
$whereParts[] = "(lat IS NOT NULL AND lng IS NOT NULL)";
|
||||||
|
$whereParts[] = "(lat BETWEEN ? AND ?)";
|
||||||
|
$whereParts[] = "(lng BETWEEN ? AND ?)";
|
||||||
|
// Haversine params (order must match SQL): first three
|
||||||
|
$bind[] = $lat; // COS(RADIANS(?))
|
||||||
|
$bind[] = $lng; // COS(RADIANS(lng) - RADIANS(?))
|
||||||
|
$bind[] = $lat; // SIN(RADIANS(?))
|
||||||
|
// THEN token binds
|
||||||
|
$bind = array_merge($bind, $bindTokens);
|
||||||
|
// Bounding box
|
||||||
|
$bind[] = $lat - $latRange;
|
||||||
|
$bind[] = $lat + $latRange;
|
||||||
|
$bind[] = $lng - $lngRange;
|
||||||
|
$bind[] = $lng + $lngRange;
|
||||||
|
// Radius for HAVING
|
||||||
|
$bind[] = $radius;
|
||||||
|
} else {
|
||||||
|
$sql = "SELECT id, title, teaser_public, description, city, region, zip, starts_at, visibility, allow_kids, location_label, lat, lng, NULL AS distance_km";
|
||||||
|
$bind = $bindTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
$where = $whereParts ? ('WHERE ' . implode(' AND ', $whereParts)) : '';
|
||||||
|
$sql .= " FROM events $where";
|
||||||
|
if ($distanceFiltering) {
|
||||||
|
$sql .= " HAVING distance_km <= ?";
|
||||||
|
$sql .= " ORDER BY distance_km ASC, starts_at ASC";
|
||||||
|
} else {
|
||||||
|
$sql .= " ORDER BY starts_at ASC";
|
||||||
|
}
|
||||||
|
$limit = (int)$limit;
|
||||||
|
$sql .= " LIMIT {$limit}";
|
||||||
|
|
||||||
|
$stmt = $this->pdo->prepare($sql);
|
||||||
|
try {
|
||||||
|
$stmt->execute($bind);
|
||||||
|
$rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
|
||||||
|
if (!$hasGeo) {
|
||||||
|
foreach ($rows as &$r) {
|
||||||
|
unset($r['distance_km']);
|
||||||
|
}
|
||||||
|
unset($r);
|
||||||
|
}
|
||||||
|
// Fuzzy filter: allow slight typos (Levenshtein <= 1 or 2)
|
||||||
|
if ($tokens) {
|
||||||
|
$rows = array_values(array_filter($rows, function ($row) use ($tokens) {
|
||||||
|
$haystack = strtolower(
|
||||||
|
($row['title'] ?? '') . ' ' .
|
||||||
|
($row['teaser_public'] ?? '') . ' ' .
|
||||||
|
($row['description'] ?? '') . ' ' .
|
||||||
|
($row['city'] ?? '') . ' ' .
|
||||||
|
($row['region'] ?? '')
|
||||||
|
);
|
||||||
|
$words = preg_split('/[^a-z0-9äöüß]+/i', $haystack) ?: [];
|
||||||
|
foreach ($tokens as $tok) {
|
||||||
|
$t = strtolower($tok);
|
||||||
|
if ($t === '') continue;
|
||||||
|
if (str_contains($haystack, $t)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
foreach ($words as $w) {
|
||||||
|
if ($w === '') continue;
|
||||||
|
$dist = levenshtein($t, $w);
|
||||||
|
if ($dist <= 1 || ($dist <= 2 && max(strlen($t), strlen($w)) > 4)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// Fallback: wenn keine Treffer, erneut ohne Token-Filter laden und nur fuzzy filtern
|
||||||
|
if (!$rows && $tokens) {
|
||||||
|
$wherePartsFallback = [
|
||||||
|
"starts_at >= NOW()",
|
||||||
|
"status != 'cancelled'",
|
||||||
|
];
|
||||||
|
$bindFb = [];
|
||||||
|
$sqlFb = '';
|
||||||
|
if ($hasGeo) {
|
||||||
|
$sqlFb = "SELECT id, title, teaser_public, description, city, region, zip, starts_at, visibility, allow_kids, location_label, lat, lng,
|
||||||
|
(6371 * ACOS(LEAST(1,
|
||||||
|
COS(RADIANS(?)) * COS(RADIANS(lat)) * COS(RADIANS(lng) - RADIANS(?)) +
|
||||||
|
SIN(RADIANS(?)) * SIN(RADIANS(lat))
|
||||||
|
))) AS distance_km";
|
||||||
|
$lat = (float)$geo['lat'];
|
||||||
|
$lng = (float)$geo['lng'];
|
||||||
|
$radius = isset($geo['radius']) && is_numeric($geo['radius']) ? max(0.1, (float)$geo['radius']) : 5.0;
|
||||||
|
$latRange = $radius / 111.0;
|
||||||
|
$lngRange = $radius / (111.0 * max(0.1, cos($lat * M_PI / 180)));
|
||||||
|
$wherePartsFallback[] = "(lat IS NOT NULL AND lng IS NOT NULL)";
|
||||||
|
$wherePartsFallback[] = "(lat BETWEEN ? AND ?)";
|
||||||
|
$wherePartsFallback[] = "(lng BETWEEN ? AND ?)";
|
||||||
|
$bindFb[] = $lat;
|
||||||
|
$bindFb[] = $lng;
|
||||||
|
$bindFb[] = $lat;
|
||||||
|
$bindFb[] = $lat - $latRange;
|
||||||
|
$bindFb[] = $lat + $latRange;
|
||||||
|
$bindFb[] = $lng - $lngRange;
|
||||||
|
$bindFb[] = $lng + $lngRange;
|
||||||
|
$bindFb[] = $radius;
|
||||||
|
$havingFb = true;
|
||||||
|
} else {
|
||||||
|
$sqlFb = "SELECT id, title, teaser_public, description, city, region, zip, starts_at, visibility, allow_kids, location_label, lat, lng, 1 AS distance_km";
|
||||||
|
$havingFb = false;
|
||||||
|
}
|
||||||
|
$whereFb = $wherePartsFallback ? ('WHERE ' . implode(' AND ', $wherePartsFallback)) : '';
|
||||||
|
$sqlFb .= " FROM events $whereFb";
|
||||||
|
if ($havingFb) {
|
||||||
|
$sqlFb .= " HAVING distance_km <= ?";
|
||||||
|
$sqlFb .= " ORDER BY distance_km ASC, starts_at ASC";
|
||||||
|
} else {
|
||||||
|
$sqlFb .= " ORDER BY starts_at ASC";
|
||||||
|
}
|
||||||
|
$sqlFb .= " LIMIT {$limit}";
|
||||||
|
$stmtFb = $this->pdo->prepare($sqlFb);
|
||||||
|
$stmtFb->execute($bindFb);
|
||||||
|
$rowsFb = $stmtFb->fetchAll(\PDO::FETCH_ASSOC) ?: [];
|
||||||
|
if ($rowsFb) {
|
||||||
|
$rows = array_values(array_filter($rowsFb, function ($row) use ($tokens) {
|
||||||
|
$haystack = strtolower(
|
||||||
|
($row['title'] ?? '') . ' ' .
|
||||||
|
($row['teaser_public'] ?? '') . ' ' .
|
||||||
|
($row['description'] ?? '') . ' ' .
|
||||||
|
($row['city'] ?? '') . ' ' .
|
||||||
|
($row['region'] ?? '')
|
||||||
|
);
|
||||||
|
$words = preg_split('/[^a-z0-9äöüß]+/i', $haystack) ?: [];
|
||||||
|
foreach ($tokens as $tok) {
|
||||||
|
$t = strtolower($tok);
|
||||||
|
if ($t === '') continue;
|
||||||
|
if (str_contains($haystack, $t)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
foreach ($words as $w) {
|
||||||
|
if ($w === '') continue;
|
||||||
|
$dist = levenshtein($t, $w);
|
||||||
|
if ($dist <= 1 || ($dist <= 2 && max(strlen($t), strlen($w)) > 4)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (defined('APP_ENV') && APP_ENV === 'staging') {
|
||||||
|
$logOk = [
|
||||||
|
'status' => 'ok',
|
||||||
|
'sql' => $sql,
|
||||||
|
'bind' => $bind,
|
||||||
|
'count' => count($rows),
|
||||||
|
'fallback' => ($rows ? 'primary' : 'fallback'),
|
||||||
|
];
|
||||||
|
@file_put_contents(__DIR__ . '/../../debug/search_debug.log', print_r($logOk, true));
|
||||||
|
}
|
||||||
|
return $rows;
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
// Log into /debug/search_debug.log and continue with empty results
|
||||||
|
$logErr = [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'sql' => $sql,
|
||||||
|
'bind' => $bind,
|
||||||
|
];
|
||||||
|
@file_put_contents(__DIR__ . '/../../debug/search_debug.log', print_r($logErr, true));
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,71 @@
|
|||||||
<?php // TODO
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class SessionManager
|
||||||
|
{
|
||||||
|
private string $sessionCookieName;
|
||||||
|
private string $clientCookieName;
|
||||||
|
|
||||||
|
public function __construct(private Config $config)
|
||||||
|
{
|
||||||
|
$prefix = $config->cookiePrefix();
|
||||||
|
$this->sessionCookieName = $prefix . 'session';
|
||||||
|
$this->clientCookieName = $prefix . 'client';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function start(): void
|
||||||
|
{
|
||||||
|
if (PHP_SAPI === 'cli') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (session_status() !== PHP_SESSION_NONE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session_name($this->sessionCookieName);
|
||||||
|
|
||||||
|
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||||
|
|| (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https');
|
||||||
|
|
||||||
|
session_set_cookie_params([
|
||||||
|
'lifetime' => 0,
|
||||||
|
'path' => '/',
|
||||||
|
'domain' => $this->config->cookieDomain(),
|
||||||
|
'secure' => $secure,
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ensureClientId(int $lifetimeSeconds = 31536000): string
|
||||||
|
{
|
||||||
|
if (PHP_SAPI === 'cli') {
|
||||||
|
return 'cli';
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $_COOKIE[$this->clientCookieName] ?? null;
|
||||||
|
if (!is_string($id) || !preg_match('/^[a-f0-9]{64}$/', $id)) {
|
||||||
|
$id = bin2hex(random_bytes(32));
|
||||||
|
|
||||||
|
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||||
|
|| (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https');
|
||||||
|
|
||||||
|
setcookie($this->clientCookieName, $id, [
|
||||||
|
'expires' => time() + $lifetimeSeconds,
|
||||||
|
'path' => '/',
|
||||||
|
'domain' => $this->config->cookieDomain(),
|
||||||
|
'secure' => $secure,
|
||||||
|
'httponly' => false, // accessible to JS if needed
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$_COOKIE[$this->clientCookieName] = $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
# src\n\nTODO: Core backend/business logic.\n
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
<?php
|
|
||||||
return require __DIR__ . '/../config/config.php';
|
|
||||||
104
src/helpers.php
104
src/helpers.php
@@ -1 +1,103 @@
|
|||||||
<?php // TODO
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\App;
|
||||||
|
|
||||||
|
function app(): App
|
||||||
|
{
|
||||||
|
return App::get();
|
||||||
|
}
|
||||||
|
|
||||||
|
function t(string $key, $default = '', array $vars = []): string
|
||||||
|
{
|
||||||
|
return app()->i18n()->get($key, $default, $vars);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tpl(string $file, string $type = 'structure', string $site = 'main'): void
|
||||||
|
{
|
||||||
|
$base = __DIR__ . '/../partials/';
|
||||||
|
|
||||||
|
// very small validation
|
||||||
|
foreach ([$file, $type, $site] as $v) {
|
||||||
|
if (preg_match('/[^a-zA-Z0-9_\-]/', $v)) {
|
||||||
|
echo "<!-- tpl(): invalid parameter -->";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type === 'landing') {
|
||||||
|
$path = $base . "landing/$site/$file.php";
|
||||||
|
} else {
|
||||||
|
$path = $base . "structure/$file.php";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file_exists($path)) {
|
||||||
|
include $path;
|
||||||
|
} else {
|
||||||
|
echo "<!-- tpl(): not found: $path -->";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_primary_domain(): string
|
||||||
|
{
|
||||||
|
if (defined('APP_DOMAIN_PRIMARY')) {
|
||||||
|
return APP_DOMAIN_PRIMARY;
|
||||||
|
}
|
||||||
|
if (defined('APP_DOMAIN_NAME')) {
|
||||||
|
return APP_DOMAIN_NAME;
|
||||||
|
}
|
||||||
|
return $_SERVER['HTTP_HOST'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_fakecheck_domain(): string
|
||||||
|
{
|
||||||
|
if (defined('APP_DOMAIN_FAKECHECK')) {
|
||||||
|
return APP_DOMAIN_FAKECHECK;
|
||||||
|
}
|
||||||
|
return app_primary_domain();
|
||||||
|
}
|
||||||
|
|
||||||
|
function asset_styles(): void
|
||||||
|
{
|
||||||
|
$styles = app()->assets()->styles();
|
||||||
|
|
||||||
|
// simple priority order
|
||||||
|
$order = ['early' => 0, 'normal' => 1, 'late' => 2];
|
||||||
|
usort($styles, fn($a,$b) => ($order[$a['priority']] ?? 1) <=> ($order[$b['priority']] ?? 1));
|
||||||
|
|
||||||
|
foreach ($styles as $s) {
|
||||||
|
$href = $s['href'];
|
||||||
|
$v = $s['version'];
|
||||||
|
if ($v !== null && $v !== '') {
|
||||||
|
$sep = (str_contains($href, '?') ? '&' : '?');
|
||||||
|
$href = $href . $sep . 'v=' . rawurlencode((string)$v);
|
||||||
|
}
|
||||||
|
echo '<link rel="stylesheet" href="' . htmlspecialchars($href, ENT_QUOTES) . '">' . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function asset_scripts(string $pos = 'footer'): void
|
||||||
|
{
|
||||||
|
$scripts = ($pos === 'header') ? app()->assets()->headerScripts() : app()->assets()->footerScripts();
|
||||||
|
|
||||||
|
foreach ($scripts as $s) {
|
||||||
|
$src = $s['src'];
|
||||||
|
$v = $s['version'];
|
||||||
|
if ($v !== null && $v !== '') {
|
||||||
|
$sep = (str_contains($src, '?') ? '&' : '?');
|
||||||
|
$src = $src . $sep . 'v=' . rawurlencode((string)$v);
|
||||||
|
}
|
||||||
|
|
||||||
|
$attrs = '';
|
||||||
|
if (!empty($s['defer'])) $attrs .= ' defer';
|
||||||
|
if (!empty($s['async'])) $attrs .= ' async';
|
||||||
|
|
||||||
|
echo '<script src="' . htmlspecialchars($src, ENT_QUOTES) . '"' . $attrs . '></script>' . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirect(string $path): void
|
||||||
|
{
|
||||||
|
header('Location: ' . $path, true, 303);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
|
|||||||
21
tools/db.php
21
tools/db.php
@@ -1,21 +0,0 @@
|
|||||||
<?php
|
|
||||||
function tools_build_pdo(): PDO
|
|
||||||
{
|
|
||||||
$cfg = require __DIR__ . '/../src/config.php';
|
|
||||||
if (!is_array($cfg)) {
|
|
||||||
throw new RuntimeException('DB config did not return an array.');
|
|
||||||
}
|
|
||||||
$required = ['db_host', 'db_name', 'db_user', 'db_pass', 'db_charset'];
|
|
||||||
foreach ($required as $key) {
|
|
||||||
if (!array_key_exists($key, $cfg)) {
|
|
||||||
throw new RuntimeException('Missing DB config key: ' . $key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$dsn = "mysql:host={$cfg['db_host']};dbname={$cfg['db_name']};charset={$cfg['db_charset']}";
|
|
||||||
$options = [
|
|
||||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
|
||||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
|
||||||
];
|
|
||||||
|
|
||||||
return new PDO($dsn, $cfg['db_user'], $cfg['db_pass'], $options);
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?php
|
|
||||||
function tools_fetch_active_printers(PDO $pdo): array
|
|
||||||
{
|
|
||||||
$stmt = $pdo->query("SELECT * FROM printers WHERE is_active = 1 ORDER BY name");
|
|
||||||
return $stmt->fetchAll();
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user