Compare commits
23 Commits
main
...
f3f24cebba
| Author | SHA1 | Date | |
|---|---|---|---|
| f3f24cebba | |||
| 6063ae4193 | |||
| 648d3aac5d | |||
| f15cf0b0ff | |||
| f0f9abdd66 | |||
| ed5d77bf6c | |||
| 0a784ef454 | |||
| a9445b305f | |||
| 140f9b4288 | |||
| 6e4002af70 | |||
| ed3fdda98e | |||
| b6e49c8ad8 | |||
| e96cfd09ad | |||
| 4ae930033b | |||
| 01e579aecf | |||
| be8f2b772c | |||
| 5b24b9464c | |||
| b6009abede | |||
| 367a3662a9 | |||
| a0b0f21d38 | |||
| 7cd512db2d | |||
| 84c7e097af | |||
| 5279b0d34d |
@@ -17,7 +17,8 @@ default:
|
|||||||
# -----------------------------------------
|
# -----------------------------------------
|
||||||
deploy:staging:
|
deploy:staging:
|
||||||
stage: deploy
|
stage: deploy
|
||||||
|
tags:
|
||||||
|
- staging
|
||||||
variables:
|
variables:
|
||||||
TARGET_PATH: "${FTP_PATH_STAGING}"
|
TARGET_PATH: "${FTP_PATH_STAGING}"
|
||||||
CONFIG_ENV_DIR: "config/staging"
|
CONFIG_ENV_DIR: "config/staging"
|
||||||
|
|||||||
122
README.md
122
README.md
@@ -1,93 +1,53 @@
|
|||||||
# shape3d
|
# shape3d
|
||||||
|
|
||||||
|
Kurzbeschreibung
|
||||||
|
Diese App zeigt eine 3D-Druck-Materialmatrix: Welche Materialien laufen auf welchen Druckern, inkl. Eigenschaften, Anforderungen und Sicherheits-/Emissionsindikatoren. Die Oberfläche lädt Daten per API und rendert sie als Tabelle mit optionalem Drucker-Vergleich.
|
||||||
|
|
||||||
|
Was wird angezeigt
|
||||||
|
- Materialliste mit Metadaten (z. B. Eigenschaften, Tg, Düsen-/Plattenanforderung, Anwendung, Kinder-Sicherheit, Emission).
|
||||||
|
- Pro ausgewähltem Drucker eine Kompatibilitäts-Spalte (voll/teilweise/mit Zusatz/nicht unterstützt).
|
||||||
|
- Auswahl: Einzelansicht (ein Drucker) oder Vergleich (mehrere Drucker).
|
||||||
|
|
||||||
## Getting started
|
Datenquellen (flexibel gehalten)
|
||||||
|
Die App ist so aufgebaut, dass UI und Datenzugriff getrennt sind. Je nach Reimport/Neustrukturierung können Dateinamen und Pfade abweichen, solange die folgenden Rollen weiterhin existieren:
|
||||||
|
|
||||||
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
|
- 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.
|
||||||
|
- API-Endpoints (z. B. `/api/*` über das Routing): Stellen JSON bereit für
|
||||||
|
- Drucker-Liste
|
||||||
|
- Material-Liste
|
||||||
|
- Drucker-spezifische Material-Kompatibilität
|
||||||
|
- DB-Zugriff (z. B. `src/App/Database.php`): Baut eine DB-Verbindung und wird von den API-Endpunkten genutzt.
|
||||||
|
|
||||||
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
|
Aktuelle DB-Struktur (Schema-Orientierung)
|
||||||
|
Das Schema besteht aus drei Kern-Tabellen, die für die Materialmatrix benötigt werden:
|
||||||
|
|
||||||
## Add your files
|
1) materials
|
||||||
|
- Stammdaten der Materialien (Code/Name/Short-Desc, Eigenschaften, Tg, Anforderungen, Anwendung).
|
||||||
|
- Flags für Kinder-Sicherheit und Emission.
|
||||||
|
- `is_active` steuert Sichtbarkeit.
|
||||||
|
|
||||||
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
|
2) printers
|
||||||
- [ ] [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
|
- Stammdaten der Drucker (Name/Brand/Modell, Temperaturgrenzen, Bauvolumen, Notes).
|
||||||
|
- `is_active` steuert Sichtbarkeit.
|
||||||
|
|
||||||
```
|
3) printer_material_support
|
||||||
cd existing_repo
|
- Zuordnung Drucker ↔ Material (M:N).
|
||||||
git remote add origin https://gitlab.int.kusche.berlin/shape3d/shape3d.git
|
- `support_level` beschreibt die Kompatibilität.
|
||||||
git branch -M main
|
- `partial_reason` / `extra_info` liefern Detailhinweise.
|
||||||
git push -uf origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integrate with your tools
|
Datenfluss (funktional)
|
||||||
|
1) UI lädt die Druckerliste.
|
||||||
|
2) Für den gewählten Drucker (oder mehrere) werden Materialien inkl. Support-Level geladen.
|
||||||
|
3) Die Tabelle rendert Materialinfos und pro Drucker eine Support-Badge-Zelle.
|
||||||
|
|
||||||
- [ ] [Set up project integrations](https://gitlab.int.kusche.berlin/shape3d/shape3d/-/settings/integrations)
|
Hinweise für Reimport/Neuaufbau
|
||||||
|
- Die Datei-/Ordnernamen dürfen sich ändern, solange die Rollen (UI, API, DB-Zugriff) abgebildet sind.
|
||||||
|
- Die API muss weiterhin JSON liefern, das die Matrix-Ansicht erwarten kann:
|
||||||
|
- `printer`: Drucker-Datensatz
|
||||||
|
- `materials`: Liste der Materialien mit optionalem `support_level` + Zusatzinfos.
|
||||||
|
- 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.
|
||||||
|
|
||||||
## Collaborate with your team
|
Sicherheit
|
||||||
|
- Zugangsdaten sollten nicht im Repo liegen. Nutze ENV-Variablen oder separate Configs pro Environment.
|
||||||
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
|
|
||||||
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
|
|
||||||
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
|
|
||||||
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
|
|
||||||
- [ ] [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
|
|
||||||
|
|
||||||
## Test and Deploy
|
|
||||||
|
|
||||||
Use the built-in continuous integration in GitLab.
|
|
||||||
|
|
||||||
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/)
|
|
||||||
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
|
|
||||||
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
|
|
||||||
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
|
|
||||||
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
|
|
||||||
|
|
||||||
***
|
|
||||||
|
|
||||||
# Editing this README
|
|
||||||
|
|
||||||
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
|
|
||||||
|
|
||||||
## Suggestions for a good README
|
|
||||||
|
|
||||||
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
|
|
||||||
|
|
||||||
## Name
|
|
||||||
Choose a self-explaining name for your project.
|
|
||||||
|
|
||||||
## Description
|
|
||||||
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
|
|
||||||
|
|
||||||
## Badges
|
|
||||||
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
|
|
||||||
|
|
||||||
## Visuals
|
|
||||||
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
|
|
||||||
|
|
||||||
## Support
|
|
||||||
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
If you have ideas for releases in the future, it is a good idea to list them in the README.
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
State if you are open to contributions and what your requirements are for accepting them.
|
|
||||||
|
|
||||||
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
|
|
||||||
|
|
||||||
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
|
|
||||||
|
|
||||||
## Authors and acknowledgment
|
|
||||||
Show your appreciation to those who have contributed to the project.
|
|
||||||
|
|
||||||
## License
|
|
||||||
For open source projects, say how it is licensed.
|
|
||||||
|
|
||||||
## Project status
|
|
||||||
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
67
config/config.php
Normal file
67
config/config.php
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
41
config/fileload.php
Normal file
41
config/fileload.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?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 +0,0 @@
|
|||||||
Laiv männ
|
|
||||||
@@ -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 +0,0 @@
|
|||||||
|
|
||||||
@@ -1 +1,93 @@
|
|||||||
<?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 = '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 @@
|
|||||||
Demo männ
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
69
partials/landing/main/material-matrix.php
Normal file
69
partials/landing/main/material-matrix.php
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
$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>
|
||||||
|
<p class="mm-kicker">Materialmatrix</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 class="mm-status" id="status">Bereit</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="mm-card">
|
||||||
|
<aside class="mm-sidebar">
|
||||||
|
<div class="mm-panel">
|
||||||
|
<h2>Drucker auswählen</h2>
|
||||||
|
<label for="printerSelect">Einzelansicht</label>
|
||||||
|
<select id="printerSelect">
|
||||||
|
<option value="">– wird geladen –</option>
|
||||||
|
</select>
|
||||||
|
<p>Zeigt die Kompatibilität nur für diesen Drucker.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mm-panel">
|
||||||
|
<label for="printerCompare">Vergleich (mehrere)</label>
|
||||||
|
<select id="printerCompare" multiple></select>
|
||||||
|
<p>Strg/⌘ gedrückt halten, um mehrere zu wählen.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mm-tip">
|
||||||
|
Tipp: Im Vergleich werden die ausgewählten Drucker rechts als separate Spalten hervorgehoben.
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="mm-main">
|
||||||
|
<div class="mm-table-wrap" id="tableWrap">
|
||||||
|
<table id="matTable" class="mm-table">
|
||||||
|
<thead>
|
||||||
|
<tr id="tableHead">
|
||||||
|
<th>Material</th>
|
||||||
|
<th>Eigenschaften</th>
|
||||||
|
<th>Tg °C</th>
|
||||||
|
<th>Düse</th>
|
||||||
|
<th>Platte</th>
|
||||||
|
<th>Zusatz</th>
|
||||||
|
<th>Anwendung</th>
|
||||||
|
<th>Kinder</th>
|
||||||
|
<th>Emission</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="matBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="errorBox" class="mm-error" hidden></div>
|
||||||
|
|
||||||
|
<section class="mm-disclaimer">
|
||||||
|
<p><strong>Hinweis:</strong> Dieses Projekt wird privat betrieben und befindet sich im Aufbau.
|
||||||
|
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>.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1 +1,3 @@
|
|||||||
<?php // TODO
|
<?php asset_scripts('footer'); ?>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
@@ -1 +1,14 @@
|
|||||||
<?php // TODO
|
<?php
|
||||||
|
/** @var \App\App $app */
|
||||||
|
$app = app();
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title><?= htmlspecialchars(t('common.title'), ENT_QUOTES) ?></title>
|
||||||
|
<?php asset_styles(); ?>
|
||||||
|
<?php asset_scripts('header'); ?>
|
||||||
|
</head>
|
||||||
|
<body class="app-body">
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<?php // TODO
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<?php // TODO
|
|
||||||
0
public/.gitkeep
Normal file
0
public/.gitkeep
Normal file
32
public/.htaccess
Normal file
32
public/.htaccess
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# -------------------------------------------------
|
||||||
|
# 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,16 +0,0 @@
|
|||||||
<?php
|
|
||||||
// public/api/_db.php
|
|
||||||
$cfg = require __DIR__ . '/../../src/config.php';
|
|
||||||
$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,
|
|
||||||
];
|
|
||||||
try {
|
|
||||||
$pdo = new PDO($dsn, $cfg['db_user'], $cfg['db_pass'], $options);
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
http_response_code(500);
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
echo json_encode(['error' => 'DB connection failed']);
|
|
||||||
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,6 +0,0 @@
|
|||||||
<?php
|
|
||||||
// public/api/printers.php
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
|
||||||
require __DIR__ . '/_db.php';
|
|
||||||
$stmt = $pdo->query("SELECT * FROM printers WHERE is_active = 1 ORDER BY name");
|
|
||||||
echo json_encode($stmt->fetchAll(), 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,266 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>3D-Druck Materialmatrix</title>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
background: radial-gradient(circle at top, #e2e8f0 0%, #f8fafc 45%, #e2e8f0 90%);
|
|
||||||
}
|
|
||||||
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">
|
|
||||||
<div class="max-w-6xl mx-auto py-6 space-y-6">
|
|
||||||
<header class="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-slate-900">3D-Druck Materialmatrix</h1>
|
|
||||||
<p class="text-xs text-slate-500">Schnell prüfen, welche Filamente auf welchen Druckern laufen.</p>
|
|
||||||
</div>
|
|
||||||
<span id="status" class="text-xs text-slate-500"></span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="bg-white/80 backdrop-blur rounded-lg shadow flex gap-6 p-4 min-h-[420px]">
|
|
||||||
<!-- Sidebar -->
|
|
||||||
<aside class="w-72 space-y-5 border-r pr-4 border-slate-100">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-sm font-semibold text-slate-700 mb-2">Drucker auswählen</h2>
|
|
||||||
<label class="block text-xs font-medium mb-1 text-slate-600">Einzelansicht</label>
|
|
||||||
<select id="printerSelect" class="w-full border rounded px-2 py-1 mb-2 text-sm">
|
|
||||||
<option value="">– wird geladen –</option>
|
|
||||||
</select>
|
|
||||||
<p class="text-xs text-slate-500">
|
|
||||||
Zeigt die Kompatibilität nur für diesen Drucker.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium mb-1 text-slate-600">Vergleich (mehrere)</label>
|
|
||||||
<select id="printerCompare" multiple class="w-full border rounded px-2 py-1 h-32 text-sm"></select>
|
|
||||||
<p class="text-xs text-slate-500">
|
|
||||||
Strg/⌘ gedrückt halten, um mehrere zu wählen.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-xs text-slate-400">
|
|
||||||
Tipp: Im Vergleich werden die ausgewählten Drucker rechts als separate Spalten eingefärbt.
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Main -->
|
|
||||||
<main class="flex-1 flex flex-col gap-3">
|
|
||||||
<div class="overflow-auto max-h-[70vh] rounded border bg-white" id="tableWrap">
|
|
||||||
<table class="min-w-full text-sm" id="matTable">
|
|
||||||
<thead class="bg-slate-50">
|
|
||||||
<tr id="tableHead">
|
|
||||||
<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>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="matBody"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="errorBox" class="hidden rounded bg-rose-50 border border-rose-200 text-rose-700 text-sm px-3 py-2"></div>
|
|
||||||
|
|
||||||
<!-- Hinweisblock unten -->
|
|
||||||
<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.
|
|
||||||
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>.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
334
public/index.php
334
public/index.php
@@ -1,270 +1,88 @@
|
|||||||
<?php
|
<?php
|
||||||
header('Content-Type: text/html; charset=utf-8');
|
declare(strict_types=1);
|
||||||
echo <<<'HTML'
|
|
||||||
<!doctype html>
|
// boot application (config, autoload, services)
|
||||||
<html lang="de">
|
require_once __DIR__ . '/../config/fileload.php';
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
// Staging-Access-Protection (Basic Auth)
|
||||||
<title>3D-Druck Materialmatrix</title>
|
$uriPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
$uriPath = preg_replace('~/{2,}~', '/', $uriPath);
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
$uriPath = trim($uriPath, '/');
|
||||||
<style>
|
$isRetoolPath = ($uriPath === 'retool' || str_starts_with($uriPath, 'retool/'));
|
||||||
body {
|
if (defined('APP_ENV') && APP_ENV === 'staging' && !$isRetoolPath) {
|
||||||
background: radial-gradient(circle at top, #e2e8f0 0%, #f8fafc 45%, #e2e8f0 90%);
|
$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;
|
||||||
}
|
}
|
||||||
thead th {
|
}
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
// Sicherheitscheck
|
||||||
backdrop-filter: blur(4px);
|
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';
|
||||||
}
|
}
|
||||||
th[data-printer],
|
// 2) Datei
|
||||||
td[data-printer] {
|
elseif (is_file($base . '.php')) {
|
||||||
background: rgba(148, 163, 184, 0.05);
|
|
||||||
|
$target = $base . '.php';
|
||||||
}
|
}
|
||||||
#disclaimer {
|
// 3) 404
|
||||||
box-shadow: inset 0 1px 0 rgba(148, 163, 184, 0.25);
|
elseif (is_file($base)) {
|
||||||
|
|
||||||
|
$target = $base;
|
||||||
}
|
}
|
||||||
</style>
|
// 3) 404
|
||||||
</head>
|
else {
|
||||||
<body class="bg-slate-100 min-h-screen">
|
http_response_code(404);
|
||||||
<div class="max-w-6xl mx-auto py-6 space-y-6">
|
$target = __DIR__ . '/page/404.php';
|
||||||
<header class="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-slate-900">3D-Druck Materialmatrix</h1>
|
|
||||||
<p class="text-xs text-slate-500">Schnell prüfen, welche Filamente auf welchen Druckern laufen.</p>
|
|
||||||
</div>
|
|
||||||
<span id="status" class="text-xs text-slate-500"></span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="bg-white/80 backdrop-blur rounded-lg shadow flex gap-6 p-4 min-h-[420px]">
|
|
||||||
<!-- Sidebar -->
|
|
||||||
<aside class="w-72 space-y-5 border-r pr-4 border-slate-100">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-sm font-semibold text-slate-700 mb-2">Drucker auswählen</h2>
|
|
||||||
<label class="block text-xs font-medium mb-1 text-slate-600">Einzelansicht</label>
|
|
||||||
<select id="printerSelect" class="w-full border rounded px-2 py-1 mb-2 text-sm">
|
|
||||||
<option value="">– wird geladen –</option>
|
|
||||||
</select>
|
|
||||||
<p class="text-xs text-slate-500">
|
|
||||||
Zeigt die Kompatibilität nur für diesen Drucker.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium mb-1 text-slate-600">Vergleich (mehrere)</label>
|
|
||||||
<select id="printerCompare" multiple class="w-full border rounded px-2 py-1 h-32 text-sm"></select>
|
|
||||||
<p class="text-xs text-slate-500">
|
|
||||||
Strg/⌘ gedrückt halten, um mehrere zu wählen.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-xs text-slate-400">
|
|
||||||
Tipp: Im Vergleich werden die ausgewählten Drucker rechts als separate Spalten eingefärbt.
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Main -->
|
|
||||||
<main class="flex-1 flex flex-col gap-3">
|
|
||||||
<div class="overflow-auto max-h-[70vh] rounded border bg-white" id="tableWrap">
|
|
||||||
<table class="min-w-full text-sm" id="matTable">
|
|
||||||
<thead class="bg-slate-50">
|
|
||||||
<tr id="tableHead">
|
|
||||||
<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>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="matBody"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="errorBox" class="hidden rounded bg-rose-50 border border-rose-200 text-rose-700 text-sm px-3 py-2"></div>
|
|
||||||
|
|
||||||
<!-- Hinweisblock unten -->
|
|
||||||
<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.
|
|
||||||
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>.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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');
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// ------------------------------------
|
||||||
|
// Layout-Regel
|
||||||
|
// ------------------------------------
|
||||||
|
$skipLayout = false;
|
||||||
|
$targetReal = realpath($target);
|
||||||
|
|
||||||
async function fetchJSON(url) {
|
// Beispiel: alles unter /page/raw/* ohne Layout
|
||||||
const res = await fetch(url);
|
if ($targetReal && str_starts_with($targetReal, realpath(__DIR__ . '/page/retool'))) {
|
||||||
if (!res.ok) throw new Error('HTTP ' + res.status + ' for ' + url);
|
$skipLayout = true;
|
||||||
return await res.json();
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async function loadPrinters() {
|
// ------------------------------------
|
||||||
try {
|
// Ausgabe
|
||||||
statusEl.textContent = 'Lade Drucker …';
|
// ------------------------------------
|
||||||
const data = await fetchJSON(`${API_BASE}/printers.php`);
|
// Erst Inhalt laden (ohne Ausgabe), damit Header/Redirects vor HTML funktionieren
|
||||||
printerSelect.innerHTML = '';
|
ob_start();
|
||||||
printerCompare.innerHTML = '';
|
require $target;
|
||||||
if (!data.length) {
|
$content = ob_get_clean();
|
||||||
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) {
|
// Wenn bereits Header gesendet wurden (z. B. eigener Redirect/Content-Type), Layout überspringen
|
||||||
if (!id) return;
|
if (headers_sent()) {
|
||||||
clearError();
|
$skipLayout = true;
|
||||||
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 (!$skipLayout) {
|
||||||
if (!ids.length) return;
|
tpl('layout_start', 'structure');
|
||||||
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) {
|
echo $content;
|
||||||
// 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 ?? [];
|
if (!$skipLayout) {
|
||||||
matBody.innerHTML = '';
|
tpl('layout_end', 'structure');
|
||||||
|
}
|
||||||
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>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
HTML;
|
|
||||||
|
|||||||
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;
|
||||||
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 @@
|
|||||||
|
|
||||||
|
|||||||
398
src/App/AccountPages.php
Normal file
398
src/App/AccountPages.php
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
<?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];
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/App/App.php
Normal file
50
src/App/App.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?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; }
|
||||||
|
}
|
||||||
44
src/App/Assets.php
Normal file
44
src/App/Assets.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?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; }
|
||||||
|
}
|
||||||
217
src/App/Auth.php
Normal file
217
src/App/Auth.php
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<?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];
|
||||||
|
}
|
||||||
|
}
|
||||||
196
src/App/Community.php
Normal file
196
src/App/Community.php
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
<?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'] ?? ''];
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/App/Config.php
Normal file
62
src/App/Config.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?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, '.');
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/App/Crypto.php
Normal file
68
src/App/Crypto.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?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 '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src/App/Database.php
Normal file
123
src/App/Database.php
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/App/Flash.php
Normal file
33
src/App/Flash.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?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'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/App/I18n.php
Normal file
59
src/App/I18n.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
352
src/App/Mailer.php
Normal file
352
src/App/Mailer.php
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/App/Request.php
Normal file
47
src/App/Request.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?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, '?');
|
||||||
|
}
|
||||||
|
}
|
||||||
241
src/App/Search.php
Normal file
241
src/App/Search.php
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
<?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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/App/SessionManager.php
Normal file
71
src/App/SessionManager.php
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?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,10 +0,0 @@
|
|||||||
<?php
|
|
||||||
// inc/config.php
|
|
||||||
// Diese Datei außerhalb des Webroots ablegen!
|
|
||||||
return [
|
|
||||||
'db_host' => 'localhost',
|
|
||||||
'db_name' => 'd0453540',
|
|
||||||
'db_user' => 'd0453540',
|
|
||||||
'db_pass' => 'P6jGRrSaX8QSiBMEJBL7',
|
|
||||||
'db_charset' => 'utf8mb4',
|
|
||||||
];
|
|
||||||
103
src/helpers.php
Normal file
103
src/helpers.php
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<?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 @@
|
|||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user