Compare commits
15
Commits
fa5894426d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b246a6d04e | ||
|
|
9c148a1242 | ||
|
|
ac4a2af7cb | ||
|
|
502dacd868 | ||
|
|
92d0327857 | ||
|
|
e943d50c9c | ||
|
|
15de8e5516 | ||
|
|
ca9ffe7e46 | ||
|
|
176394785f | ||
|
|
76690c919c | ||
|
|
4be948557e | ||
|
|
7cc1aa90b5 | ||
|
|
4e5efa9cb9 | ||
|
|
f4c20d0504 | ||
|
|
0db09bf098 |
@@ -1,10 +1,10 @@
|
|||||||
# Referenzwerte für die Produktion (docker1: /root/stacks/svc-essensplanung/.env,
|
# Referenzwerte für die Produktion (docker1: /root/stacks/svc-essensplanung/.env.local,
|
||||||
# als env_file über compose gemountet – siehe Doku/plan.md M5).
|
# als env_file über compose gemountet – siehe Doku/plan.md M5).
|
||||||
# Lokale Entwicklung nutzt stattdessen .env (var/data.db).
|
# Lokale Entwicklung nutzt die gleiche DB (postgresql.lan).
|
||||||
APP_ENV=prod
|
APP_ENV=prod
|
||||||
APP_DEBUG=0
|
APP_DEBUG=0
|
||||||
APP_SECRET=<random-64-hex>
|
APP_SECRET=<random-64-hex>
|
||||||
DATABASE_URL=sqlite:////data/essens.db
|
DATABASE_URL=postgresql://essens:<passwort>@postgresql.lan:5432/essens?serverVersion=15&charset=utf8
|
||||||
KEYCLOAK_ISSUER=https://iam.ljh31.de/realms/family
|
KEYCLOAK_ISSUER=https://iam.ljh31.de/realms/family
|
||||||
KEYCLOAK_CLIENT_ID=essens
|
KEYCLOAK_CLIENT_ID=essens
|
||||||
KEYCLOAK_CLIENT_SECRET=<keycloak-client-secret>
|
KEYCLOAK_CLIENT_SECRET=<keycloak-client-secret>
|
||||||
|
|||||||
+6
-5
@@ -1,20 +1,21 @@
|
|||||||
FROM php:8.4-frankenphp
|
FROM dunglas/frankenphp:1.12.7-php8.4
|
||||||
|
|
||||||
ENV APP_ENV=prod \
|
ENV APP_ENV=prod \
|
||||||
COMPOSER_ALLOW_SUPERUSER=1
|
COMPOSER_ALLOW_SUPERUSER=1 \
|
||||||
|
SERVER_NAME=:8787
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends git unzip sqlite3 \
|
&& apt-get install -y --no-install-recommends git unzip sqlite3 libicu-dev libpq-dev \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN docker-php-ext-install pdo pdo_sqlite opcache bcmath intl
|
RUN docker-php-ext-install bcmath intl pgsql pdo_pgsql
|
||||||
|
|
||||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# composer.json zuerst → Layer-Cache
|
# composer.json zuerst → Layer-Cache
|
||||||
COPY composer.json composer.lock ./
|
COPY composer.json composer.lock ./
|
||||||
RUN composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist
|
RUN composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist --no-scripts
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
// Einmalige Daten-Migration: SQLite /data/essens.db -> PostgreSQL (DATABASE_URL)
|
||||||
|
// Aufruf im App-Container: php bin/migrate-sqlite-to-pg.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$sqlitePath = '/data/essens.db';
|
||||||
|
$pgUrl = $_SERVER['DATABASE_URL'] ?? getenv('DATABASE_URL');
|
||||||
|
if (!$pgUrl) {
|
||||||
|
fwrite(STDERR, "DATABASE_URL nicht gesetzt\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sqlite = new PDO('sqlite:' . $sqlitePath);
|
||||||
|
$sqlite->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
|
||||||
|
// PDO pgsql akzeptiert keine // URLs -> DSN-Form
|
||||||
|
preg_match('#^[a-z+]+://([^:@/]*)(?::([^@/]*))?@([^:/?]*)(?::(\d+))?/([^?]*)(?:\?(.*))?$#', $pgUrl, $m);
|
||||||
|
[$authUser, $authPass, $host, $port, $dbname] = [$m[1] ?? 'essens', $m[2] ?? '', $m[3] ?? 'localhost', $m[4] ?? '5432', $m[5] ?? 'essens'];
|
||||||
|
$pg = new PDO(
|
||||||
|
sprintf('pgsql:host=%s;port=%s;dbname=%s;user=%s;password=%s', $host, $port, $dbname, $authUser, $authPass),
|
||||||
|
$authUser,
|
||||||
|
$authPass,
|
||||||
|
);
|
||||||
|
$pg->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
|
||||||
|
$pg->beginTransaction();
|
||||||
|
try {
|
||||||
|
$userStmt = $pg->prepare('INSERT INTO essens_user (id, username, email, kc_sub, created_am, updated_am) VALUES (?, ?, ?, ?, ?, ?)');
|
||||||
|
$mealStmt = $pg->prepare('INSERT INTO essens_mahlzeiten (id, name, notiz, wochentage, sort_order, status, gegessen_am, erstellt_am, aktualisiert_am) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
||||||
|
|
||||||
|
foreach ($sqlite->query('SELECT id, username, email, kc_sub, created_am, updated_am FROM essens_user') as $r) {
|
||||||
|
$userStmt->execute([$r['id'], $r['username'], $r['email'], $r['kc_sub'] ?: null, $r['created_am'] ?: null, $r['updated_am'] ?: null]);
|
||||||
|
}
|
||||||
|
foreach ($sqlite->query('SELECT id, name, notiz, wochentage, sort_order, status, gegessen_am, erstellt_am, aktualisiert_am FROM essens_mahlzeiten') as $r) {
|
||||||
|
$mealStmt->execute([
|
||||||
|
$r['id'], $r['name'], $r['notiz'] ?: null,
|
||||||
|
$r['wochentage'] ?: null,
|
||||||
|
(int) $r['sort_order'], $r['status'],
|
||||||
|
$r['gegessen_am'] ?: null, $r['erstellt_am'] ?: null, $r['aktualisiert_am'] ?: null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pg->exec("SELECT setval('essens_user_id_seq', COALESCE(MAX(id), 0) + 1, false) FROM essens_user");
|
||||||
|
$pg->exec("SELECT setval('essens_mahlzeiten_id_seq', COALESCE(MAX(id), 0) + 1, false) FROM essens_mahlzeiten");
|
||||||
|
$pg->commit();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$pg->rollBack();
|
||||||
|
fwrite(STDERR, get_class($e) . ': ' . $e->getMessage() . "\n");
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
$u = $pg->query('SELECT count(*) FROM essens_user')->fetchColumn();
|
||||||
|
$m = $pg->query('SELECT count(*) FROM essens_mahlzeiten')->fetchColumn();
|
||||||
|
echo "Import ok: $u User, $m Mahlzeiten\n";
|
||||||
Executable
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Einmalige Migration: SQLite (/data/essens.db, Volume essens_data) -> PostgreSQL (postgresql.lan/essens)
|
||||||
|
# Läuft auf docker1 im Stack-Verzeichnis. Erwartet: .env.local enthält DATABASE_URL="postgresql://essens:<pw>@postgresql.lan:5432/essens?..."
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
DB_URL=$(grep -m1 '^DATABASE_URL=' .env.local | sed 's/^DATABASE_URL=//; s/^"//; s/"$//')
|
||||||
|
PG_USER=$(sed -n 's|.*://\([^:@]*\):.*|\1|p' <<<"$DB_URL")
|
||||||
|
PG_PASS=$(sed -n 's|.*://[^:@]*:\([^@]*\)@.*|\1|p' <<<"$DB_URL")
|
||||||
|
PG_HOST=$(sed -n 's|.*@\([^:/?]*\).*|\1|p' <<<"$DB_URL")
|
||||||
|
PG_DB=$(sed -n 's|.*/\([^/?]*\).*|\1|p' <<<"$DB_URL")
|
||||||
|
export PGPASSWORD="$PG_PASS"
|
||||||
|
|
||||||
|
echo "== 1) SQLite-Export (CSV) aus dem laufenden Container"
|
||||||
|
TMP=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
docker exec svc-essensplanung sh -c 'sqlite3 -cmd ".nullvalue NULL" -csv /data/essens.db "SELECT * FROM essens_user;"' > "$TMP/essens_user.csv"
|
||||||
|
docker exec svc-essensplanung sh -c 'sqlite3 -cmd ".nullvalue NULL" -csv /data/essens.db "SELECT * FROM essens_mahlzeiten;"' > "$TMP/essens_mahlzeiten.csv"
|
||||||
|
echo " essens_user: $(wc -l < "$TMP/essens_user.csv") Zeilen"
|
||||||
|
echo " essens_mahlzeiten: $(wc -l < "$TMP/essens_mahlzeiten.csv") Zeilen"
|
||||||
|
|
||||||
|
echo "== 2) Schema-Migrations auf PostgreSQL"
|
||||||
|
docker compose run --rm --no-deps app bin/console doctrine:migrations:migrate --no-interaction
|
||||||
|
|
||||||
|
echo "== 3) Datenimport via COPY"
|
||||||
|
psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB" <<EOF
|
||||||
|
BEGIN;
|
||||||
|
COPY essens_user (id, username, email, kc_sub, created_am, updated_am) FROM STDIN WITH (FORMAT csv);
|
||||||
|
$(cat "$TMP/essens_user.csv")
|
||||||
|
\.
|
||||||
|
COPY essens_mahlzeiten (id, name, notiz, wochentage, sort_order, status, gegessen_am, erstellt_am, aktualisiert_am) FROM STDIN WITH (FORMAT csv);
|
||||||
|
$(cat "$TMP/essens_mahlzeiten.csv")
|
||||||
|
\.
|
||||||
|
SELECT setval('essens_user_id_seq', COALESCE(MAX(id), 0) + 1, false) FROM essens_user;
|
||||||
|
SELECT setval('essens_mahlzeiten_id_seq', COALESCE(MAX(id), 0) + 1, false) FROM essens_mahlzeiten;
|
||||||
|
COMMIT;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "== 4) Verifikation"
|
||||||
|
psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB" -c 'SELECT (SELECT count(*) FROM essens_user) AS users, (SELECT count(*) FROM essens_mahlzeiten) AS mahlzeiten;'
|
||||||
+7
-3
@@ -4,16 +4,19 @@ services:
|
|||||||
container_name: svc-essensplanung
|
container_name: svc-essensplanung
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
# Secrets + Prod-Redirect-URI: APP_SECRET, API_TOKEN, KEYCLOAK_REDIRECT_URI
|
||||||
|
- .env.local
|
||||||
environment:
|
environment:
|
||||||
APP_ENV: prod
|
APP_ENV: prod
|
||||||
APP_DEBUG: '0'
|
APP_DEBUG: '0'
|
||||||
DATABASE_URL: sqlite:////data/essens.db
|
DEFAULT_URI: https://essen.ljh31.de
|
||||||
|
KEYCLOAK_ISSUER: https://iam.ljh31.de/realms/family
|
||||||
|
KEYCLOAK_CLIENT_ID: essen
|
||||||
volumes:
|
volumes:
|
||||||
- essens_data:/data
|
- essens_data:/data
|
||||||
- essens_var:/app/var
|
- essens_var:/app/var
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD', 'php', '-r', '$c=@fsockopen("127.0.0.1",8787); exit($c?0:1);']
|
test: ['CMD', 'php', '-r', '$$c=@fsockopen("127.0.0.1",8787); exit($$c?0:1);']
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -24,6 +27,7 @@ services:
|
|||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.essens.rule=Host(`essen.ljh31.de`)"
|
- "traefik.http.routers.essens.rule=Host(`essen.ljh31.de`)"
|
||||||
- "traefik.http.routers.essens.entrypoints=websecure"
|
- "traefik.http.routers.essens.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.essens.middlewares=keycloak-essens@file,error-404@file,error-500@file"
|
||||||
- "traefik.http.routers.essens.tls=true"
|
- "traefik.http.routers.essens.tls=true"
|
||||||
- "traefik.http.routers.essens.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.essens.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.services.essens.loadbalancer.server.port=8787"
|
- "traefik.http.services.essens.loadbalancer.server.port=8787"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"php": ">=8.2",
|
"php": ">=8.2",
|
||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
|
"ext-pgsql": "*",
|
||||||
"doctrine/doctrine-bundle": "^3.3",
|
"doctrine/doctrine-bundle": "^3.3",
|
||||||
"doctrine/doctrine-migrations-bundle": "^4.0",
|
"doctrine/doctrine-migrations-bundle": "^4.0",
|
||||||
"doctrine/orm": "^3.7",
|
"doctrine/orm": "^3.7",
|
||||||
|
|||||||
@@ -3,7 +3,14 @@ framework:
|
|||||||
secret: '%env(APP_SECRET)%'
|
secret: '%env(APP_SECRET)%'
|
||||||
|
|
||||||
# Note that the session will be started ONLY if you read or write from it.
|
# Note that the session will be started ONLY if you read or write from it.
|
||||||
session: true
|
session:
|
||||||
|
# 7 Tage: Session überlebt Browser-Neustarts (Default 0 = Session-Cookie)
|
||||||
|
cookie_lifetime: 604800
|
||||||
|
# 7 Tage: PHP-GC löszt inaktive Sessions nicht nach 24 min (Default 1440)
|
||||||
|
gc_maxlifetime: 604800
|
||||||
|
# Außerhalb des Cache-Verzeichnisses: Deploy/Cache-Clear löscht keine Sessions
|
||||||
|
# (Volume essens_var → bleibt über Rebuilds erhalten)
|
||||||
|
save_path: '%kernel.project_dir%/var/sessions'
|
||||||
|
|
||||||
http_client:
|
http_client:
|
||||||
default_options:
|
default_options:
|
||||||
|
|||||||
@@ -946,6 +946,15 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* twig?: TwigConfig,
|
* twig?: TwigConfig,
|
||||||
* doctrine?: DoctrineConfig,
|
* doctrine?: DoctrineConfig,
|
||||||
* doctrine_migrations?: DoctrineMigrationsConfig,
|
* doctrine_migrations?: DoctrineMigrationsConfig,
|
||||||
|
* "when@dev"?: array{
|
||||||
|
* imports?: ImportsConfig,
|
||||||
|
* parameters?: ParametersConfig,
|
||||||
|
* services?: ServicesConfig,
|
||||||
|
* framework?: FrameworkConfig,
|
||||||
|
* twig?: TwigConfig,
|
||||||
|
* doctrine?: DoctrineConfig,
|
||||||
|
* doctrine_migrations?: DoctrineMigrationsConfig,
|
||||||
|
* },
|
||||||
* "when@prod"?: array{
|
* "when@prod"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
* parameters?: ParametersConfig,
|
* parameters?: ParametersConfig,
|
||||||
@@ -1046,6 +1055,7 @@ namespace Symfony\Component\Routing\Loader\Configurator;
|
|||||||
* deprecated?: array{package:string, version:string, message?:string},
|
* deprecated?: array{package:string, version:string, message?:string},
|
||||||
* }
|
* }
|
||||||
* @psalm-type RoutesConfig = array{
|
* @psalm-type RoutesConfig = array{
|
||||||
|
* "when@dev"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
||||||
* "when@prod"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
* "when@prod"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
||||||
* "when@test"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
* "when@test"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
||||||
* ...<string, RouteConfig|ImportConfig|AliasConfig>
|
* ...<string, RouteConfig|ImportConfig|AliasConfig>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
# yaml-language-server: $schema=https://symfony.com/schema/config/routing/routing.yaml
|
# yaml-language-server: $schema=https://symfony.com/schema/config/routing/routing.yaml
|
||||||
|
|
||||||
|
import:
|
||||||
|
resource: routes/api.yaml
|
||||||
|
|
||||||
home:
|
home:
|
||||||
path: /
|
path: /
|
||||||
controller: App\UI\Http\HomeController::index
|
controller: App\UI\Http\HomeController::index
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# yaml-language-server: $schema=https://symfony.com/schema/config/routing/routing.yaml
|
||||||
|
# Stateless-JSON-API für HA & Co. (Auth: X-Api-Token, s. ApiTokenSubscriber)
|
||||||
|
|
||||||
|
api_mahlzeiten_liste:
|
||||||
|
path: /api/mahlzeiten
|
||||||
|
controller: App\UI\Http\Api\MahlzeitApiController::index
|
||||||
|
methods: [GET]
|
||||||
|
|
||||||
|
api_mahlzeiten_create:
|
||||||
|
path: /api/mahlzeiten
|
||||||
|
controller: App\UI\Http\Api\MahlzeitApiController::create
|
||||||
|
methods: [POST]
|
||||||
|
|
||||||
|
api_mahlzeiten_checkoff:
|
||||||
|
path: /api/mahlzeiten/{id}/checkoff
|
||||||
|
controller: App\UI\Http\Api\MahlzeitApiController::checkoff
|
||||||
|
methods: [POST]
|
||||||
|
requirements:
|
||||||
|
id: \d+
|
||||||
|
|
||||||
|
api_mahlzeiten_uncheck:
|
||||||
|
path: /api/mahlzeiten/{id}/uncheck
|
||||||
|
controller: App\UI\Http\Api\MahlzeitApiController::uncheck
|
||||||
|
methods: [POST]
|
||||||
|
requirements:
|
||||||
|
id: \d+
|
||||||
@@ -8,26 +8,24 @@ use Doctrine\DBAL\Schema\Schema;
|
|||||||
use Doctrine\Migrations\AbstractMigration;
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-generated Migration: Please modify to your needs!
|
* Postgres-Port der ursprünglichen SQLite-Migration (2026-09-13).
|
||||||
*/
|
*/
|
||||||
final class Version20260913204759 extends AbstractMigration
|
final class Version20260913204759 extends AbstractMigration
|
||||||
{
|
{
|
||||||
public function getDescription(): string
|
public function getDescription(): string
|
||||||
{
|
{
|
||||||
return '';
|
return 'essens_user (Postgres)';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
public function up(Schema $schema): void
|
||||||
{
|
{
|
||||||
// this up() migration is auto-generated, please modify it to your needs
|
$this->addSql('CREATE TABLE essens_user (id SERIAL PRIMARY KEY, username VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, kc_sub VARCHAR(64) NOT NULL, created_am TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_am TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL)');
|
||||||
$this->addSql('CREATE TABLE essens_user (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, username VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, kc_sub VARCHAR(64) NOT NULL, created_am DATETIME NOT NULL, updated_am DATETIME NOT NULL)');
|
|
||||||
$this->addSql('CREATE UNIQUE INDEX uniq_email ON essens_user (email)');
|
$this->addSql('CREATE UNIQUE INDEX uniq_email ON essens_user (email)');
|
||||||
$this->addSql('CREATE UNIQUE INDEX uniq_kc_sub ON essens_user (kc_sub)');
|
$this->addSql('CREATE UNIQUE INDEX uniq_kc_sub ON essens_user (kc_sub)');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
public function down(Schema $schema): void
|
||||||
{
|
{
|
||||||
// this down() migration is auto-generated, please modify it to your needs
|
|
||||||
$this->addSql('DROP TABLE essens_user');
|
$this->addSql('DROP TABLE essens_user');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,26 +8,24 @@ use Doctrine\DBAL\Schema\Schema;
|
|||||||
use Doctrine\Migrations\AbstractMigration;
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-generated Migration: Please modify to your needs!
|
* Postgres-Port der ursprünglichen SQLite-Migration (2026-09-18).
|
||||||
*/
|
*/
|
||||||
final class Version20260918213517 extends AbstractMigration
|
final class Version20260918213517 extends AbstractMigration
|
||||||
{
|
{
|
||||||
public function getDescription(): string
|
public function getDescription(): string
|
||||||
{
|
{
|
||||||
return '';
|
return 'essens_mahlzeiten (Postgres)';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
public function up(Schema $schema): void
|
||||||
{
|
{
|
||||||
// this up() migration is auto-generated, please modify it to your needs
|
$this->addSql('CREATE TABLE essens_mahlzeiten (id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, notiz TEXT DEFAULT NULL, wochentage VARCHAR(32) DEFAULT NULL, sort_order INTEGER NOT NULL, status VARCHAR(32) NOT NULL, gegessen_am TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, erstellt_am TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, aktualisiert_am TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL)');
|
||||||
$this->addSql('CREATE TABLE essens_mahlzeiten (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, notiz CLOB DEFAULT NULL, wochentage VARCHAR(32) DEFAULT NULL, sort_order INTEGER NOT NULL, status VARCHAR(32) NOT NULL, gegessen_am DATETIME DEFAULT NULL, erstellt_am DATETIME NOT NULL, aktualisiert_am DATETIME NOT NULL)');
|
|
||||||
$this->addSql('CREATE INDEX idx_status ON essens_mahlzeiten (status)');
|
$this->addSql('CREATE INDEX idx_status ON essens_mahlzeiten (status)');
|
||||||
$this->addSql('CREATE INDEX idx_sort_order ON essens_mahlzeiten (sort_order)');
|
$this->addSql('CREATE INDEX idx_sort_order ON essens_mahlzeiten (sort_order)');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
public function down(Schema $schema): void
|
||||||
{
|
{
|
||||||
// this down() migration is auto-generated, please modify it to your needs
|
|
||||||
$this->addSql('DROP TABLE essens_mahlzeiten');
|
$this->addSql('DROP TABLE essens_mahlzeiten');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "Essensplanung",
|
||||||
|
"short_name": "Essen",
|
||||||
|
"description": "Familien-Essensplanung: Planungsliste, Tagesansicht, Inspiration.",
|
||||||
|
"lang": "de",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#f4eee1",
|
||||||
|
"theme_color": "#3f6d4e",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/* Service Worker: App-Shell cachen (Offline + schneller Start).
|
||||||
|
Version bei jeder Template-/Asset-Änderung bumpen (SHELL_VERSION),
|
||||||
|
damit alte Caches beim Activate bereinigt werden. */
|
||||||
|
const SHELL_VERSION = 'v1';
|
||||||
|
const SHELL_CACHE = 'essen-shell-' + SHELL_VERSION;
|
||||||
|
const SHELL = [
|
||||||
|
'/',
|
||||||
|
'/manifest.webmanifest',
|
||||||
|
'/js/htmx.min.js',
|
||||||
|
'/js/sortable.min.js',
|
||||||
|
'/icons/icon-192.png',
|
||||||
|
'/icons/icon-512.png',
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.open(SHELL_CACHE)
|
||||||
|
.then((cache) => cache.addAll(SHELL))
|
||||||
|
.then(() => self.skipWaiting())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.keys()
|
||||||
|
.then((keys) =>
|
||||||
|
Promise.all(
|
||||||
|
keys
|
||||||
|
.filter((key) => key.startsWith('essen-shell-') && key !== SHELL_CACHE)
|
||||||
|
.map((key) => caches.delete(key))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
if (event.request.method !== 'GET' || url.origin !== self.location.origin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigations: erst Netzwerk (frische Deploys!), dann Cache (Offline).
|
||||||
|
if (event.request.mode === 'navigate') {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request)
|
||||||
|
.then((response) => {
|
||||||
|
const kopie = response.clone();
|
||||||
|
caches.open(SHELL_CACHE).then((cache) => cache.put(event.request, kopie));
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
caches.match(event.request).then((treffer) => treffer || caches.match('/'))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statische Assets: Cache-first (unveränderlich pro Version).
|
||||||
|
if (
|
||||||
|
url.pathname.startsWith('/js/') ||
|
||||||
|
url.pathname.startsWith('/icons/') ||
|
||||||
|
url.pathname === '/manifest.webmanifest'
|
||||||
|
) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request).then(
|
||||||
|
(treffer) =>
|
||||||
|
treffer ||
|
||||||
|
fetch(event.request).then((response) => {
|
||||||
|
const kopie = response.clone();
|
||||||
|
caches.open(SHELL_CACHE).then((cache) => cache.put(event.request, kopie));
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\UI\Http\Api;
|
||||||
|
|
||||||
|
use App\Logic\Essen\Dto\MahlzeitCreateRequest;
|
||||||
|
use App\Logic\Essen\Dto\MahlzeitListRequest;
|
||||||
|
use App\Logic\Essen\Model\MahlzeitStatus;
|
||||||
|
use App\Logic\Essen\Model\Wochentag;
|
||||||
|
use App\Logic\Essen\Query\MahlzeitListQuery;
|
||||||
|
use App\Logic\Essen\UseCase\MahlzeitCheckOffUseCase;
|
||||||
|
use App\Logic\Essen\UseCase\MahlzeitCreateUseCase;
|
||||||
|
use App\Logic\Essen\UseCase\MahlzeitUncheckUseCase;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stateless JSON-API für Consumer wie Home Assistant (Auth via `X-Api-Token`,
|
||||||
|
* s. `ApiTokenSubscriber`).
|
||||||
|
*
|
||||||
|
* - `GET /api/mahlzeiten` – Liste, optional `?status=geplant|gegessen`.
|
||||||
|
* - `POST /api/mahlzeiten/{id}/checkoff` – abhaken (geplant → gegessen).
|
||||||
|
* - `POST /api/mahlzeiten/{id}/uncheck` – aufheben (gegessen → geplant).
|
||||||
|
* - `POST /api/mahlzeiten` – anlegen (JSON `name` Pflicht, `notiz`/`wochentage` optional).
|
||||||
|
*
|
||||||
|
* Fachregeln laufen über dieselben UseCases/Queries wie die Web-UI;
|
||||||
|
* Fehler werden vom `ExceptionSubscriber` als JSON gerendert.
|
||||||
|
*/
|
||||||
|
final readonly class MahlzeitApiController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private MahlzeitListQuery $list,
|
||||||
|
private MahlzeitCheckOffUseCase $checkOff,
|
||||||
|
private MahlzeitUncheckUseCase $uncheck,
|
||||||
|
private MahlzeitCreateUseCase $create,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @param non-empty-string $name – für die Route-Signatur, hier nur Doku */
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$status = null;
|
||||||
|
$filter = (string) $request->query->get('status', '');
|
||||||
|
if ($filter !== '') {
|
||||||
|
$status = MahlzeitStatus::tryFrom($filter);
|
||||||
|
if ($status === null) {
|
||||||
|
throw new BadRequestHttpException('status muss "geplant" oder "gegessen" sein.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = $this->list->execute(new MahlzeitListRequest(status: $status))->items;
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'mahlzeiten' => \array_map(self::serialize(...), $items),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkoff(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
return new JsonResponse(self::serialize($this->checkOff->execute($id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function uncheck(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
return new JsonResponse(self::serialize($this->uncheck->execute($id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = json_decode((string) $request->getContent(), true);
|
||||||
|
if (!\is_array($payload)) {
|
||||||
|
throw new BadRequestHttpException('JSON-Body erwartet, z. B. {"name": "Bockbrot"}');
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = \trim((string) ($payload['name'] ?? ''));
|
||||||
|
if ($name === '') {
|
||||||
|
throw new BadRequestHttpException('name ist ein Pflichtfeld.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$notiz = \trim((string) ($payload['notiz'] ?? ''));
|
||||||
|
$wochentage = Wochentag::ausWerten((array) ($payload['wochentage'] ?? []));
|
||||||
|
|
||||||
|
return new JsonResponse(self::serialize($this->create->execute(new MahlzeitCreateRequest(
|
||||||
|
name: $name,
|
||||||
|
notiz: $notiz,
|
||||||
|
wochentage: $wochentage,
|
||||||
|
))->mahlzeit), 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialisiert ein Read-Modell in das API-Format. */
|
||||||
|
public static function serialize(object $m): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $m->id,
|
||||||
|
'name' => $m->name,
|
||||||
|
'notiz' => $m->notiz,
|
||||||
|
'wochentage' => \array_map(static fn (Wochentag $t): string => $t->value, $m->wochentage),
|
||||||
|
'status' => $m->status->value,
|
||||||
|
'sort_order' => $m->sortOrder,
|
||||||
|
'gegessen_am' => $m->gegessenAm?->format(\DateTimeInterface::RFC3339),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,7 +65,8 @@ final class OidcAuthController
|
|||||||
try {
|
try {
|
||||||
$tokens = $this->oidc->exchangeCode($code);
|
$tokens = $this->oidc->exchangeCode($code);
|
||||||
$claims = $this->oidc->verifyIdToken($tokens['id_token']);
|
$claims = $this->oidc->verifyIdToken($tokens['id_token']);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable $e) {
|
||||||
|
error_log('OIDC-Callback fehlgeschlagen: ' . $e->getMessage());
|
||||||
return new RedirectResponse($this->router->generate('login'));
|
return new RedirectResponse($this->router->generate('login'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ final class OidcClient
|
|||||||
'client_id' => $this->clientId,
|
'client_id' => $this->clientId,
|
||||||
'redirect_uri' => $this->redirectUri,
|
'redirect_uri' => $this->redirectUri,
|
||||||
'scope' => implode(' ', $this->scopes),
|
'scope' => implode(' ', $this->scopes),
|
||||||
'prompt' => 'login',
|
|
||||||
];
|
];
|
||||||
if ($state !== null && $state !== '') {
|
if ($state !== null && $state !== '') {
|
||||||
$q['state'] = $state;
|
$q['state'] = $state;
|
||||||
@@ -71,7 +70,8 @@ final class OidcClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
$resp = $this->httpClient->request('POST', $this->issuer . '/protocol/openid-connect/token', [
|
$resp = $this->httpClient->request('POST', $this->issuer . '/protocol/openid-connect/token', [
|
||||||
'json' => $body,
|
'body' => http_build_query($body),
|
||||||
|
'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
|
||||||
'timeout' => 10,
|
'timeout' => 10,
|
||||||
]);
|
]);
|
||||||
$sc = $resp->getStatusCode();
|
$sc = $resp->getStatusCode();
|
||||||
@@ -198,10 +198,9 @@ final class OidcClient
|
|||||||
|
|
||||||
$mod = $this->derInteger($n);
|
$mod = $this->derInteger($n);
|
||||||
$exp = $this->derInteger($e);
|
$exp = $this->derInteger($e);
|
||||||
$rsakey =
|
$modTLV = "\x02" . $this->derLen(strlen($mod)) . $mod;
|
||||||
"\x30" . $this->derLen(strlen($mod) + strlen($exp))
|
$expTLV = "\x02" . $this->derLen(strlen($exp)) . $exp;
|
||||||
. "\x02" . $this->derLen(strlen($mod)) . $mod
|
$rsakey = "\x30" . $this->derLen(strlen($modTLV) + strlen($expTLV)) . $modTLV . $expTLV;
|
||||||
. "\x02" . $this->derLen(strlen($exp)) . $exp;
|
|
||||||
|
|
||||||
// rsaEncryption OID 1.2.840.113549.1.1.1 → DER: 2A 86 48 86 F7 0D 01 01 01
|
// rsaEncryption OID 1.2.840.113549.1.1.1 → DER: 2A 86 48 86 F7 0D 01 01 01
|
||||||
$algId = "\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01";
|
$algId = "\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01";
|
||||||
|
|||||||
@@ -78,9 +78,18 @@ final class MahlzeitController extends AbstractController
|
|||||||
return $this->render('mahlzeiten/tage.html.twig', [
|
return $this->render('mahlzeiten/tage.html.twig', [
|
||||||
'tage' => $this->gruppierenNachTag($mahlzeiten),
|
'tage' => $this->gruppierenNachTag($mahlzeiten),
|
||||||
'total' => \count($mahlzeiten),
|
'total' => \count($mahlzeiten),
|
||||||
|
'heute' => $this->heutigerTag(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Aktueller Wochentag (Europe/Berlin) als `Wochentag`-Name (z. B. "Di"). */
|
||||||
|
private function heutigerTag(): string
|
||||||
|
{
|
||||||
|
$iso = (int) (new \DateTimeImmutable('now', new \DateTimeZone('Europe/Berlin')))->format('N');
|
||||||
|
|
||||||
|
return Wochentag::cases()[$iso - 1]->name;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /mahlzeiten/reorder — neue Reihenfolge der Planungsliste (Drag & Drop)
|
* POST /mahlzeiten/reorder — neue Reihenfolge der Planungsliste (Drag & Drop)
|
||||||
* persistieren. Erwartet JSON `{"ids": [3, 1, 2]}` oder das Formularfeld
|
* persistieren. Erwartet JSON `{"ids": [3, 1, 2]}` oder das Formularfeld
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ final class ExceptionSubscriber implements EventSubscriberInterface
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($e instanceof HttpExceptionInterface && $api) {
|
if ($e instanceof HttpExceptionInterface) {
|
||||||
$event->setResponse(
|
$event->setResponse(
|
||||||
$this->render($request, $e->getStatusCode(), $this->shortName(get_class($e)), $e->getMessage(), $wantsJson)
|
$this->render($request, $e->getStatusCode(), $this->shortName(get_class($e)), $e->getMessage(), $wantsJson)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,11 +3,15 @@
|
|||||||
{% block title %}Start · svc-essensplanung{% endblock %}
|
{% block title %}Start · svc-essensplanung{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<h1>{{ name }}</h1>
|
<h1>Willkommen, {{ name }}!</h1>
|
||||||
|
<p class="lead" style="margin-top: .4rem;">Deine Woche auf einen Blick – was steht an und was schmeckt mal wieder?</p>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<p>Willkommen zur Essensplanung! (M0-Skelett)</p>
|
<div class="actions">
|
||||||
<p><strong>E-Mail:</strong> {{ email }}</p>
|
<a class="btn" href="{{ path('mahlzeiten_index') }}">Zur Planungsliste</a>
|
||||||
<p><strong>Redirect-Pfad:</strong> <code>{{ redirect }}</code></p>
|
<a class="btn btn-link" href="{{ path('mahlzeiten_inspiration') }}">Inspiration</a>
|
||||||
<p>M1: CRUD-Slice (Dish-Create/Update/Delete/List) folgt.</p>
|
<a class="btn btn-link" href="{{ path('mahlzeiten_archiv') }}">Archiv</a>
|
||||||
|
</div>
|
||||||
|
<p class="muted" style="margin: 1.1rem 0 0;">Angemeldet als {{ email }}</p>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+380
-52
@@ -2,102 +2,423 @@
|
|||||||
<html lang="de" dir="ltr">
|
<html lang="de" dir="ltr">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<title>{% block title %}svc-essensplanung{% endblock %}</title>
|
<title>{% block title %}svc-essensplanung{% endblock %}</title>
|
||||||
|
<link rel="manifest" href="{{ asset('manifest.webmanifest') }}">
|
||||||
|
<meta name="theme-color" content="#3f6d4e">
|
||||||
|
<link rel="icon" href="{{ asset('icons/icon-192.png') }}" type="image/png">
|
||||||
|
<link rel="apple-touch-icon" href="{{ asset('icons/apple-touch-icon.png') }}">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Essen">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg: #f4f4f4;
|
--bg: #f4eee1;
|
||||||
--fg: #112233;
|
--bg-soft: #ece4d2;
|
||||||
--card: #fff;
|
--card: #fffdf8;
|
||||||
--accent: #0a7bff;
|
--fg: #2e2a22;
|
||||||
--muted: #667;
|
--muted: #877d69;
|
||||||
--warn: #b45309;
|
--border: #e6ddc9;
|
||||||
--danger: #b91c1c;
|
--border-strong: #d8cdb4;
|
||||||
|
--accent: #3f6d4e;
|
||||||
|
--accent-strong: #315a3e;
|
||||||
|
--accent-soft: #e2ecdf;
|
||||||
|
--danger: #a8442a;
|
||||||
|
--danger-strong: #8f3820;
|
||||||
|
--danger-soft: #f6e6de;
|
||||||
|
--ring: rgba(63, 109, 78, .22);
|
||||||
|
--shadow: 0 1px 2px rgba(76, 60, 32, .05), 0 5px 18px rgba(76, 60, 32, .08);
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
html { -webkit-text-size-adjust: 100%; }
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
font: 16px/1.55 ui-rounded, "SF Pro Rounded", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
background: var(--bg);
|
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
|
background: linear-gradient(180deg, #f7f2e7 0%, var(--bg) 320px) fixed;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
a { color: var(--accent); }
|
||||||
|
|
||||||
|
/* ---------- Header ---------- */
|
||||||
header {
|
header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.75rem 1rem;
|
gap: .5rem 1rem;
|
||||||
background: var(--card);
|
flex-wrap: wrap;
|
||||||
box-shadow: 0 1px 0 #ddd;
|
padding: calc(.65rem + env(safe-area-inset-top, 0px)) 1.25rem .65rem;
|
||||||
|
background: rgba(255, 253, 248, .94);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
header a {
|
.brand {
|
||||||
color: var(--accent);
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
color: var(--fg);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
margin-left: 1rem;
|
font-weight: 700;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
letter-spacing: -.01em;
|
||||||
}
|
}
|
||||||
main {
|
.brand svg { color: var(--accent); flex: none; }
|
||||||
max-width: 40rem;
|
nav { display: flex; align-items: center; gap: .15rem; flex-wrap: wrap; }
|
||||||
margin: 2rem auto;
|
nav a {
|
||||||
padding: 0 1rem;
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 550;
|
||||||
|
font-size: .95rem;
|
||||||
|
padding: .35rem .8rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
}
|
||||||
|
nav a:hover { color: var(--fg); background: var(--bg-soft); }
|
||||||
|
nav a.nav-logout { margin-left: .5rem; color: var(--muted); font-size: .88rem; }
|
||||||
|
nav a.nav-logout:hover { color: var(--danger); background: var(--danger-soft); }
|
||||||
|
|
||||||
|
/* ---------- Layout ---------- */
|
||||||
|
main { max-width: 44rem; margin: 1.75rem auto 3rem; padding: 0 1rem; }
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.55rem;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: -.02em;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 1.12rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -.01em;
|
||||||
|
margin: 1.6rem 0 .55rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
}
|
||||||
|
.subtitle { color: var(--muted); font-size: .95rem; margin-top: .25rem; }
|
||||||
|
.page-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1.1rem;
|
||||||
}
|
}
|
||||||
.card {
|
.card {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
padding: 1.25rem;
|
padding: 1.4rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 1rem;
|
||||||
box-shadow: 0 1px 2px rgba(0,0,0,.06);
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
.muted { color: var(--muted); font-size: 0.9rem; }
|
.muted { color: var(--muted); font-size: .9rem; }
|
||||||
|
.lead { font-size: 1.02rem; color: var(--fg); margin: .2rem 0 1rem; }
|
||||||
.row-between { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; }
|
.row-between { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; }
|
||||||
|
.empty { text-align: center; color: var(--muted); margin: 1.5rem 0 .5rem; padding: 1.25rem 1rem; background: var(--bg-soft); border-radius: .8rem; }
|
||||||
|
|
||||||
|
/* ---------- Buttons ---------- */
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-block;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: .45rem;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 0.4rem 0.8rem;
|
padding: .55rem 1.15rem;
|
||||||
border-radius: 0.35rem;
|
border-radius: 999px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border: none;
|
border: 1px solid transparent;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: .95rem;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 1px 2px rgba(49, 90, 62, .28);
|
||||||
|
transition: background .15s, box-shadow .15s, transform .06s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn:hover { background: var(--accent-strong); box-shadow: 0 2px 6px rgba(49, 90, 62, .3); }
|
||||||
|
.btn:active { transform: translateY(1px); }
|
||||||
|
.btn svg { width: 1.05em; height: 1.05em; flex: none; }
|
||||||
|
.btn-link {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
box-shadow: none;
|
||||||
|
font-weight: 550;
|
||||||
|
}
|
||||||
|
.btn-link:hover { color: var(--fg); background: var(--bg-soft); }
|
||||||
|
.btn-danger {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--danger);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.btn-danger:hover { background: var(--danger-soft); color: var(--danger-strong); }
|
||||||
|
.btn:focus-visible, nav a:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px var(--ring);
|
||||||
|
}
|
||||||
|
.actions { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
|
||||||
|
.action-haupt { display: flex; }
|
||||||
|
.action-neben {
|
||||||
|
display: flex;
|
||||||
|
gap: .4rem;
|
||||||
|
align-items: center;
|
||||||
|
padding-left: .65rem;
|
||||||
|
margin-left: .1rem;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.action-neben .btn { font-size: .87rem; padding: .5rem .75rem; }
|
||||||
|
.action-neben form { display: inline-flex; }
|
||||||
|
|
||||||
|
/* ---------- Liste ---------- */
|
||||||
|
.list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .6rem; }
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: .75rem 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: .9rem 1.05rem;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: .85rem;
|
||||||
|
transition: box-shadow .15s, border-color .15s, background .15s;
|
||||||
|
}
|
||||||
|
.item:hover { box-shadow: var(--shadow); border-color: var(--border-strong); }
|
||||||
|
.item-main { display: flex; align-items: flex-start; gap: .15rem; min-width: 0; }
|
||||||
|
.item-name { min-width: 0; }
|
||||||
|
.item-name strong, .item-name .name {
|
||||||
|
font-size: 1.07rem;
|
||||||
|
font-weight: 680;
|
||||||
|
letter-spacing: -.01em;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.notiz { color: var(--muted); font-size: .9rem; margin-top: .1rem; }
|
||||||
|
.drag-handle {
|
||||||
|
flex: none;
|
||||||
|
align-self: center;
|
||||||
|
cursor: grab;
|
||||||
|
color: #b6ab90;
|
||||||
|
margin-right: .6rem;
|
||||||
|
padding: .45rem .3rem;
|
||||||
|
border-radius: .45rem;
|
||||||
|
line-height: 0;
|
||||||
|
user-select: none;
|
||||||
|
transition: color .15s, background .15s;
|
||||||
|
}
|
||||||
|
.drag-handle:hover { color: var(--muted); background: var(--bg-soft); }
|
||||||
|
.updown { display: none; }
|
||||||
|
.updown button {
|
||||||
|
flex: none;
|
||||||
|
width: 2.2rem;
|
||||||
|
height: 2.2rem;
|
||||||
|
margin: .1rem 0;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: .55rem;
|
||||||
|
line-height: 0;
|
||||||
|
color: #b6ab90;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border: 1px solid var(--border);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.btn:hover { opacity: 0.92; }
|
.updown button:active { color: var(--accent-strong); background: var(--accent-soft); border-color: #cfe0cd; }
|
||||||
.btn-danger { background: var(--danger); }
|
.updown button:disabled { opacity: .35; pointer-events: none; }
|
||||||
.btn-link { background: transparent; color: var(--muted); }
|
.item-drag { opacity: .35; }
|
||||||
.actions { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
.tags { display: flex; flex-wrap: wrap; gap: .35rem; margin-top: .5rem; }
|
||||||
.list { list-style: none; margin: 1rem 0 0; padding: 0; display: flex; flex-direction: column; gap: 0.75rem; }
|
.tag {
|
||||||
.item { display: flex; justify-content: space-between; gap: 1rem; padding: 0.75rem; border: 1px solid #eee; border-radius: 0.4rem; align-items: center; flex-wrap: wrap; }
|
background: var(--accent-soft);
|
||||||
.drag-handle { cursor: grab; color: var(--muted); font-weight: 600; margin-right: 0.55rem; user-select: none; }
|
color: var(--accent-strong);
|
||||||
.item-drag { opacity: 0.4; }
|
padding: .13rem .6rem;
|
||||||
.tags { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.4rem; }
|
border-radius: 999px;
|
||||||
.tag { background: #eef4ff; color: var(--accent); padding: 0.1rem 0.5rem; border-radius: 999px; font-size: 0.8rem; }
|
font-size: .78rem;
|
||||||
.status { font-size: 0.8rem; color: var(--muted); }
|
font-weight: 650;
|
||||||
.status-gegessen { color: #15573b; }
|
letter-spacing: .01em;
|
||||||
.field { margin-bottom: 1rem; }
|
}
|
||||||
.field > label { display: block; font-weight: 600; margin-bottom: 0.3rem; }
|
.tag-mo { background: #e3efe2; color: #2f5d39; }
|
||||||
|
.tag-di { background: #ddefed; color: #2a5f56; }
|
||||||
|
.tag-mi { background: #e1ebf6; color: #2f5578; }
|
||||||
|
.tag-do { background: #ebe5f4; color: #54437a; }
|
||||||
|
.tag-fr { background: #fcedda; color: #94621c; }
|
||||||
|
.tag-sa { background: #fae6e9; color: #93404d; }
|
||||||
|
.tag-so { background: #f9f2d4; color: #7d6414; }
|
||||||
|
.status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
margin-top: .55rem;
|
||||||
|
padding: .16rem .65rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: .79rem;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--bg-soft);
|
||||||
|
}
|
||||||
|
.status::before {
|
||||||
|
content: "";
|
||||||
|
width: .45rem;
|
||||||
|
height: .45rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #c9bfa6;
|
||||||
|
}
|
||||||
|
.status-gegessen { color: var(--accent-strong); background: var(--accent-soft); }
|
||||||
|
.status-gegessen::before { background: var(--accent); }
|
||||||
|
.item-gegessen { background: #faf6ec; }
|
||||||
|
.item-gegessen .item-name strong, .item-gegessen .item-name .name {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: line-through;
|
||||||
|
text-decoration-color: #b6ab90;
|
||||||
|
text-decoration-thickness: 1.5px;
|
||||||
|
}
|
||||||
|
.item .actions { margin-left: auto; }
|
||||||
|
|
||||||
|
/* ---------- Formular ---------- */
|
||||||
|
.field { margin-bottom: 1.15rem; }
|
||||||
|
.field > label { display: block; font-weight: 650; margin-bottom: .35rem; font-size: .97rem; }
|
||||||
.field input[type="text"], .field textarea {
|
.field input[type="text"], .field textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.5rem;
|
padding: .6rem .85rem;
|
||||||
border: 1px solid #ccc;
|
border: 1px solid var(--border-strong);
|
||||||
border-radius: 0.35rem;
|
border-radius: .6rem;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--fg);
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
}
|
}
|
||||||
|
.field input[type="text"]:focus, .field textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--ring);
|
||||||
|
}
|
||||||
|
.field textarea { resize: vertical; min-height: 4.2rem; }
|
||||||
.req { color: var(--danger); }
|
.req { color: var(--danger); }
|
||||||
.checkboxes { display: flex; flex-wrap: wrap; gap: 0.5rem 0.9rem; margin-top: 0.3rem; }
|
.checkboxes { display: flex; flex-wrap: wrap; gap: .55rem 1rem; margin-top: .4rem; }
|
||||||
.checkbox { display: inline-flex; align-items: center; gap: 0.3rem; font-weight: 400; cursor: pointer; }
|
.checkbox {
|
||||||
.flash { padding: 0.6rem 0.9rem; border-radius: 0.4rem; margin-bottom: 1rem; }
|
display: inline-flex;
|
||||||
.flash-notice { background: #e6f6ec; color: #15573b; }
|
align-items: center;
|
||||||
.flash-error { background: #fdecec; color: #8a1f1f; }
|
gap: .45rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: .3rem .7rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff;
|
||||||
|
transition: border-color .15s, background .15s;
|
||||||
|
}
|
||||||
|
.checkbox:hover { border-color: var(--border-strong); }
|
||||||
|
.checkbox input { accent-color: var(--accent); width: 1rem; height: 1rem; margin: 0; }
|
||||||
|
.checkbox:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-strong); }
|
||||||
|
|
||||||
|
/* ---------- Flash ---------- */
|
||||||
|
.flash {
|
||||||
|
padding: .7rem 1rem;
|
||||||
|
border-radius: .75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-weight: 550;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.flash-notice { background: var(--accent-soft); color: var(--accent-strong); border-color: #cfe0cd; }
|
||||||
|
.flash-error { background: var(--danger-soft); color: var(--danger-strong); border-color: #eccfc4; }
|
||||||
|
|
||||||
|
/* ---------- Inspiration ---------- */
|
||||||
|
.vorschlag-meta { color: var(--muted); font-size: .85rem; margin-top: .3rem; }
|
||||||
|
.vorschlag-meta b { color: var(--accent-strong); font-weight: 650; }
|
||||||
|
|
||||||
|
/* ---------- Tagesansicht ---------- */
|
||||||
|
h2[data-count]::after {
|
||||||
|
content: attr(data-count);
|
||||||
|
font-size: .76rem;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--bg-soft);
|
||||||
|
padding: .08rem .55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
h2[data-heute] { color: var(--accent-strong); }
|
||||||
|
h2[data-heute]::after {
|
||||||
|
content: attr(data-count) " · heute";
|
||||||
|
color: var(--accent-strong);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
h2[data-heute] + .list .item { border-color: #cfe0cd; }
|
||||||
|
|
||||||
|
/* ---------- FAB ---------- */
|
||||||
|
.fab {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 20;
|
||||||
|
right: calc(1rem + env(safe-area-inset-right, 0px));
|
||||||
|
bottom: calc(1.25rem + env(safe-area-inset-bottom, 0px));
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 14px rgba(49, 90, 62, .4);
|
||||||
|
}
|
||||||
|
.fab:active { background: var(--accent-strong); transform: translateY(1px); }
|
||||||
|
.fab svg { width: 1.5rem; height: 1.5rem; }
|
||||||
|
|
||||||
|
/* ---------- Mobil ---------- */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
main { margin-top: 1.1rem; padding: 0 .8rem; padding-bottom: 6rem; }
|
||||||
|
.card { padding: 1.1rem; }
|
||||||
|
.btn { min-height: 2.75rem; }
|
||||||
|
.fab { display: flex; }
|
||||||
|
.drag-handle { display: none; }
|
||||||
|
.updown { display: inline-flex; flex-direction: column; align-self: center; margin-right: .6rem; }
|
||||||
|
.item { flex-direction: column; align-items: stretch; }
|
||||||
|
.item .actions { margin-left: 0; flex-direction: column; align-items: stretch; }
|
||||||
|
.item .action-haupt { width: 100%; }
|
||||||
|
.item .action-haupt .btn { width: 100%; }
|
||||||
|
.item .action-neben {
|
||||||
|
width: 100%;
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin-left: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-top: .5rem;
|
||||||
|
margin-top: .15rem;
|
||||||
|
}
|
||||||
|
.item .action-neben .btn,
|
||||||
|
.item .action-neben form { flex: 1; }
|
||||||
|
.item .action-neben .btn { width: auto; }
|
||||||
|
.item .action-neben form .btn { width: 100%; }
|
||||||
|
.page-head { flex-direction: column; align-items: stretch; }
|
||||||
|
.page-head .actions { width: 100%; }
|
||||||
|
.page-head .actions .btn { flex: 1; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% block stylesheets %}{% endblock %}
|
{% block stylesheets %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<a href="{{ path('home') }}" style="color: inherit; font-weight: 600;">essen.ljh31.de</a>
|
<a class="brand" href="{{ path('home') }}">
|
||||||
|
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" aria-hidden="true">
|
||||||
|
<path d="M4 12h16a8 8 0 0 1-16 0Z"/>
|
||||||
|
<path d="M9.2 8.2c-.6-1.4.5-2-.1-3.6"/>
|
||||||
|
<path d="M13.8 8.2c-.6-1.4.5-2-.1-3.6"/>
|
||||||
|
</svg>
|
||||||
|
Essensplanung
|
||||||
|
</a>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="{{ path('mahlzeiten_index') }}">Mahlzeiten</a>
|
<a href="{{ path('mahlzeiten_index') }}">Mahlzeiten</a>
|
||||||
|
<a href="{{ path('mahlzeiten_tage') }}">Tage</a>
|
||||||
<a href="{{ path('mahlzeiten_inspiration') }}">Inspiration</a>
|
<a href="{{ path('mahlzeiten_inspiration') }}">Inspiration</a>
|
||||||
<a href="{{ path('mahlzeiten_archiv') }}">Archiv</a>
|
<a href="{{ path('mahlzeiten_archiv') }}">Archiv</a>
|
||||||
<a href="{{ path('logout') }}">Abmelden</a>
|
<a class="nav-logout" href="{{ path('logout') }}">Abmelden</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
{% block fab %}
|
||||||
|
<a class="fab" href="{{ path('mahlzeiten_neu') }}" aria-label="Neue Mahlzeit">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true">
|
||||||
|
<path d="M12 5v14M5 12h14"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{% endblock %}
|
||||||
<main>
|
<main>
|
||||||
{% for label, messages in app.flashes %}
|
{% for label, messages in app.flashes %}
|
||||||
{% for message in messages %}
|
{% for message in messages %}
|
||||||
@@ -108,5 +429,12 @@
|
|||||||
</main>
|
</main>
|
||||||
<script src="{{ asset('js/htmx.min.js') }}"></script>
|
<script src="{{ asset('js/htmx.min.js') }}"></script>
|
||||||
{% block javascripts %}{% endblock %}
|
{% block javascripts %}{% endblock %}
|
||||||
|
<script>
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', function () {
|
||||||
|
navigator.serviceWorker.register('{{ asset('sw.js') }}').catch(function () {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -3,49 +3,98 @@
|
|||||||
eingebunden und als htmx-Fragment von den CheckOff/Uncheck-Endpunkten
|
eingebunden und als htmx-Fragment von den CheckOff/Uncheck-Endpunkten
|
||||||
zurückgeliefert (htmx ersetzt per hx-swap="outerHTML" genau diese <li>).
|
zurückgeliefert (htmx ersetzt per hx-swap="outerHTML" genau diese <li>).
|
||||||
|
|
||||||
@param MahlzeitResponse m
|
@param MahlzeitResponse m
|
||||||
@param bool sortierbar optional: zeigt Drag-Handle + data-id (Planungsliste)
|
@param bool sortierbar optional: zeigt Drag-Handle + Auf/Ab-Buttons + data-id (Planungsliste)
|
||||||
#}
|
#}
|
||||||
<li class="item" id="mahlzeit-{{ m.id }}"
|
<li class="item {{ m.status.value == 'gegessen' ? 'item-gegessen' : '' }}" id="mahlzeit-{{ m.id }}"
|
||||||
{% if sortierbar is defined and sortierbar %}data-id="{{ m.id }}"{% endif %}>
|
{% if sortierbar is defined and sortierbar %}data-id="{{ m.id }}"{% endif %}>
|
||||||
<div>
|
<div class="item-main">
|
||||||
{% if sortierbar is defined and sortierbar %}
|
{% if sortierbar is defined and sortierbar %}
|
||||||
<span class="drag-handle" title="Ziehen zum Sortieren">⋮</span>
|
<span class="drag-handle" title="Ziehen zum Sortieren" aria-hidden="true">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<circle cx="5.5" cy="3" r="1.4"/><circle cx="10.5" cy="3" r="1.4"/>
|
||||||
|
<circle cx="5.5" cy="8" r="1.4"/><circle cx="10.5" cy="8" r="1.4"/>
|
||||||
|
<circle cx="5.5" cy="13" r="1.4"/><circle cx="10.5" cy="13" r="1.4"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="updown">
|
||||||
|
<button type="button" class="updown-btn" data-dir="-1" aria-label="Nach oben verschieben">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="m6 15 6-6 6 6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="updown-btn" data-dir="1" aria-label="Nach unten verschieben">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="m6 9 6 6 6-6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<strong>{{ m.name }}</strong>
|
<div class="item-name">
|
||||||
{% if m.notiz != '' %}
|
<strong>{{ m.name }}</strong>
|
||||||
<div class="muted">{{ m.notiz }}</div>
|
{% if m.notiz != '' %}
|
||||||
{% endif %}
|
<div class="notiz">{{ m.notiz }}</div>
|
||||||
{% if m.wochentage|length > 0 %}
|
|
||||||
<div class="tags">
|
|
||||||
{% for tag in m.wochentage %}
|
|
||||||
<span class="tag">{{ tag.name }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<span class="status {{ m.status.value == 'gegessen' ? 'status-gegessen' : '' }}">
|
|
||||||
{{ m.status.value == 'gegessen' ? 'gegessen' : 'geplant' }}
|
|
||||||
{% if m.status.value == 'gegessen' and m.gegessenAm %}
|
|
||||||
· {{ m.gegessenAm|date('d.m.Y H:i') }}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
{% if m.wochentage|length > 0 %}
|
||||||
|
<div class="tags">
|
||||||
|
{% for tag in m.wochentage %}
|
||||||
|
<span class="tag tag-{{ tag.value }}">{{ tag.name }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<span class="status {{ m.status.value == 'gegessen' ? 'status-gegessen' : '' }}">
|
||||||
|
{{ m.status.value == 'gegessen' ? 'gegessen' : 'geplant' }}
|
||||||
|
{% if m.status.value == 'gegessen' and m.gegessenAm %}
|
||||||
|
· {{ m.gegessenAm|date('d.m.Y H:i') }}
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
{% if m.status.value == 'geplant' %}
|
<div class="action-haupt">
|
||||||
<button type="button" class="btn"
|
{% if m.status.value == 'geplant' %}
|
||||||
hx-post="{{ path('mahlzeiten_checkoff', { id: m.id }) }}"
|
<button type="button" class="btn"
|
||||||
hx-target="#mahlzeit-{{ m.id }}"
|
hx-post="{{ path('mahlzeiten_checkoff', { id: m.id }) }}"
|
||||||
hx-swap="outerHTML">Abhaken</button>
|
hx-target="#mahlzeit-{{ m.id }}"
|
||||||
{% else %}
|
hx-swap="outerHTML">
|
||||||
<button type="button" class="btn btn-link"
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
hx-post="{{ path('mahlzeiten_uncheck', { id: m.id }) }}"
|
<path d="M4.5 12.5 9.5 17.5 19.5 6.5"/>
|
||||||
hx-target="#mahlzeit-{{ m.id }}"
|
</svg>
|
||||||
hx-swap="outerHTML">Aufheben</button>
|
Abhaken
|
||||||
{% endif %}
|
</button>
|
||||||
<a class="btn btn-link" href="{{ path('mahlzeiten_bearbeiten', { id: m.id }) }}">Bearbeiten</a>
|
{% else %}
|
||||||
<form method="post" action="{{ path('mahlzeiten_loeschen', { id: m.id }) }}"
|
<button type="button" class="btn btn-link"
|
||||||
onsubmit="return confirm('Mahlzeit „{{ m.name }}“ wirklich löschen?');">
|
hx-post="{{ path('mahlzeiten_uncheck', { id: m.id }) }}"
|
||||||
<button type="submit" class="btn btn-danger">Löschen</button>
|
hx-target="#mahlzeit-{{ m.id }}"
|
||||||
</form>
|
hx-swap="outerHTML">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M9 14 4 9l5-5"/>
|
||||||
|
<path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/>
|
||||||
|
</svg>
|
||||||
|
Aufheben
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="action-neben">
|
||||||
|
<a class="btn btn-link" href="{{ path('mahlzeiten_bearbeiten', { id: m.id }) }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M12 20h9"/>
|
||||||
|
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/>
|
||||||
|
</svg>
|
||||||
|
Bearbeiten
|
||||||
|
</a>
|
||||||
|
<form method="post" action="{{ path('mahlzeiten_loeschen', { id: m.id }) }}"
|
||||||
|
onsubmit="return confirm('Mahlzeit „{{ m.name }}“ wirklich löschen?');">
|
||||||
|
<button type="submit" class="btn btn-danger">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M3 6h18"/>
|
||||||
|
<path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/>
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/>
|
||||||
|
<path d="M10 11v6M14 11v6"/>
|
||||||
|
</svg>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -4,18 +4,19 @@
|
|||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="row-between">
|
<div class="page-head">
|
||||||
<h1 style="margin: 0;">Archiv</h1>
|
<div>
|
||||||
|
<h1>Archiv</h1>
|
||||||
|
<div class="subtitle">Alle abgehakten (gegessenen) Mahlzeiten. „Aufheben" stellt sie wieder auf „geplant" um.</div>
|
||||||
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a class="btn btn-link" href="{{ path('mahlzeiten_index') }}">← Mahlzeiten</a>
|
<a class="btn btn-link" href="{{ path('mahlzeiten_index') }}">← Mahlzeiten</a>
|
||||||
<a class="btn" href="{{ path('mahlzeiten_neu') }}">+ Neue Mahlzeit</a>
|
<a class="btn" href="{{ path('mahlzeiten_neu') }}">+ Neue Mahlzeit</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="muted" style="margin-top: 0.75rem;">Alle abgehakten (gegessenen) Mahlzeiten. „Aufheben" stellt sie wieder auf „geplant" um.</p>
|
|
||||||
|
|
||||||
{% if mahlzeiten|length == 0 %}
|
{% if mahlzeiten|length == 0 %}
|
||||||
<p class="muted">Noch nichts gegessen – das Archiv ist leer.</p>
|
<p class="empty">Noch nichts gegessen – das Archiv ist leer.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
{% for m in mahlzeiten %}
|
{% for m in mahlzeiten %}
|
||||||
|
|||||||
@@ -4,9 +4,16 @@
|
|||||||
|
|
||||||
{% block title %}{{ titel }} · svc-essensplanung{% endblock %}
|
{% block title %}{{ titel }} · svc-essensplanung{% endblock %}
|
||||||
|
|
||||||
|
{% block fab %}{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1 style="margin-top: 0;">{{ titel }}</h1>
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>{{ titel }}</h1>
|
||||||
|
<div class="subtitle">{{ mahlzeit ? 'Ändere Name, Notiz oder Wochentage.' : 'Ein Gericht, das wieder auf den Plan soll.' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if fehler %}
|
{% if fehler %}
|
||||||
<div class="flash flash-error">{{ fehler }}</div>
|
<div class="flash flash-error">{{ fehler }}</div>
|
||||||
@@ -17,12 +24,14 @@
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="name">Name <span class="req" title="Pflichtfeld">*</span></label>
|
<label for="name">Name <span class="req" title="Pflichtfeld">*</span></label>
|
||||||
<input type="text" id="name" name="name" value="{{ werte.name }}" required>
|
<input type="text" id="name" name="name" value="{{ werte.name }}" required
|
||||||
|
placeholder="z. B. Spaghetti Bolognese" autofocus
|
||||||
|
autocomplete="off" enterkeyhint="next">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="notiz">Notiz</label>
|
<label for="notiz">Notiz</label>
|
||||||
<textarea id="notiz" name="notiz" rows="3" placeholder="z. B. mit Basilikum, scharf …">{{ werte.notiz }}</textarea>
|
<textarea id="notiz" name="notiz" rows="3" placeholder="z. B. mit Basilikum, scharf …" enterkeyhint="done">{{ werte.notiz }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -39,7 +48,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions" style="margin-top: 1.4rem;">
|
||||||
<button type="submit" class="btn">{{ mahlzeit ? 'Speichern' : 'Anlegen' }}</button>
|
<button type="submit" class="btn">{{ mahlzeit ? 'Speichern' : 'Anlegen' }}</button>
|
||||||
<a class="btn btn-link" href="{{ path('mahlzeiten_index') }}">Abbrechen</a>
|
<a class="btn btn-link" href="{{ path('mahlzeiten_index') }}">Abbrechen</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,21 +4,23 @@
|
|||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="row-between">
|
<div class="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h1 style="margin: 0;">Mahlzeiten</h1>
|
<h1>Mahlzeiten</h1>
|
||||||
<div class="muted" style="margin-top: 0.2rem;">Planungsliste – per Drag & Drop sortieren</div>
|
<div class="subtitle">Planungsliste – per Drag & Drop sortieren</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a class="btn" href="{{ path('mahlzeiten_neu') }}">+ Neue Mahlzeit</a>
|
<a class="btn" href="{{ path('mahlzeiten_neu') }}">
|
||||||
<a class="btn btn-link" href="{{ path('mahlzeiten_inspiration') }}">Inspiration</a>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true">
|
||||||
<a class="btn btn-link" href="{{ path('mahlzeiten_tage') }}">Tagesansicht</a>
|
<path d="M12 5v14M5 12h14"/>
|
||||||
<a class="btn btn-link" href="{{ path('mahlzeiten_archiv') }}">Archiv</a>
|
</svg>
|
||||||
|
Neue Mahlzeit
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if total == 0 %}
|
{% if total == 0 %}
|
||||||
<p class="muted" style="margin-top: 1rem;">Noch keine Mahlzeiten in der Planung.</p>
|
<p class="empty">Noch keine Mahlzeiten in der Planung.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<ul class="list sortierbar">
|
<ul class="list sortierbar">
|
||||||
{% for m in mahlzeiten %}
|
{% for m in mahlzeiten %}
|
||||||
@@ -34,26 +36,46 @@
|
|||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const liste = document.querySelector('ul.sortierbar');
|
const liste = document.querySelector('ul.sortierbar');
|
||||||
if (!liste || typeof Sortable === 'undefined') { return; }
|
if (!liste) { return; }
|
||||||
const url = '{{ path("mahlzeiten_reorder") }}';
|
const url = '{{ path("mahlzeiten_reorder") }}';
|
||||||
Sortable.create(liste, {
|
|
||||||
handle: '.drag-handle',
|
function speichern() {
|
||||||
animation: 150,
|
const ids = Array.prototype.map.call(
|
||||||
ghostClass: 'item-drag',
|
liste.querySelectorAll('li[data-id]'),
|
||||||
onEnd: function () {
|
function (li) { return Number(li.getAttribute('data-id')); }
|
||||||
const ids = Array.prototype.map.call(
|
);
|
||||||
liste.querySelectorAll('li[data-id]'),
|
fetch(url, {
|
||||||
function (li) { return Number(li.getAttribute('data-id')); }
|
method: 'POST',
|
||||||
);
|
headers: { 'Content-Type': 'application/json' },
|
||||||
fetch(url, {
|
body: JSON.stringify({ ids: ids }),
|
||||||
method: 'POST',
|
}).then(function (antwort) {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
if (!antwort.ok) { window.location.reload(); }
|
||||||
body: JSON.stringify({ ids: ids }),
|
}).catch(function () { window.location.reload(); });
|
||||||
}).then(function (antwort) {
|
}
|
||||||
if (!antwort.ok) { window.location.reload(); }
|
|
||||||
}).catch(function () { window.location.reload(); });
|
// Auf/Ab-Buttons (mobil): Element mit Nachbar tauschen, dann speichern.
|
||||||
},
|
liste.addEventListener('click', function (ereignis) {
|
||||||
|
const knopf = ereignis.target.closest('.updown-btn');
|
||||||
|
if (!knopf) { return; }
|
||||||
|
const li = knopf.closest('li[data-id]');
|
||||||
|
if (!li) { return; }
|
||||||
|
const ziel = knopf.getAttribute('data-dir') === '1'
|
||||||
|
? li.nextElementSibling
|
||||||
|
: li.previousElementSibling;
|
||||||
|
if (!ziel || !ziel.matches('li[data-id]')) { return; }
|
||||||
|
if (knopf.getAttribute('data-dir') === '1') { ziel.after(li); } else { ziel.before(li); }
|
||||||
|
speichern();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Drag & Drop (Desktop)
|
||||||
|
if (typeof Sortable !== 'undefined') {
|
||||||
|
Sortable.create(liste, {
|
||||||
|
handle: '.drag-handle',
|
||||||
|
animation: 150,
|
||||||
|
ghostClass: 'item-drag',
|
||||||
|
onEnd: speichern,
|
||||||
|
});
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="row-between">
|
<div class="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h1 style="margin: 0;">Inspiration</h1>
|
<h1>Inspiration</h1>
|
||||||
<div class="muted" style="margin-top: 0.2rem;">Gerichte aus dem Archiv – was kochst du mal wieder?</div>
|
<div class="subtitle">Gerichte aus dem Archiv – was kochst du mal wieder?</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a class="btn btn-link" href="{{ path('mahlzeiten_index') }}">← Mahlzeiten</a>
|
<a class="btn btn-link" href="{{ path('mahlzeiten_index') }}">← Mahlzeiten</a>
|
||||||
@@ -18,40 +18,45 @@
|
|||||||
{% set alleAusschluss = ausschluss + gezeigte %}
|
{% set alleAusschluss = ausschluss + gezeigte %}
|
||||||
|
|
||||||
{% if total == 0 %}
|
{% if total == 0 %}
|
||||||
<p class="muted" style="margin-top: 1rem;">
|
<p class="empty">
|
||||||
Noch keine Inspiration: Hake Mahlzeiten ab – dann erscheinen sie hier als Vorschlag.
|
Noch keine Inspiration: Hake Mahlzeiten ab – dann erscheinen sie hier als Vorschlag.
|
||||||
</p>
|
</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
{% for v in vorschlaege %}
|
{% for v in vorschlaege %}
|
||||||
<li class="item" id="vorschlag-{{ v.id }}">
|
<li class="item" id="vorschlag-{{ v.id }}">
|
||||||
<div>
|
<div class="item-name">
|
||||||
<strong>{{ v.name }}</strong>
|
<strong>{{ v.name }}</strong>
|
||||||
{% if v.notiz != '' %}
|
{% if v.notiz != '' %}
|
||||||
<div class="muted">{{ v.notiz }}</div>
|
<div class="notiz">{{ v.notiz }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="status">
|
<div class="vorschlag-meta">
|
||||||
{% if v.gegessenAm %}
|
{% if v.gegessenAm %}
|
||||||
zuletzt gegessen · {{ v.gegessenAm|date('d.m.Y') }}
|
zuletzt gegessen · {{ v.gegessenAm|date('d.m.Y') }}
|
||||||
{% else %}
|
{% else %}
|
||||||
aus dem Archiv
|
aus dem Archiv
|
||||||
{% endif %}
|
{% endif %}
|
||||||
· {{ v.auftraete }}× in der Küche
|
· <b>{{ v.auftraete }}</b>× in der Küche
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<form method="post" action="{{ path('mahlzeiten_adopt', { id: v.id }) }}">
|
<form method="post" action="{{ path('mahlzeiten_adopt', { id: v.id }) }}">
|
||||||
{% for id in alleAusschluss %}
|
{% for id in alleAusschluss %}
|
||||||
<input type="hidden" name="ausschluss[]" value="{{ id }}">
|
<input type="hidden" name="ausschluss[]" value="{{ id }}">
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<button type="submit" class="btn">Übernehmen</button>
|
<button type="submit" class="btn">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M20 6 9 17l-5-5"/>
|
||||||
|
</svg>
|
||||||
|
Übernehmen
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% if mehrVerfuegbar %}
|
{% if mehrVerfuegbar %}
|
||||||
<p style="margin: 1rem 0 0;">
|
<p class="muted" style="margin: 1.1rem 0 0; text-align: center;">
|
||||||
<a class="btn btn-link"
|
<a class="btn btn-link"
|
||||||
href="{{ path('mahlzeiten_inspiration', { ausschluss: alleAusschluss|join(',') }) }}">Weitere 5 Vorschläge</a>
|
href="{{ path('mahlzeiten_inspiration', { ausschluss: alleAusschluss|join(',') }) }}">Weitere 5 Vorschläge</a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="row-between">
|
<div class="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h1 style="margin: 0;">Tagesansicht</h1>
|
<h1>Tagesansicht</h1>
|
||||||
<div class="muted" style="margin-top: 0.2rem;">Alle Mahlzeiten nach Wochentag</div>
|
<div class="subtitle">Alle Mahlzeiten nach Wochentag</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a class="btn btn-link" href="{{ path('mahlzeiten_index') }}">← Mahlzeiten</a>
|
<a class="btn btn-link" href="{{ path('mahlzeiten_index') }}">← Mahlzeiten</a>
|
||||||
@@ -16,10 +16,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if total == 0 %}
|
{% if total == 0 %}
|
||||||
<p class="muted" style="margin-top: 1rem;">Noch keine Mahlzeiten angelegt.</p>
|
<p class="empty">Noch keine Mahlzeiten angelegt.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% for tag, mahlzeiten in tage %}
|
{% for tag, mahlzeiten in tage %}
|
||||||
<h2 style="margin: 1.25rem 0 0;">{{ tag }}</h2>
|
<h2 data-count="{{ mahlzeiten|length }}" {% if tag == heute %}data-heute="1"{% endif %}>{{ tag }}</h2>
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
{% for m in mahlzeiten %}
|
{% for m in mahlzeiten %}
|
||||||
{{ include('mahlzeiten/_mahlzeit.html.twig', { m: m }) }}
|
{{ include('mahlzeiten/_mahlzeit.html.twig', { m: m }) }}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Functional\UI\Essen;
|
||||||
|
|
||||||
|
use Doctrine\ORM\Tools\SchemaTool;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
use Symfony\Component\BrowserKit\Cookie;
|
||||||
|
|
||||||
|
final class PwaSmokeTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private static function authedClient(): KernelBrowser
|
||||||
|
{
|
||||||
|
$client = self::createClient();
|
||||||
|
$client->followRedirects(false);
|
||||||
|
$em = self::getContainer()->get('doctrine')->getManager();
|
||||||
|
$schemaTool = new SchemaTool($em);
|
||||||
|
$schemaTool->dropSchema($em->getMetadataFactory()->getAllMetadata());
|
||||||
|
$schemaTool->createSchema($em->getMetadataFactory()->getAllMetadata());
|
||||||
|
$session = self::getContainer()->get('session.factory')->createSession();
|
||||||
|
$session->start();
|
||||||
|
foreach (['auth_sub' => 'test-sub', 'auth_name' => 'Jens', 'auth_email' => 'jens@ljh31.de'] as $k => $v) {
|
||||||
|
$session->set($k, $v);
|
||||||
|
}
|
||||||
|
$session->save();
|
||||||
|
$client->getCookieJar()->set(new Cookie($session->getName(), $session->getId()));
|
||||||
|
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIndexHasPwaAndMobileMarkers(): void
|
||||||
|
{
|
||||||
|
$client = self::authedClient();
|
||||||
|
$client->request('GET', '/mahlzeiten');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
$html = (string) $client->getResponse()->getContent();
|
||||||
|
self::assertStringContainsString('manifest.webmanifest', $html);
|
||||||
|
self::assertStringContainsString('class="fab"', $html);
|
||||||
|
self::assertStringContainsString("register('/sw.js')", $html);
|
||||||
|
self::assertStringContainsString('href="/mahlzeiten/tage"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTageHighlightsHeutigerTag(): void
|
||||||
|
{
|
||||||
|
$client = self::authedClient();
|
||||||
|
$client->request('POST', '/mahlzeiten', [
|
||||||
|
'name' => 'Heutiges Gericht',
|
||||||
|
'notiz' => '',
|
||||||
|
'wochentage' => [self::heuteWert()],
|
||||||
|
]);
|
||||||
|
$client->request('GET', '/mahlzeiten/tage');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
$h2 = $client->getCrawler()->filterXPath('//h2[@data-heute="1"]');
|
||||||
|
self::assertCount(1, $h2, 'Genau ein <h2> trägt das data-heute-Attribut.');
|
||||||
|
self::assertSame(self::heuteName(), trim($h2->text()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function heuteName(): string
|
||||||
|
{
|
||||||
|
$iso = (int) (new \DateTimeImmutable('now', new \DateTimeZone('Europe/Berlin')))->format('N');
|
||||||
|
|
||||||
|
return ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'][$iso - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function heuteWert(): string
|
||||||
|
{
|
||||||
|
$iso = (int) (new \DateTimeImmutable('now', new \DateTimeZone('Europe/Berlin')))->format('N');
|
||||||
|
|
||||||
|
return ['mo', 'di', 'mi', 'do', 'fr', 'sa', 'so'][$iso - 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\UI\Http\Auth;
|
||||||
|
|
||||||
|
use App\UI\Http\Auth\OidcClient;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\HttpClient\MockHttpClient;
|
||||||
|
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regressions-Test für die RS256-IdToken-Verifizierung:
|
||||||
|
* Signiert ein JWT gegen einen lokal generierten RSA-Schlüssel und prüft
|
||||||
|
* OidcClient::verifyIdToken() inkl. JWKS→PEM-Konvertierung.
|
||||||
|
*/
|
||||||
|
final class OidcClientTest extends TestCase
|
||||||
|
{
|
||||||
|
private const ISSUER = 'https://issuer.test/realms/family';
|
||||||
|
private const CLIENT_ID = 'essen';
|
||||||
|
private const KID = 'test-kid-signing';
|
||||||
|
|
||||||
|
/** @var resource */
|
||||||
|
private $key;
|
||||||
|
/** @var array{n: string, e: string} */
|
||||||
|
private array $jwk;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->key = openssl_pkey_new([
|
||||||
|
'private_key_bits' => 2048,
|
||||||
|
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||||
|
]);
|
||||||
|
if ($this->key === false) {
|
||||||
|
self::fail('konnte RSA-Schlüssel generieren.');
|
||||||
|
}
|
||||||
|
$details = openssl_pkey_get_details($this->key);
|
||||||
|
$n = $details['n'] ?? $details['rsa']['n'];
|
||||||
|
$e = $details['e'] ?? $details['rsa']['e'];
|
||||||
|
$this->jwk = [
|
||||||
|
'n' => self::b64url($n),
|
||||||
|
'e' => self::b64url($e),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVerifyIdTokenAkzeptiertGueltigSigniertesToken(): void
|
||||||
|
{
|
||||||
|
$jwt = $this->signJwt(['sub' => 'jens@ljh31.de', 'email' => 'jens@ljh31.de']);
|
||||||
|
|
||||||
|
$claims = $this->oidcClient()->verifyIdToken($jwt);
|
||||||
|
|
||||||
|
self::assertSame('jens@ljh31.de', $claims['sub']);
|
||||||
|
self::assertSame('jens@ljh31.de', $claims['email']);
|
||||||
|
self::assertSame(self::ISSUER, $claims['iss']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVerifyIdTokenAkzeptiertAudienceAlsArray(): void
|
||||||
|
{
|
||||||
|
$jwt = $this->signJwt(['sub' => 'x'], ['aud' => ['essen', 'anderer-client']]);
|
||||||
|
|
||||||
|
$claims = $this->oidcClient()->verifyIdToken($jwt);
|
||||||
|
|
||||||
|
self::assertSame('x', $claims['sub']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVerifyIdTokenVerwirftManipuliertesPayload(): void
|
||||||
|
{
|
||||||
|
$jwt = $this->signJwt(['sub' => 'jens@ljh31.de']);
|
||||||
|
$tampered = $this->tamper($jwt, 'jens@ljh31.de', 'admin@ljh31.de');
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->oidcClient()->verifyIdToken($tampered);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVerifyIdTokenVerwirftFalscheIssuer(): void
|
||||||
|
{
|
||||||
|
$jwt = $this->signJwt(['sub' => 'x'], ['iss' => 'https://anderer.issuer/realms/family']);
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->oidcClient()->verifyIdToken($jwt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVerifyIdTokenVerwirftFalscheAudience(): void
|
||||||
|
{
|
||||||
|
$jwt = $this->signJwt(['sub' => 'x'], ['aud' => 'falscher-client']);
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->oidcClient()->verifyIdToken($jwt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVerifyIdTokenVerwirftAbgelaufenesToken(): void
|
||||||
|
{
|
||||||
|
$jwt = $this->signJwt(['sub' => 'x'], ['exp' => time() - 3600]);
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->oidcClient()->verifyIdToken($jwt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVerifyIdTokenWähltRichtigenKeyBeiMehrfachJwks(): void
|
||||||
|
{
|
||||||
|
// Zusätzliches RSA-OAEP-Enc-Key (wie in der echten Keycloak-Realm):
|
||||||
|
// muss ignoriert werden, obwohl es denselben Modulus teilt.
|
||||||
|
$jwt = $this->signJwt(['sub' => 'x']);
|
||||||
|
|
||||||
|
$claims = $this->oidcClient(extraKey: [
|
||||||
|
'kty' => 'RSA',
|
||||||
|
'alg' => 'RSA-OAEP',
|
||||||
|
'use' => 'enc',
|
||||||
|
'kid' => 'test-kid-enc',
|
||||||
|
'n' => $this->jwk['n'],
|
||||||
|
'e' => $this->jwk['e'],
|
||||||
|
])->verifyIdToken($jwt);
|
||||||
|
|
||||||
|
self::assertSame('x', $claims['sub']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function signJwt(array $claims, array $overrides = []): string
|
||||||
|
{
|
||||||
|
$header = ['alg' => 'RS256', 'typ' => 'JWT', 'kid' => self::KID];
|
||||||
|
$payload = array_merge([
|
||||||
|
'iss' => self::ISSUER,
|
||||||
|
'aud' => self::CLIENT_ID,
|
||||||
|
'exp' => time() + 300,
|
||||||
|
'iat' => time(),
|
||||||
|
], $overrides, $claims);
|
||||||
|
|
||||||
|
$h64 = self::b64url((string) json_encode($header));
|
||||||
|
$p64 = self::b64url((string) json_encode($payload));
|
||||||
|
if (openssl_sign("$h64.$p64", $sig, $this->key, OPENSSL_ALGO_SHA256) !== true) {
|
||||||
|
self::fail('konnte Test-JWT signieren.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return "$h64.$p64." . self::b64url($sig);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tamper(string $jwt, string $from, string $to): string
|
||||||
|
{
|
||||||
|
$parts = explode('.', $jwt);
|
||||||
|
$payload = json_decode((string) base64_decode(strtr($parts[1], '-_', '+/')), true);
|
||||||
|
$payload['sub'] = $to;
|
||||||
|
$parts[1] = self::b64url((string) json_encode($payload));
|
||||||
|
return implode('.', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function oidcClient(?array $extraKey = null): OidcClient
|
||||||
|
{
|
||||||
|
$keys = [[
|
||||||
|
'kty' => 'RSA',
|
||||||
|
'alg' => 'RS256',
|
||||||
|
'use' => 'sig',
|
||||||
|
'kid' => self::KID,
|
||||||
|
'n' => $this->jwk['n'],
|
||||||
|
'e' => $this->jwk['e'],
|
||||||
|
]];
|
||||||
|
if ($extraKey !== null) {
|
||||||
|
array_unshift($keys, $extraKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
$httpClient = new MockHttpClient(new MockResponse((string) json_encode(['keys' => $keys])));
|
||||||
|
|
||||||
|
return new OidcClient(
|
||||||
|
httpClient: $httpClient,
|
||||||
|
issuer: self::ISSUER,
|
||||||
|
clientId: self::CLIENT_ID,
|
||||||
|
clientSecret: null,
|
||||||
|
redirectUri: 'https://app.test/auth/callback',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function b64url(string $binary): string
|
||||||
|
{
|
||||||
|
return rtrim(strtr(base64_encode($binary), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user