Initial commit: Dudi habit tracker
Symfony 8 SPA with Doctrine ORM, Symfony Security, vanilla JS frontend. Migrated from plain PHP (delight-im/auth + raw SQL) to full Symfony stack. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
fd473f00af
64 changed files with 12287 additions and 0 deletions
18
.env
Normal file
18
.env
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
###> symfony/framework-bundle ###
|
||||||
|
APP_ENV=dev
|
||||||
|
APP_SECRET=change_me
|
||||||
|
###< symfony/framework-bundle ###
|
||||||
|
|
||||||
|
###> doctrine/doctrine-bundle ###
|
||||||
|
DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11-MariaDB&charset=utf8mb4"
|
||||||
|
###< doctrine/doctrine-bundle ###
|
||||||
|
|
||||||
|
###> symfony/mailer ###
|
||||||
|
MAILER_DSN=null://null
|
||||||
|
MAILER_FROM=noreply@example.com
|
||||||
|
###< symfony/mailer ###
|
||||||
|
|
||||||
|
###> app ###
|
||||||
|
APP_URL=http://localhost
|
||||||
|
DEFAULT_URI=http://localhost/dd
|
||||||
|
###< app ###
|
||||||
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
###> symfony ###
|
||||||
|
/.env.local
|
||||||
|
/.env.*.local
|
||||||
|
/var/
|
||||||
|
/vendor/
|
||||||
|
/public/bundles/
|
||||||
|
###< symfony ###
|
||||||
|
|
||||||
|
###> legacy (pre-symfony) ###
|
||||||
|
/include/config.php
|
||||||
|
###< legacy ###
|
||||||
|
|
||||||
|
###> tools ###
|
||||||
|
/.claude/
|
||||||
|
/.superpowers/
|
||||||
|
/.aider*
|
||||||
|
/docs/private/
|
||||||
|
###< tools ###
|
||||||
15
.htaccess
Normal file
15
.htaccess
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteBase /dd/
|
||||||
|
|
||||||
|
# Composer-Dateien nicht ausliefern
|
||||||
|
<FilesMatch "^(composer\.(json|lock))$">
|
||||||
|
Require all denied
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
|
# API-Requests an api.php weiterleiten, Pfad als Query-Parameter
|
||||||
|
RewriteRule ^api/(.+)$ api.php?_path=$1 [L,QSA]
|
||||||
|
RewriteRule ^api/?$ api.php [L,QSA]
|
||||||
|
|
||||||
|
# Alles andere an index.php (SPA)
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteRule ^ index.php [L]
|
||||||
48
CLAUDE.md
Normal file
48
CLAUDE.md
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
# Dudi – Claude Context
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
Habit & goal tracking PWA. Users set goals (e.g. "50 push-ups/day for 30 days"), log sets daily, and see progress. Invite-only registration. German UI.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- Symfony 8 + Doctrine ORM (src/)
|
||||||
|
- Vanilla JS SPA — no build step, no npm (public/app.js)
|
||||||
|
- HTML `<template>` elements in templates/app.html.twig (rendered once, JS clones them)
|
||||||
|
- MariaDB, session auth + remember-me 24h
|
||||||
|
|
||||||
|
## Local dev
|
||||||
|
- URL: http://dudi.local/
|
||||||
|
- DB: zieltracker / zieltracker (see .env.local, gitignored)
|
||||||
|
- `php bin/console cache:clear` after config changes
|
||||||
|
- `public/app.js` is the authoritative JS file — also copy to `public/` after edits to `app.js` at root (root copy is legacy, will be removed)
|
||||||
|
|
||||||
|
## Server
|
||||||
|
- `ssh -p 30183 root@miniweb.kuehn.home`
|
||||||
|
- App: /var/www/tracker/, DB: dd/dd
|
||||||
|
- Deploy: `bash deploy.sh` (rsync + remote composer install + cache:clear)
|
||||||
|
- Remember-me table must exist: `rememberme_token` (already created)
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
- `src/Controller/AuthController.php` — login, register, password reset, name change
|
||||||
|
- `src/Controller/GoalController.php` — goals CRUD
|
||||||
|
- `src/Controller/InviteController.php` — invite creation + listing
|
||||||
|
- `src/Security/JsonLoginAuthenticator.php` — JSON POST /api/login
|
||||||
|
- `config/packages/security.yaml` — firewall, remember-me, access control
|
||||||
|
- `templates/app.html.twig` — SPA shell + all tpl-* templates
|
||||||
|
- `public/app.js` — all frontend logic
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
- `goals.sets` is a JSON object: `{"2026-04-01": [20, 30], "2026-04-02": [50]}` — keys are dates, values are arrays of logged amounts per session
|
||||||
|
- Users table schema is compatible with the old delight-im/auth library (bcrypt hashes work unchanged)
|
||||||
|
- Password resets use `users_resets` table directly (raw SQL) — Symfony doesn't manage this table
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
- API responses: `{ ok: true, ... }` on success, `{ error: "message" }` on failure
|
||||||
|
- All API routes prefixed `/api/`, catch-all route in AppController renders SPA
|
||||||
|
- No Twig cache (cache: false in index.php, now handled by Symfony kernel in prod)
|
||||||
|
- JS uses `tpl(id)` helper to clone `<template>` elements, fills via querySelector with semantic class names
|
||||||
|
- No comments in JS unless non-obvious; no trailing summaries in responses
|
||||||
|
|
||||||
|
## Pending cleanup
|
||||||
|
- `api.php`, `index.php`, `include/`, `app.js`, `style.css` at project root are legacy pre-Symfony files — safe to delete once miniweb is migrated to Symfony
|
||||||
|
- Legacy DB tables that can be dropped: `users_throttling`, `users_confirmations`, `schema_migrations`
|
||||||
|
- miniweb still runs old non-Symfony code — Symfony deploy + Apache config update pending
|
||||||
48
README.md
Normal file
48
README.md
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
# Dudi – Durchzieh-Dienst
|
||||||
|
|
||||||
|
A lightweight habit & goal tracker PWA built with Symfony 8, Doctrine ORM, and vanilla JS.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Backend:** Symfony 8, Doctrine ORM, Symfony Security, Symfony Mailer
|
||||||
|
- **Frontend:** Vanilla JS SPA, HTML `<template>` elements, no build step
|
||||||
|
- **Database:** MariaDB
|
||||||
|
- **Auth:** Symfony Security with bcrypt, session + remember-me (24h)
|
||||||
|
|
||||||
|
## Local Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer install
|
||||||
|
cp .env .env.local # then fill in real values
|
||||||
|
php bin/console cache:clear
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure Apache to point to `public/` as DocumentRoot (see `docs/deployment.md`).
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
The app runs at `http://dudi.local/` locally. An Apache VirtualHost and `/etc/hosts` entry are required.
|
||||||
|
|
||||||
|
```
|
||||||
|
127.0.0.1 dudi.local
|
||||||
|
```
|
||||||
|
|
||||||
|
Apache VirtualHost:
|
||||||
|
```apache
|
||||||
|
<VirtualHost *:80>
|
||||||
|
ServerName dudi.local
|
||||||
|
DocumentRoot "/path/to/dudi/public"
|
||||||
|
<Directory "/path/to/dudi/public">
|
||||||
|
AllowOverride All
|
||||||
|
Require all granted
|
||||||
|
</Directory>
|
||||||
|
</VirtualHost>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
See `docs/deployment.md` for first-time server setup.
|
||||||
294
api.php
Normal file
294
api.php
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
<?php
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
ini_set('log_errors', 1);
|
||||||
|
ini_set('error_log', '/tmp/zt.log');
|
||||||
|
set_exception_handler(function(\Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => $e->getMessage(), 'file' => basename($e->getFile()), 'line' => $e->getLine()]);
|
||||||
|
exit;
|
||||||
|
});
|
||||||
|
require_once __DIR__ . '/include/db.php';
|
||||||
|
require_once __DIR__ . '/include/mailer.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
// CORS für gleiche Domain nicht nötig; bei separaten Dev-Domains ggf. ergänzen
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$segments = explode('/', trim($_GET['_path'] ?? '', '/'));
|
||||||
|
$resource = $segments[0] ?? '';
|
||||||
|
$resourceId = isset($segments[1]) && $segments[1] !== '' ? (int)$segments[1] : null;
|
||||||
|
|
||||||
|
|
||||||
|
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
|
||||||
|
function json_out(array $data, int $status = 200): never {
|
||||||
|
http_response_code($status);
|
||||||
|
echo json_encode($data);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function require_auth(\Delight\Auth\Auth $auth): int {
|
||||||
|
if (!$auth->isLoggedIn()) {
|
||||||
|
json_out(['error' => 'Unauthorized'], 401);
|
||||||
|
}
|
||||||
|
return $auth->getUserId();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /api/login ──────────────────────────────────────────────────────────
|
||||||
|
if ($method === 'POST' && $resource === 'login') {
|
||||||
|
$email = trim($body['email'] ?? '');
|
||||||
|
$pass = $body['password'] ?? '';
|
||||||
|
try {
|
||||||
|
$auth->login($email, $pass, 86400);
|
||||||
|
json_out(['ok' => true, 'email' => $auth->getEmail()]);
|
||||||
|
} catch (\Delight\Auth\InvalidEmailException
|
||||||
|
| \Delight\Auth\InvalidPasswordException
|
||||||
|
| \Delight\Auth\UserNotFound $e) {
|
||||||
|
json_out(['error' => 'Falsche E-Mail oder Passwort'], 401);
|
||||||
|
} catch (\Delight\Auth\TooManyRequestsException $e) {
|
||||||
|
json_out(['error' => 'Zu viele Versuche, bitte warten'], 429);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /api/logout ─────────────────────────────────────────────────────────
|
||||||
|
if ($method === 'POST' && $resource === 'logout') {
|
||||||
|
$auth->logOut();
|
||||||
|
json_out(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /api/me ───────────────────────────────────────────────────────────────
|
||||||
|
if ($method === 'GET' && $resource === 'me') {
|
||||||
|
if ($auth->isLoggedIn()) {
|
||||||
|
$name = $conn->fetchOne('SELECT username FROM users WHERE id = ?', [$auth->getUserId()]);
|
||||||
|
json_out(['ok' => true, 'email' => $auth->getEmail(), 'id' => $auth->getUserId(), 'name' => $name ?: '']);
|
||||||
|
}
|
||||||
|
json_out(['ok' => false], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PATCH /api/me ─────────────────────────────────────────────────────────────
|
||||||
|
if ($method === 'PATCH' && $resource === 'me') {
|
||||||
|
$uid = require_auth($auth);
|
||||||
|
$name = trim($body['name'] ?? '');
|
||||||
|
if (!$name) json_out(['error' => 'Name fehlt'], 400);
|
||||||
|
$conn->executeStatement('UPDATE users SET username = ? WHERE id = ?', [$name, $uid]);
|
||||||
|
json_out(['ok' => true, 'name' => $name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /api/register ────────────────────────────────────────────────────────
|
||||||
|
if ($method === 'POST' && $resource === 'register') {
|
||||||
|
$email = trim($body['email'] ?? '');
|
||||||
|
$pass = $body['password'] ?? '';
|
||||||
|
$token = trim($body['token'] ?? '');
|
||||||
|
|
||||||
|
if (!$email || !$pass || !$token) {
|
||||||
|
json_out(['error' => 'Fehlende Felder'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token validieren
|
||||||
|
$invite = $conn->fetchAssociative(
|
||||||
|
'SELECT * FROM invites WHERE token = ? AND used_by IS NULL AND expires_at > NOW()',
|
||||||
|
[$token]
|
||||||
|
);
|
||||||
|
if (!$invite) {
|
||||||
|
json_out(['error' => 'Ungültiger oder abgelaufener Einladungslink'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = trim($body['name'] ?? '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$userId = $auth->register($email, $pass);
|
||||||
|
} catch (\Delight\Auth\InvalidEmailException $e) {
|
||||||
|
json_out(['error' => 'Ungültige E-Mail'], 400);
|
||||||
|
} catch (\Delight\Auth\InvalidPasswordException $e) {
|
||||||
|
json_out(['error' => 'Passwort zu kurz (min. 8 Zeichen)'], 400);
|
||||||
|
} catch (\Delight\Auth\UserAlreadyExistsException $e) {
|
||||||
|
json_out(['error' => 'E-Mail bereits registriert'], 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token als verwendet markieren
|
||||||
|
$conn->executeStatement(
|
||||||
|
'UPDATE invites SET used_by = ?, used_at = NOW() WHERE token = ?',
|
||||||
|
[$userId, $token]
|
||||||
|
);
|
||||||
|
if ($name) {
|
||||||
|
$conn->executeStatement('UPDATE users SET username = ? WHERE id = ?', [$name, $userId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direkt einloggen
|
||||||
|
$auth->login($email, $pass, 86400);
|
||||||
|
json_out(['ok' => true, 'email' => $auth->getEmail(), 'name' => $name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /api/reset-request ──────────────────────────────────────────────────
|
||||||
|
if ($method === 'POST' && $resource === 'reset-request') {
|
||||||
|
$email = trim($body['email'] ?? '');
|
||||||
|
if (!$email) json_out(['error' => 'E-Mail fehlt'], 400);
|
||||||
|
try {
|
||||||
|
$auth->requestPasswordReset($email, function (string $selector, string $token) use ($email) {
|
||||||
|
$url = APP_URL . '/?reset_selector=' . rawurlencode($selector) . '&reset_token=' . rawurlencode($token);
|
||||||
|
$text = "Hallo,\n\nklicke auf den folgenden Link um dein Passwort zurückzusetzen (gültig 24h):\n\n$url\n\nFalls du das nicht angefragt hast, ignoriere diese E-Mail.";
|
||||||
|
sendMail($email, 'Durchzieh-Dienst – Passwort zurücksetzen', $text);
|
||||||
|
});
|
||||||
|
} catch (\Delight\Auth\InvalidEmailException $e) {
|
||||||
|
// Keinen Hinweis geben ob die E-Mail existiert
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
json_out(['error' => 'Mail konnte nicht gesendet werden'], 500);
|
||||||
|
}
|
||||||
|
json_out(['ok' => true]); // immer ok, auch wenn E-Mail unbekannt
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /api/reset-password ──────────────────────────────────────────────────
|
||||||
|
if ($method === 'POST' && $resource === 'reset-password') {
|
||||||
|
$selector = $body['selector'] ?? '';
|
||||||
|
$token = $body['token'] ?? '';
|
||||||
|
$pass = $body['password'] ?? '';
|
||||||
|
if (!$selector || !$token || !$pass) json_out(['error' => 'Fehlende Felder'], 400);
|
||||||
|
try {
|
||||||
|
$auth->resetPassword($selector, $token, $pass);
|
||||||
|
json_out(['ok' => true]);
|
||||||
|
} catch (\Delight\Auth\InvalidSelectorTokenPairException $e) {
|
||||||
|
json_out(['error' => 'Ungültiger oder abgelaufener Reset-Link'], 400);
|
||||||
|
} catch (\Delight\Auth\TokenExpiredException $e) {
|
||||||
|
json_out(['error' => 'Reset-Link abgelaufen, bitte neu anfordern'], 400);
|
||||||
|
} catch (\Delight\Auth\InvalidPasswordException $e) {
|
||||||
|
json_out(['error' => 'Passwort zu kurz (min. 8 Zeichen)'], 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /api/change-password ─────────────────────────────────────────────────
|
||||||
|
if ($method === 'POST' && $resource === 'change-password') {
|
||||||
|
$uid = require_auth($auth);
|
||||||
|
$oldPass = $body['old_password'] ?? '';
|
||||||
|
$newPass = $body['new_password'] ?? '';
|
||||||
|
if (!$oldPass || !$newPass) json_out(['error' => 'Fehlende Felder'], 400);
|
||||||
|
try {
|
||||||
|
$auth->changePassword($oldPass, $newPass);
|
||||||
|
json_out(['ok' => true]);
|
||||||
|
} catch (\Delight\Auth\NotLoggedInException $e) {
|
||||||
|
json_out(['error' => 'Nicht eingeloggt'], 401);
|
||||||
|
} catch (\Delight\Auth\InvalidPasswordException $e) {
|
||||||
|
json_out(['error' => 'Neues Passwort zu kurz (min. 8 Zeichen)'], 400);
|
||||||
|
} catch (\Delight\Auth\TooManyRequestsException $e) {
|
||||||
|
json_out(['error' => 'Zu viele Versuche'], 429);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /api/invite ─────────────────────────────────────────────────────────
|
||||||
|
if ($method === 'POST' && $resource === 'invite') {
|
||||||
|
$uid = require_auth($auth);
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
$note = trim($body['note'] ?? '');
|
||||||
|
$conn->executeStatement(
|
||||||
|
'INSERT INTO invites (token, note, created_by, expires_at) VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 7 DAY))',
|
||||||
|
[$token, $note ?: null, $uid]
|
||||||
|
);
|
||||||
|
json_out(['url' => APP_URL . '/?invite=' . $token]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /api/invites ──────────────────────────────────────────────────────────
|
||||||
|
if ($method === 'GET' && $resource === 'invites') {
|
||||||
|
$uid = require_auth($auth);
|
||||||
|
$rows = $conn->fetchAllAssociative(
|
||||||
|
'SELECT i.token, i.created_at, i.expires_at, i.used_at, i.note, u.email AS used_by_email
|
||||||
|
FROM invites i
|
||||||
|
LEFT JOIN users u ON u.id = i.used_by
|
||||||
|
WHERE i.created_by = ?
|
||||||
|
ORDER BY i.created_at DESC',
|
||||||
|
[$uid]
|
||||||
|
);
|
||||||
|
$result = array_map(function ($r) {
|
||||||
|
if ($r['used_by_email'] !== null) {
|
||||||
|
$status = 'used';
|
||||||
|
} elseif (strtotime($r['expires_at']) < time()) {
|
||||||
|
$status = 'expired';
|
||||||
|
} else {
|
||||||
|
$status = 'pending';
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'url' => $status === 'pending' ? APP_URL . '/?invite=' . $r['token'] : null,
|
||||||
|
'created_at' => $r['created_at'],
|
||||||
|
'expires_at' => $r['expires_at'],
|
||||||
|
'used_at' => $r['used_at'],
|
||||||
|
'note' => $r['note'],
|
||||||
|
'used_by_email' => $r['used_by_email'],
|
||||||
|
'status' => $status,
|
||||||
|
];
|
||||||
|
}, $rows);
|
||||||
|
json_out($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /api/goals ────────────────────────────────────────────────────────────
|
||||||
|
if ($method === 'GET' && $resource === 'goals') {
|
||||||
|
$uid = require_auth($auth);
|
||||||
|
$rows = $conn->fetchAllAssociative(
|
||||||
|
'SELECT id, name, unit, daily, days, start, sets FROM goals WHERE user_id = ? ORDER BY created_at ASC',
|
||||||
|
[$uid]
|
||||||
|
);
|
||||||
|
$goals = array_map(function ($r) {
|
||||||
|
$r['sets'] = json_decode($r['sets'], true) ?: [];
|
||||||
|
$r['id'] = (string)$r['id'];
|
||||||
|
return $r;
|
||||||
|
}, $rows);
|
||||||
|
json_out($goals);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /api/goals ───────────────────────────────────────────────────────────
|
||||||
|
if ($method === 'POST' && $resource === 'goals') {
|
||||||
|
$uid = require_auth($auth);
|
||||||
|
$name = trim($body['name'] ?? '');
|
||||||
|
$unit = trim($body['unit'] ?? 'Stück');
|
||||||
|
$daily = (float)($body['daily'] ?? 1);
|
||||||
|
$days = (int) ($body['days'] ?? 30);
|
||||||
|
try { $start = (new DateTime($body['start'] ?? 'now'))->format('Y-m-d H:i:s'); }
|
||||||
|
catch (\Exception $e) { $start = date('Y-m-d H:i:s'); }
|
||||||
|
$setsJson = isset($body['sets']) && is_array($body['sets']) ? json_encode((object)$body['sets']) : '{}';
|
||||||
|
if (!$name) json_out(['error' => 'Name fehlt'], 400);
|
||||||
|
|
||||||
|
$conn->executeStatement(
|
||||||
|
'INSERT INTO goals (user_id, name, unit, daily, days, start, sets) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[$uid, $name, $unit, $daily, $days, $start, $setsJson]
|
||||||
|
);
|
||||||
|
$id = (string)$conn->lastInsertId();
|
||||||
|
json_out(['id' => $id, 'name' => $name, 'unit' => $unit, 'daily' => $daily, 'days' => $days, 'start' => $start, 'sets' => json_decode($setsJson, true)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PATCH /api/goals/{id} ─────────────────────────────────────────────────────
|
||||||
|
if ($method === 'PATCH' && $resource === 'goals' && $resourceId !== null) {
|
||||||
|
$uid = require_auth($auth);
|
||||||
|
// Sicherstellen dass das Ziel dem User gehört
|
||||||
|
$existing = $conn->fetchOne('SELECT id FROM goals WHERE id = ? AND user_id = ?', [$resourceId, $uid]);
|
||||||
|
if (!$existing) json_out(['error' => 'Nicht gefunden'], 404);
|
||||||
|
|
||||||
|
$fields = [];
|
||||||
|
$params = [];
|
||||||
|
foreach (['name', 'unit'] as $f) {
|
||||||
|
if (isset($body[$f])) { $fields[] = "$f = ?"; $params[] = (string)$body[$f]; }
|
||||||
|
}
|
||||||
|
foreach (['daily'] as $f) {
|
||||||
|
if (isset($body[$f])) { $fields[] = "$f = ?"; $params[] = (float)$body[$f]; }
|
||||||
|
}
|
||||||
|
foreach (['days'] as $f) {
|
||||||
|
if (isset($body[$f])) { $fields[] = "$f = ?"; $params[] = (int)$body[$f]; }
|
||||||
|
}
|
||||||
|
if (isset($body['sets'])) {
|
||||||
|
$fields[] = 'sets = ?';
|
||||||
|
$params[] = json_encode($body['sets']);
|
||||||
|
}
|
||||||
|
if (!$fields) json_out(['ok' => true]);
|
||||||
|
|
||||||
|
$params[] = $resourceId;
|
||||||
|
$params[] = $uid;
|
||||||
|
$conn->executeStatement('UPDATE goals SET ' . implode(', ', $fields) . ' WHERE id = ? AND user_id = ?', $params);
|
||||||
|
json_out(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DELETE /api/goals/{id} ────────────────────────────────────────────────────
|
||||||
|
if ($method === 'DELETE' && $resource === 'goals' && $resourceId !== null) {
|
||||||
|
$uid = require_auth($auth);
|
||||||
|
$conn->executeStatement('DELETE FROM goals WHERE id = ? AND user_id = ?', [$resourceId, $uid]);
|
||||||
|
json_out(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
json_out(['error' => 'Not found'], 404);
|
||||||
716
app.js
Normal file
716
app.js
Normal file
|
|
@ -0,0 +1,716 @@
|
||||||
|
var TODAY = new Date(); TODAY.setHours(0,0,0,0);
|
||||||
|
var goals = [], prefs, selDay = {}, addAmt = {}, renamingId = null, renameVal = '', collapsed = {};
|
||||||
|
var userName = '';
|
||||||
|
|
||||||
|
function loadPref(k,def){ try{ return JSON.parse(localStorage.getItem(k)||def); }catch(e){ return JSON.parse(def); } }
|
||||||
|
function saveP(){ localStorage.setItem('zt_p',JSON.stringify(prefs)); }
|
||||||
|
prefs = loadPref('zt_p','{}');
|
||||||
|
|
||||||
|
function tOff(g){ return Math.round((TODAY - new Date(g.start))/86400000); }
|
||||||
|
function o2d(g,i){ var d=new Date(new Date(g.start).getTime()+i*86400000); d.setHours(0,0,0,0); return d; }
|
||||||
|
function dTot(g,o){ return (g.sets[String(o)]||[]).reduce(function(a,b){return a+b.amount;},0); }
|
||||||
|
function fd(d){ return d.toLocaleDateString('de-DE',{weekday:'short',day:'numeric',month:'short'}); }
|
||||||
|
function fs(d){ return d.toLocaleDateString('de-DE',{day:'numeric',month:'short'}); }
|
||||||
|
function editable(g,o){ var t=tOff(g); return o===t||o===t-1; }
|
||||||
|
function now(){ var n=new Date(); return String(n.getHours()).padStart(2,'0')+':'+String(n.getMinutes()).padStart(2,'0'); }
|
||||||
|
|
||||||
|
function heuteColor(tdone,daily){
|
||||||
|
if(tdone===0) return 'var(--red)';
|
||||||
|
if(tdone>=daily*1.1) return 'var(--blue)';
|
||||||
|
if(tdone>=daily) return 'var(--green)';
|
||||||
|
return 'var(--amber)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCollapsed(id){ return collapsed[id]!==false; }
|
||||||
|
function toggleCollapse(id){
|
||||||
|
var wasCollapsed=isCollapsed(id);
|
||||||
|
collapsed[id]=!wasCollapsed;
|
||||||
|
if(wasCollapsed){
|
||||||
|
var g=goals.filter(function(x){return x.id===id;})[0];
|
||||||
|
if(g) selDay[id]=tOff(g);
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function calc(g){
|
||||||
|
var t=tOff(g), tot=g.daily*g.days;
|
||||||
|
var dr=Math.max(0,g.days-t-1);
|
||||||
|
var sd=new Date(g.start); sd.setHours(0,0,0,0);
|
||||||
|
var end=new Date(sd.getTime()+g.days*86400000);
|
||||||
|
var past=0;
|
||||||
|
for(var i=0;i<Math.min(t,g.days);i++) past+=dTot(g,i);
|
||||||
|
var tdone=dTot(g,t), tot2=past+tdone;
|
||||||
|
var dl=dr+1;
|
||||||
|
var remaining=Math.max(0,tot-past);
|
||||||
|
var pd=Math.ceil(remaining/Math.max(1,dl));
|
||||||
|
var st=Math.max(0,pd-tdone);
|
||||||
|
var expectedPast=Math.min(t,g.days)*g.daily;
|
||||||
|
var buf=(past-expectedPast)+Math.max(0,tdone-g.daily);
|
||||||
|
var deficit=Math.min(0,buf);
|
||||||
|
var surplus=Math.max(0,buf);
|
||||||
|
var dailyDelta=pd-g.daily;
|
||||||
|
var pct=Math.min(100,Math.round((tot2/tot)*100));
|
||||||
|
return{tot:tot,tOff:t,end:end,dr:dr,done:tot2,tdone:tdone,pd:pd,st:st,buf:buf,deficit:deficit,surplus:surplus,dailyDelta:dailyDelta,net:tdone-pd,pct:pct,ok:tdone>=pd};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dcls(g,i){
|
||||||
|
var t=tOff(g); if(i>t) return 'dot df';
|
||||||
|
var v=dTot(g,i);
|
||||||
|
var c=v===0?'dot dm':v>=g.daily*1.1?'dot db':v>=g.daily?'dot dd':'dot dp';
|
||||||
|
return c+(editable(g,i)?' de':' dl');
|
||||||
|
}
|
||||||
|
function dlbl(g,i){
|
||||||
|
var t=tOff(g); if(i>t) return String(i+1);
|
||||||
|
var v=dTot(g,i);
|
||||||
|
if(v===0) return '✕'; if(v>=g.daily*1.1) return '+'; if(v>=g.daily) return '✓';
|
||||||
|
return Math.round(v/g.daily*100)+'%';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function api(method, path, body){
|
||||||
|
var opts = {method:method, credentials:'include', headers:{'Content-Type':'application/json'}};
|
||||||
|
if(body) opts.body = JSON.stringify(body);
|
||||||
|
return fetch('api/' + path, opts).then(function(res){
|
||||||
|
return res.json().then(function(data){
|
||||||
|
if(!res.ok){ var e=new Error(data.error||'Fehler'); e.status=res.status; throw e; }
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadGoals(){
|
||||||
|
return api('GET','goals').then(function(data){ return data; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveGoal(g){
|
||||||
|
api('PATCH','goals/'+g.id,{name:g.name,unit:g.unit,daily:g.daily,days:g.days,start:g.start,sets:g.sets})
|
||||||
|
.catch(function(e){
|
||||||
|
if(e.status===401){ showLogin(); }
|
||||||
|
else showToast('Speicherfehler');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toast ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function showToast(msg){
|
||||||
|
var t=document.createElement('div'); t.className='toast'; t.textContent=msg;
|
||||||
|
document.body.appendChild(t); setTimeout(function(){t.remove();},3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Goal-Aktionen ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function addSet(gid,off){
|
||||||
|
var g=goals.filter(function(x){return x.id===gid;})[0];
|
||||||
|
if(!g||!editable(g,off)) return;
|
||||||
|
var k=gid+'_'+off, amt=parseInt(addAmt[k]||'0',10);
|
||||||
|
if(amt<=0) return;
|
||||||
|
if(!g.sets[String(off)]) g.sets[String(off)]=[];
|
||||||
|
g.sets[String(off)].push({amount:amt,time:off===tOff(g)?now():'—'});
|
||||||
|
addAmt[k]=''; saveGoal(g); render();
|
||||||
|
}
|
||||||
|
function remSet(gid,off,idx){
|
||||||
|
var g=goals.filter(function(x){return x.id===gid;})[0];
|
||||||
|
if(!g||!editable(g,off)) return;
|
||||||
|
g.sets[String(off)].splice(idx,1); saveGoal(g); render();
|
||||||
|
}
|
||||||
|
function delGoal(id){
|
||||||
|
if(!confirm('Ziel wirklich löschen?')) return;
|
||||||
|
goals=goals.filter(function(g){return g.id!==id;});
|
||||||
|
render();
|
||||||
|
api('DELETE','goals/'+id).catch(function(){ showToast('Fehler beim Löschen'); });
|
||||||
|
}
|
||||||
|
function selD(gid,off){
|
||||||
|
var g=goals.filter(function(x){return x.id===gid;})[0];
|
||||||
|
if(!g||!editable(g,off)) return;
|
||||||
|
selDay[gid]=selDay[gid]===off?null:off; render();
|
||||||
|
}
|
||||||
|
function startRen(id){
|
||||||
|
var g=goals.filter(function(x){return x.id===id;})[0]; if(!g) return;
|
||||||
|
renamingId=id; renameVal=g.name; render();
|
||||||
|
setTimeout(function(){ var el=document.getElementById('ri'+id); if(el){el.focus();el.select();} },50);
|
||||||
|
}
|
||||||
|
function commitRen(id){
|
||||||
|
var g=goals.filter(function(x){return x.id===id;})[0];
|
||||||
|
if(g&&renameVal.trim()){g.name=renameVal.trim(); saveGoal(g);}
|
||||||
|
renamingId=null; render();
|
||||||
|
}
|
||||||
|
function cancelRen(){ renamingId=null; render(); }
|
||||||
|
|
||||||
|
// ── Template-Helper ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function tpl(id){
|
||||||
|
return document.getElementById(id).content.cloneNode(true).firstElementChild;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Overlays ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var OV_CSS='display:flex;position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.5);align-items:flex-end;justify-content:center;animation:fi .2s ease';
|
||||||
|
|
||||||
|
function closeOv(){
|
||||||
|
var o=document.getElementById('ov');
|
||||||
|
o.style.display='none';
|
||||||
|
o.innerHTML='';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSheet(content, dismissable){
|
||||||
|
var o=document.getElementById('ov');
|
||||||
|
o.style.cssText=OV_CSS;
|
||||||
|
var sheet=tpl('tpl-sheet');
|
||||||
|
sheet.appendChild(content);
|
||||||
|
o.innerHTML='';
|
||||||
|
o.appendChild(sheet);
|
||||||
|
o.onclick=dismissable!==false?function(e){if(e.target===o)closeOv();}:null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Login ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function showLogin(err){
|
||||||
|
var c=tpl('tpl-login');
|
||||||
|
if(err){ var e=c.querySelector('.login-err'); e.textContent=err; e.style.display=''; }
|
||||||
|
showSheet(c,false);
|
||||||
|
var email=c.querySelector('.lf-email'), pass=c.querySelector('.lf-pass'), sub=c.querySelector('.lf-sub');
|
||||||
|
setTimeout(function(){email.focus();},50);
|
||||||
|
email.onkeydown=function(e){if(e.key==='Enter')pass.focus();};
|
||||||
|
pass.onkeydown=function(e){if(e.key==='Enter')sub.click();};
|
||||||
|
c.querySelector('.lf-fgt').onclick=function(){showForgotPassword();};
|
||||||
|
sub.onclick=function(){
|
||||||
|
var ev=email.value.trim(), pv=pass.value;
|
||||||
|
if(!ev||!pv){ var errEl=c.querySelector('.login-err'); errEl.textContent='Bitte E-Mail und Passwort eingeben'; errEl.style.display=''; return; }
|
||||||
|
sub.disabled=true; sub.textContent='…';
|
||||||
|
api('POST','login',{email:ev,password:pv})
|
||||||
|
.then(function(){ return loadGoals(); })
|
||||||
|
.then(function(g){ goals=g; closeOv(); render(); })
|
||||||
|
.catch(function(err){
|
||||||
|
sub.disabled=false; sub.textContent='Anmelden';
|
||||||
|
showLogin(err.status===401?'Falsche E-Mail oder Passwort':err.status===429?'Zu viele Versuche':'Verbindungsfehler');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Passwort vergessen ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function showForgotPassword(){
|
||||||
|
var c=tpl('tpl-forgot-pw');
|
||||||
|
showSheet(c,false);
|
||||||
|
var email=c.querySelector('.fp-email'), errEl=c.querySelector('.login-err'), sub=c.querySelector('.fp-sub');
|
||||||
|
setTimeout(function(){email.focus();},50);
|
||||||
|
c.querySelector('.fp-back').onclick=function(){showLogin();};
|
||||||
|
sub.onclick=function(){
|
||||||
|
var ev=email.value.trim(); if(!ev) return;
|
||||||
|
sub.disabled=true; sub.textContent='…';
|
||||||
|
api('POST','reset-request',{email:ev})
|
||||||
|
.then(function(){
|
||||||
|
var conf=tpl('tpl-email-sent');
|
||||||
|
conf.querySelector('.es-ok').onclick=function(){showLogin();};
|
||||||
|
showSheet(conf,false);
|
||||||
|
})
|
||||||
|
.catch(function(err){
|
||||||
|
sub.disabled=false; sub.textContent='Link senden';
|
||||||
|
errEl.textContent=err.message||'Fehler'; errEl.style.display='';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Passwort zurücksetzen ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function showResetPassword(selector,token){
|
||||||
|
var c=tpl('tpl-reset-pw');
|
||||||
|
showSheet(c,false);
|
||||||
|
var pass=c.querySelector('.rp-pass'), errEl=c.querySelector('.login-err'), sub=c.querySelector('.rp-sub');
|
||||||
|
setTimeout(function(){pass.focus();},50);
|
||||||
|
sub.onclick=function(){
|
||||||
|
var pv=pass.value; if(!pv) return;
|
||||||
|
sub.disabled=true; sub.textContent='…';
|
||||||
|
api('POST','reset-password',{selector:selector,token:token,password:pv})
|
||||||
|
.then(function(){
|
||||||
|
var conf=tpl('tpl-pw-changed');
|
||||||
|
conf.querySelector('.pc-ok').onclick=function(){showLogin();};
|
||||||
|
showSheet(conf,false);
|
||||||
|
})
|
||||||
|
.catch(function(err){
|
||||||
|
sub.disabled=false; sub.textContent='Passwort setzen';
|
||||||
|
errEl.textContent=err.message||'Fehler'; errEl.style.display='';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Passwort ändern ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function showChangePassword(){
|
||||||
|
var c=tpl('tpl-change-pw');
|
||||||
|
showSheet(c,true);
|
||||||
|
var oldP=c.querySelector('.cp-old'), newP=c.querySelector('.cp-new'), newP2=c.querySelector('.cp-new2');
|
||||||
|
var errEl=c.querySelector('.login-err'), sub=c.querySelector('.cp-sub');
|
||||||
|
setTimeout(function(){oldP.focus();},50);
|
||||||
|
c.querySelector('.cp-can').onclick=closeOv;
|
||||||
|
sub.onclick=function(){
|
||||||
|
var o=oldP.value, n=newP.value, n2=newP2.value;
|
||||||
|
if(!o||!n||!n2) return;
|
||||||
|
if(n!==n2){ errEl.textContent='Die neuen Passwörter stimmen nicht überein'; errEl.style.display=''; return; }
|
||||||
|
sub.disabled=true; sub.textContent='…';
|
||||||
|
api('POST','change-password',{old_password:o,new_password:n})
|
||||||
|
.then(function(){ showToast('Passwort geändert'); closeOv(); })
|
||||||
|
.catch(function(err){
|
||||||
|
sub.disabled=false; sub.textContent='Ändern';
|
||||||
|
errEl.textContent=err.message||'Fehler'; errEl.style.display='';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Registrierung ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function showRegister(token){
|
||||||
|
var c=tpl('tpl-register');
|
||||||
|
showSheet(c,false);
|
||||||
|
var nameInp=c.querySelector('.rg-name'), email=c.querySelector('.rg-email');
|
||||||
|
var pass=c.querySelector('.rg-pass'), pass2=c.querySelector('.rg-pass2');
|
||||||
|
var errEl=c.querySelector('.login-err'), sub=c.querySelector('.rg-sub');
|
||||||
|
setTimeout(function(){nameInp.focus();},50);
|
||||||
|
nameInp.onkeydown=function(e){if(e.key==='Enter')email.focus();};
|
||||||
|
email.onkeydown=function(e){if(e.key==='Enter')pass.focus();};
|
||||||
|
pass.onkeydown=function(e){if(e.key==='Enter')pass2.focus();};
|
||||||
|
pass2.onkeydown=function(e){if(e.key==='Enter')sub.click();};
|
||||||
|
function checkMatch(){ if(pass2.value&&pass.value!==pass2.value){ errEl.textContent='Passwörter stimmen nicht überein'; errEl.style.display=''; } else { errEl.style.display='none'; } }
|
||||||
|
pass.oninput=checkMatch; pass2.oninput=checkMatch;
|
||||||
|
sub.onclick=function(){
|
||||||
|
var nv=nameInp.value.trim(), ev=email.value.trim(), pv=pass.value;
|
||||||
|
if(!nv||!ev||!pv){ errEl.textContent='Bitte alle Felder ausfüllen'; errEl.style.display=''; return; }
|
||||||
|
if(pv!==pass2.value){ errEl.textContent='Passwörter stimmen nicht überein'; errEl.style.display=''; return; }
|
||||||
|
sub.disabled=true; sub.textContent='…';
|
||||||
|
api('POST','register',{name:nv,email:ev,password:pv,token:token})
|
||||||
|
.then(function(r){ userName=r.name||''; return loadGoals(); })
|
||||||
|
.then(function(g){ goals=g; closeOv(); updateHeader(); render(); })
|
||||||
|
.catch(function(err){
|
||||||
|
sub.disabled=false; sub.textContent='Registrieren';
|
||||||
|
errEl.textContent=err.message||'Fehler'; errEl.style.display='';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Neues Ziel ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function openNew(){
|
||||||
|
var c=tpl('tpl-new-goal');
|
||||||
|
showSheet(c,true);
|
||||||
|
var name=c.querySelector('.ng-name'), unit=c.querySelector('.ng-unit');
|
||||||
|
var daily=c.querySelector('.ng-daily'), days=c.querySelector('.ng-days'), sub=c.querySelector('.ng-sub');
|
||||||
|
setTimeout(function(){name.focus();},50);
|
||||||
|
c.querySelector('.ng-can').onclick=closeOv;
|
||||||
|
sub.onclick=function(){
|
||||||
|
var nv=(name.value||'').trim(), uv=(unit.value||'').trim()||'Stück';
|
||||||
|
var dv=parseInt(daily.value,10)||1, dyv=parseInt(days.value,10)||30;
|
||||||
|
if(!nv){ name.focus(); return; }
|
||||||
|
sub.disabled=true;
|
||||||
|
api('POST','goals',{name:nv,unit:uv,daily:dv,days:dyv,start:TODAY.toISOString()})
|
||||||
|
.then(function(r){
|
||||||
|
goals.push({id:r.id,name:r.name,unit:r.unit,daily:r.daily,days:r.days,start:r.start,sets:r.sets||{}});
|
||||||
|
closeOv(); render();
|
||||||
|
}).catch(function(e){
|
||||||
|
sub.disabled=false;
|
||||||
|
if(e.status===401){ closeOv(); showLogin(); }
|
||||||
|
else showToast('Fehler beim Erstellen');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Daten-Menü ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function openData(){
|
||||||
|
var c=tpl('tpl-data-menu');
|
||||||
|
showSheet(c,true);
|
||||||
|
c.querySelector('.dm-cls').onclick=closeOv;
|
||||||
|
c.querySelector('.dm-name').onclick=function(){
|
||||||
|
var nc=tpl('tpl-change-name');
|
||||||
|
showSheet(nc,true);
|
||||||
|
var inp=nc.querySelector('.cn-name'), errEl=nc.querySelector('.login-err'), sub=nc.querySelector('.cn-sub');
|
||||||
|
inp.value=userName;
|
||||||
|
setTimeout(function(){inp.focus();inp.select();},50);
|
||||||
|
nc.querySelector('.cn-can').onclick=closeOv;
|
||||||
|
sub.onclick=function(){
|
||||||
|
var nv=inp.value.trim();
|
||||||
|
if(!nv){ errEl.textContent='Name darf nicht leer sein'; errEl.style.display=''; return; }
|
||||||
|
sub.disabled=true; sub.textContent='…';
|
||||||
|
api('PATCH','me',{name:nv})
|
||||||
|
.then(function(r){ userName=r.name; closeOv(); render(); showToast('Name gespeichert'); })
|
||||||
|
.catch(function(){ sub.disabled=false; sub.textContent='Speichern'; showToast('Fehler beim Speichern'); });
|
||||||
|
};
|
||||||
|
};
|
||||||
|
c.querySelector('.dm-cpw').onclick=function(){ closeOv(); showChangePassword(); };
|
||||||
|
c.querySelector('.dm-lgout').onclick=function(){
|
||||||
|
api('POST','logout').then(function(){ goals=[]; closeOv(); render(); showLogin(); });
|
||||||
|
};
|
||||||
|
|
||||||
|
c.querySelector('.dm-inv').onclick=function(){
|
||||||
|
var ic=tpl('tpl-invite-form');
|
||||||
|
showSheet(ic,true);
|
||||||
|
var invName=ic.querySelector('.inv-name');
|
||||||
|
setTimeout(function(){invName.focus();},50);
|
||||||
|
ic.querySelector('.inv-cancel').onclick=closeOv;
|
||||||
|
ic.querySelector('.inv-gen').onclick=function(){
|
||||||
|
var note=invName.value.trim(), btn=this;
|
||||||
|
btn.disabled=true; btn.textContent='…';
|
||||||
|
api('POST','invite',{note:note}).then(function(res){
|
||||||
|
var lc=tpl('tpl-invite-link');
|
||||||
|
lc.querySelector('.stitle').textContent='Einladungslink'+(note?' für '+note:'');
|
||||||
|
var urlInp=lc.querySelector('.il-url');
|
||||||
|
urlInp.value=res.url;
|
||||||
|
showSheet(lc,true);
|
||||||
|
lc.querySelector('.il-close').onclick=closeOv;
|
||||||
|
lc.querySelector('.il-copy').onclick=function(){
|
||||||
|
navigator.clipboard.writeText(res.url).then(function(){ showToast('Link kopiert!'); closeOv(); });
|
||||||
|
};
|
||||||
|
setTimeout(function(){urlInp.select();},50);
|
||||||
|
}).catch(function(){
|
||||||
|
btn.disabled=false; btn.textContent='Link generieren';
|
||||||
|
showToast('Fehler beim Generieren');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
c.querySelector('.dm-invlist').onclick=function(){
|
||||||
|
api('GET','invites').then(function(list){
|
||||||
|
var statusLabel={'pending':'Ausstehend','used':'Angenommen','expired':'Abgelaufen'};
|
||||||
|
var statusColor={'pending':'var(--amber)','used':'var(--green)','expired':'var(--red)'};
|
||||||
|
var lc=tpl('tpl-invite-list');
|
||||||
|
var body=lc.querySelector('.dpanel-body');
|
||||||
|
if(!list.length){
|
||||||
|
var empty=document.createElement('div');
|
||||||
|
empty.className='nosets'; empty.style.padding='16px';
|
||||||
|
empty.textContent='Noch keine Einladungen verschickt';
|
||||||
|
body.appendChild(empty);
|
||||||
|
} else {
|
||||||
|
for(var i=0;i<list.length;i++){
|
||||||
|
var inv=list[i];
|
||||||
|
var label=inv.note||new Date(inv.created_at).toLocaleDateString('de-DE',{day:'numeric',month:'short',year:'numeric'});
|
||||||
|
var detail=inv.used_by_email?('→ '+inv.used_by_email):(inv.status==='pending'?'läuft ab: '+new Date(inv.expires_at).toLocaleDateString('de-DE',{day:'numeric',month:'short'}):'');
|
||||||
|
var row=tpl('tpl-invite-row');
|
||||||
|
row.querySelector('.ir-label').textContent=label;
|
||||||
|
if(detail) row.querySelector('.ir-detail').textContent=' '+detail;
|
||||||
|
var st=row.querySelector('.ir-status');
|
||||||
|
st.textContent=statusLabel[inv.status]; st.style.color=statusColor[inv.status];
|
||||||
|
if(inv.url){
|
||||||
|
var cp=row.querySelector('.ir-copy'); cp.style.display='';
|
||||||
|
cp.onclick=function(url){ return function(){ navigator.clipboard.writeText(url).then(function(){ showToast('Link kopiert!'); }); }; }(inv.url);
|
||||||
|
}
|
||||||
|
body.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showSheet(lc,true);
|
||||||
|
lc.querySelector('.il-close').onclick=closeOv;
|
||||||
|
}).catch(function(){ showToast('Fehler beim Laden'); });
|
||||||
|
};
|
||||||
|
|
||||||
|
c.querySelector('.dm-exp').onclick=function(){
|
||||||
|
var blob=new Blob([JSON.stringify({goals:goals,at:new Date().toISOString()},null,2)],{type:'application/json'});
|
||||||
|
var url=URL.createObjectURL(blob), a=document.createElement('a');
|
||||||
|
a.href=url; a.download='dudi-backup.json'; a.click(); URL.revokeObjectURL(url); closeOv();
|
||||||
|
};
|
||||||
|
|
||||||
|
c.querySelector('.dm-imp').onclick=function(){
|
||||||
|
var inp=document.createElement('input'); inp.type='file'; inp.accept='.json';
|
||||||
|
inp.onchange=function(e){
|
||||||
|
var f=e.target.files[0]; if(!f) return;
|
||||||
|
var r=new FileReader(); r.onload=function(ev){
|
||||||
|
try{
|
||||||
|
var p=JSON.parse(ev.target.result);
|
||||||
|
if(!p.goals||!Array.isArray(p.goals)) throw new Error('Ungültiges Format');
|
||||||
|
if(!confirm(p.goals.length+' Ziel(e) importieren?')) return;
|
||||||
|
var promises=p.goals.map(function(g){
|
||||||
|
return api('POST','goals',{name:g.name,unit:g.unit,daily:g.daily,days:g.days,start:g.start,sets:g.sets||{}})
|
||||||
|
.then(function(r){ goals.push({id:r.id,name:r.name,unit:r.unit,daily:r.daily,days:r.days,start:r.start,sets:r.sets||{}}); });
|
||||||
|
});
|
||||||
|
Promise.all(promises).then(function(){ closeOv(); render(); alert(p.goals.length+' Ziel(e) importiert.'); });
|
||||||
|
}catch(err){ alert('Fehler: '+err.message); }
|
||||||
|
}; r.readAsText(f);
|
||||||
|
}; inp.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
c.querySelector('.dm-clr').onclick=function(){
|
||||||
|
if(!confirm('Alle Daten löschen?')) return;
|
||||||
|
var ids=goals.map(function(g){return g.id;}); goals=[]; render();
|
||||||
|
Promise.all(ids.map(function(id){return api('DELETE','goals/'+id);}))
|
||||||
|
.catch(function(){ showToast('Fehler beim Löschen'); });
|
||||||
|
closeOv();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Card-Bausteine ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildNameWrap(g){
|
||||||
|
if(renamingId===g.id){
|
||||||
|
var el=tpl('tpl-name-edit');
|
||||||
|
var inp=el.querySelector('.ren-input');
|
||||||
|
inp.id='ri'+g.id; inp.value=g.name; inp.dataset.g=g.id;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
var el=tpl('tpl-name-view');
|
||||||
|
el.querySelector('.goal-name').textContent=g.name;
|
||||||
|
el.querySelector('.btn-ren').dataset.g=g.id;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPanel(g,off){
|
||||||
|
var t=tOff(g), sets=g.sets[String(off)]||[], tot=dTot(g,off);
|
||||||
|
var lbl=off===t?'Heute':'Gestern', k=g.id+'_'+off;
|
||||||
|
var el=tpl('tpl-panel');
|
||||||
|
el.querySelector('.dpanel-title').textContent=lbl+' — '+fd(o2d(g,off));
|
||||||
|
el.querySelector('.dpanel-sub').textContent=tot+' / '+g.daily+' '+g.unit;
|
||||||
|
var body=el.querySelector('.dpanel-body');
|
||||||
|
if(sets.length){
|
||||||
|
for(var i=0;i<sets.length;i++){
|
||||||
|
var s=sets[i], row=tpl('tpl-set-row'), span=row.querySelector('span');
|
||||||
|
if(s.time!=='—'){
|
||||||
|
var st=document.createElement('span'); st.className='stime'; st.textContent=s.time+' ·';
|
||||||
|
span.appendChild(st); span.appendChild(document.createTextNode(' '));
|
||||||
|
}
|
||||||
|
var strong=document.createElement('strong'); strong.textContent=s.amount;
|
||||||
|
span.appendChild(strong); span.appendChild(document.createTextNode(' '+g.unit));
|
||||||
|
var btn=row.querySelector('.sdel');
|
||||||
|
btn.dataset.g=g.id; btn.dataset.o=off; btn.dataset.i=i;
|
||||||
|
body.appendChild(row);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
body.appendChild(tpl('tpl-nosets'));
|
||||||
|
}
|
||||||
|
var addRow=tpl('tpl-add-row');
|
||||||
|
var inp=addRow.querySelector('.num-in');
|
||||||
|
inp.placeholder=g.daily; inp.value=addAmt[k]||''; inp.dataset.k=k; inp.dataset.g=g.id; inp.dataset.o=off;
|
||||||
|
var abtn=addRow.querySelector('.btn-as');
|
||||||
|
abtn.dataset.g=g.id; abtn.dataset.o=off;
|
||||||
|
addRow.querySelector('.ulbl').textContent=g.unit;
|
||||||
|
body.appendChild(addRow);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCard(g){
|
||||||
|
var c=calc(g), t=c.tOff;
|
||||||
|
var fc=c.surplus>0?'var(--blue)':c.st===0?'var(--green)':c.dailyDelta<=0?'var(--green)':c.dailyDelta<=g.daily*.2?'var(--amber)':'var(--red)';
|
||||||
|
var bc,bt,bufStr=(c.buf>0?'+':'')+c.buf;
|
||||||
|
if(c.ok&&c.surplus>0){bc='b-buf';bt=bufStr;}
|
||||||
|
else if(c.ok){bc='b-done';bt=bufStr;}
|
||||||
|
else if(c.dailyDelta<=0){bc='b-ok';bt=bufStr;}
|
||||||
|
else if(c.dailyDelta<=g.daily*.2){bc='b-warn';bt=bufStr;}
|
||||||
|
else{bc='b-danger';bt=bufStr;}
|
||||||
|
|
||||||
|
var el;
|
||||||
|
if(isCollapsed(g.id)){
|
||||||
|
el=tpl('tpl-card-collapsed');
|
||||||
|
if(c.ok) el.classList.add('done');
|
||||||
|
el.querySelector('.card-hdr').dataset.g=g.id;
|
||||||
|
var bd=el.querySelector('.card-bd');
|
||||||
|
bd.insertBefore(buildNameWrap(g),bd.firstElementChild);
|
||||||
|
var hc=heuteColor(c.tdone,g.daily);
|
||||||
|
el.querySelector('.m-dr').textContent=c.dr;
|
||||||
|
el.querySelector('.m-end').textContent=fs(c.end);
|
||||||
|
var mH=el.querySelector('.m-heute'); mH.textContent=c.tdone+'/'+g.daily; mH.style.color=hc;
|
||||||
|
el.querySelector('.m-total').textContent=c.done+'/'+c.tot;
|
||||||
|
var badge=el.querySelector('.badge'); badge.className='badge '+bc; badge.textContent=bt;
|
||||||
|
var fill=el.querySelector('.prog-fill'); fill.style.width=c.pct+'%'; fill.style.background=fc;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
el=tpl('tpl-card-expanded');
|
||||||
|
if(c.ok) el.classList.add('done');
|
||||||
|
el.querySelector('.card-hdr').dataset.g=g.id;
|
||||||
|
var bd=el.querySelector('.card-bd');
|
||||||
|
bd.insertBefore(buildNameWrap(g),bd.firstElementChild);
|
||||||
|
el.querySelector('.m-dr').textContent=c.dr;
|
||||||
|
el.querySelector('.m-end').textContent=fs(c.end);
|
||||||
|
var badge=el.querySelector('.badge'); badge.className='badge '+bc; badge.textContent=bt;
|
||||||
|
var fill=el.querySelector('.prog-fill'); fill.style.width=c.pct+'%'; fill.style.background=fc;
|
||||||
|
el.querySelector('.pr-done').textContent=c.done+' '+g.unit+' gemacht';
|
||||||
|
el.querySelector('.pr-pct').textContent=c.pct+'% von '+c.tot;
|
||||||
|
el.querySelector('.sv-tdone').textContent=c.tdone;
|
||||||
|
el.querySelector('.sv-daily').textContent=g.daily;
|
||||||
|
el.querySelector('.sv-st').textContent=c.st;
|
||||||
|
el.querySelector('.sv-noch').style.color=heuteColor(c.tdone,g.daily);
|
||||||
|
el.querySelectorAll('.sunit').forEach(function(u){ u.textContent=g.unit; });
|
||||||
|
|
||||||
|
var sel=selDay[g.id]!=null?selDay[g.id]:t;
|
||||||
|
var dotsWrap=el.querySelector('.dots-wrap');
|
||||||
|
for(var i=0;i<g.days;i++){
|
||||||
|
var it=i===t, iy=i===t-1, is=sel===i, ed=editable(g,i);
|
||||||
|
var dot=tpl('tpl-dot');
|
||||||
|
dot.className=dcls(g,i)+(is?' rs':it?' rt':iy&&t>0?' ry':'');
|
||||||
|
if(ed){ dot.dataset.g=g.id; dot.dataset.d=i; }
|
||||||
|
dot.textContent=dlbl(g,i);
|
||||||
|
dotsWrap.appendChild(dot);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(sel!=null) el.insertBefore(buildPanel(g,sel),el.querySelector('.card-foot'));
|
||||||
|
el.querySelector('.btn-del').dataset.g=g.id;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Quick-Buchen ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildQuickBook(){
|
||||||
|
var active=goals.filter(function(g){ var c=calc(g); return tOff(g)<g.days&&!c.ok; });
|
||||||
|
if(!active.length) return null;
|
||||||
|
var frag=document.createDocumentFragment();
|
||||||
|
var lbl=document.createElement('div'); lbl.className='sec-lbl'; lbl.textContent='Quick-Buchen';
|
||||||
|
frag.appendChild(lbl);
|
||||||
|
var card=document.createElement('div'); card.className='card qb-card';
|
||||||
|
for(var i=0;i<active.length;i++){
|
||||||
|
var g=active[i], c=calc(g), k=g.id+'_'+c.tOff;
|
||||||
|
var row=tpl('tpl-qb-row');
|
||||||
|
row.querySelector('.qb-name').textContent=g.name;
|
||||||
|
var stat=row.querySelector('.qb-stat'); stat.textContent=c.tdone+'/'+g.daily; stat.style.color=heuteColor(c.tdone,g.daily);
|
||||||
|
var inp=row.querySelector('.num-in');
|
||||||
|
inp.placeholder=g.daily; inp.value=addAmt[k]||''; inp.dataset.k=k; inp.dataset.g=g.id; inp.dataset.o=c.tOff;
|
||||||
|
var btn=row.querySelector('.btn-as'); btn.dataset.g=g.id; btn.dataset.o=c.tOff;
|
||||||
|
card.appendChild(row);
|
||||||
|
}
|
||||||
|
frag.appendChild(card);
|
||||||
|
return frag;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function calcAwards(){
|
||||||
|
var units=0;
|
||||||
|
for(var i=0;i<goals.length;i++){
|
||||||
|
var g=goals[i];
|
||||||
|
if(tOff(g)>=g.days) units+=Math.floor(g.days/30);
|
||||||
|
}
|
||||||
|
var gold=Math.floor(units/25); units%=25;
|
||||||
|
var silver=Math.floor(units/5); var bronze=units%5;
|
||||||
|
return{gold:gold,silver:silver,bronze:bronze};
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(){
|
||||||
|
var m=document.getElementById('main');
|
||||||
|
var frag=document.createDocumentFragment();
|
||||||
|
|
||||||
|
if(!prefs.hd){
|
||||||
|
var hint=tpl('tpl-hint');
|
||||||
|
hint.querySelector('.hclose').onclick=function(){ prefs.hd=1; saveP(); hint.remove(); };
|
||||||
|
frag.appendChild(hint);
|
||||||
|
}
|
||||||
|
|
||||||
|
var aw=calcAwards();
|
||||||
|
if(aw.gold||aw.silver||aw.bronze){
|
||||||
|
var awards=document.createElement('div'); awards.className='awards';
|
||||||
|
var medals=[['🥇',aw.gold],['🥈',aw.silver],['🥉',aw.bronze]];
|
||||||
|
for(var mi=0;mi<medals.length;mi++){
|
||||||
|
for(var ai=0;ai<medals[mi][1];ai++){
|
||||||
|
var sp=document.createElement('span'); sp.className='aw'; sp.textContent=medals[mi][0];
|
||||||
|
awards.appendChild(sp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frag.appendChild(awards);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!goals.length){
|
||||||
|
frag.appendChild(tpl('tpl-empty'));
|
||||||
|
m.innerHTML=''; m.appendChild(frag); wire(); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(userName){
|
||||||
|
var gr=document.createElement('div'); gr.className='greeting'; gr.textContent='Hallo '+userName+'!';
|
||||||
|
frag.appendChild(gr);
|
||||||
|
}
|
||||||
|
|
||||||
|
var qb=buildQuickBook(); if(qb) frag.appendChild(qb);
|
||||||
|
|
||||||
|
var open=[],done=[];
|
||||||
|
for(var gi=0;gi<goals.length;gi++){
|
||||||
|
var g=goals[gi], c=calc(g);
|
||||||
|
if(c.ok) done.push(g); else open.push(g);
|
||||||
|
}
|
||||||
|
if(open.length){
|
||||||
|
var sl=document.createElement('div'); sl.className='sec-lbl'; sl.textContent='Offen';
|
||||||
|
frag.appendChild(sl);
|
||||||
|
for(var i=0;i<open.length;i++) frag.appendChild(buildCard(open[i]));
|
||||||
|
}
|
||||||
|
if(done.length){
|
||||||
|
var sl2=document.createElement('div'); sl2.className='sec-lbl'; sl2.textContent='Heute erledigt';
|
||||||
|
frag.appendChild(sl2);
|
||||||
|
for(var j=0;j<done.length;j++) frag.appendChild(buildCard(done[j]));
|
||||||
|
}
|
||||||
|
|
||||||
|
m.innerHTML=''; m.appendChild(frag); wire();
|
||||||
|
}
|
||||||
|
|
||||||
|
function wire(){
|
||||||
|
document.querySelectorAll('.card-hdr[data-g]').forEach(function(el){
|
||||||
|
el.onclick=function(e){
|
||||||
|
if(e.target.classList.contains('btn-ren')||e.target.classList.contains('ren-input')) return;
|
||||||
|
toggleCollapse(this.dataset.g);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.btn-ren').forEach(function(b){
|
||||||
|
b.onclick=function(e){e.stopPropagation();startRen(this.dataset.g);};
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.ren-input').forEach(function(inp){
|
||||||
|
var gid=inp.dataset.g;
|
||||||
|
inp.oninput=function(){renameVal=this.value;};
|
||||||
|
inp.onkeydown=function(e){if(e.key==='Enter')commitRen(gid);if(e.key==='Escape')cancelRen();};
|
||||||
|
inp.onblur=function(){commitRen(gid);};
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.de').forEach(function(d){
|
||||||
|
d.onclick=function(e){e.stopPropagation();selD(this.dataset.g,parseInt(this.dataset.d,10));};
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.btn-as').forEach(function(b){
|
||||||
|
b.onclick=function(){addSet(this.dataset.g,parseInt(this.dataset.o,10));};
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.num-in').forEach(function(inp){
|
||||||
|
var k=inp.dataset.k, g=inp.dataset.g, o=parseInt(inp.dataset.o,10);
|
||||||
|
inp.oninput=function(){addAmt[k]=this.value;};
|
||||||
|
inp.onkeydown=function(e){if(e.key==='Enter')addSet(g,o);};
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.sdel').forEach(function(b){
|
||||||
|
b.onclick=function(){remSet(this.dataset.g,parseInt(this.dataset.o,10),parseInt(this.dataset.i,10));};
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.btn-del').forEach(function(b){
|
||||||
|
b.onclick=function(){delGoal(this.dataset.g);};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function updateHeader(){
|
||||||
|
document.getElementById('tlbl').textContent=TODAY.toLocaleDateString('de-DE',{weekday:'long',day:'numeric',month:'long'});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btnNew').onclick=openNew;
|
||||||
|
document.getElementById('btnData').onclick=openData;
|
||||||
|
updateHeader();
|
||||||
|
|
||||||
|
var _qs=new URLSearchParams(window.location.search);
|
||||||
|
var inviteToken=_qs.get('invite');
|
||||||
|
var resetSelector=_qs.get('reset_selector');
|
||||||
|
var resetToken=_qs.get('reset_token');
|
||||||
|
if(inviteToken||resetSelector) history.replaceState(null,'',location.pathname);
|
||||||
|
|
||||||
|
if(resetSelector&&resetToken){
|
||||||
|
render(); showResetPassword(resetSelector,resetToken);
|
||||||
|
} else {
|
||||||
|
api('GET','me')
|
||||||
|
.then(function(r){ userName=r.name||''; updateHeader(); return loadGoals(); })
|
||||||
|
.then(function(g){ goals=g; render(); })
|
||||||
|
.catch(function(){
|
||||||
|
render();
|
||||||
|
if(inviteToken){ showRegister(inviteToken); }
|
||||||
|
else { showLogin(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleMidnight(){
|
||||||
|
var n=new Date();
|
||||||
|
var ms=new Date(n.getFullYear(),n.getMonth(),n.getDate()+1,0,0,5).getTime()-n.getTime();
|
||||||
|
setTimeout(function(){
|
||||||
|
TODAY=new Date();TODAY.setHours(0,0,0,0);selDay={};collapsed={};
|
||||||
|
updateHeader();render();scheduleMidnight();
|
||||||
|
},ms);
|
||||||
|
}
|
||||||
|
scheduleMidnight();
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange',function(){
|
||||||
|
if(document.visibilityState==='visible'){
|
||||||
|
var n=new Date();n.setHours(0,0,0,0);
|
||||||
|
if(n.getTime()!==TODAY.getTime()){TODAY=n;selDay={};collapsed={};render();scheduleMidnight();}
|
||||||
|
loadGoals().then(function(g){goals=g;render();}).catch(function(){});
|
||||||
|
}
|
||||||
|
});
|
||||||
21
bin/console
Executable file
21
bin/console
Executable file
|
|
@ -0,0 +1,21 @@
|
||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Kernel;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||||
|
|
||||||
|
if (!is_dir(dirname(__DIR__).'/vendor')) {
|
||||||
|
throw new LogicException('Dependencies are missing. Try running "composer install".');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
|
||||||
|
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||||
|
|
||||||
|
return function (array $context) {
|
||||||
|
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||||
|
|
||||||
|
return new Application($kernel);
|
||||||
|
};
|
||||||
77
composer.json
Normal file
77
composer.json
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
{
|
||||||
|
"type": "project",
|
||||||
|
"license": "proprietary",
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"prefer-stable": true,
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.4",
|
||||||
|
"ext-ctype": "*",
|
||||||
|
"doctrine/doctrine-bundle": "^3.2",
|
||||||
|
"doctrine/doctrine-migrations-bundle": "^4.0",
|
||||||
|
"doctrine/orm": "^3.6",
|
||||||
|
"symfony/console": "8.0.*",
|
||||||
|
"symfony/doctrine-messenger": "8.0.*",
|
||||||
|
"symfony/dotenv": "8.0.*",
|
||||||
|
"symfony/flex": "^2",
|
||||||
|
"symfony/framework-bundle": "8.0.*",
|
||||||
|
"symfony/mailer": "8.0.*",
|
||||||
|
"symfony/password-hasher": "8.0.*",
|
||||||
|
"symfony/runtime": "8.0.*",
|
||||||
|
"symfony/security-bundle": "8.0.*",
|
||||||
|
"symfony/twig-bundle": "8.0.*",
|
||||||
|
"symfony/validator": "8.0.*",
|
||||||
|
"symfony/yaml": "8.0.*"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"allow-plugins": {
|
||||||
|
"php-http/discovery": true,
|
||||||
|
"symfony/flex": true,
|
||||||
|
"symfony/runtime": true
|
||||||
|
},
|
||||||
|
"optimize-autoloader": true,
|
||||||
|
"sort-packages": true
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"replace": {
|
||||||
|
"symfony/polyfill-ctype": "*",
|
||||||
|
"symfony/polyfill-iconv": "*",
|
||||||
|
"symfony/polyfill-php72": "*",
|
||||||
|
"symfony/polyfill-php73": "*",
|
||||||
|
"symfony/polyfill-php74": "*",
|
||||||
|
"symfony/polyfill-php80": "*",
|
||||||
|
"symfony/polyfill-php81": "*",
|
||||||
|
"symfony/polyfill-php82": "*",
|
||||||
|
"symfony/polyfill-php83": "*",
|
||||||
|
"symfony/polyfill-php84": "*"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"auto-scripts": {
|
||||||
|
"cache:clear": "symfony-cmd",
|
||||||
|
"assets:install %PUBLIC_DIR%": "symfony-cmd"
|
||||||
|
},
|
||||||
|
"post-install-cmd": [
|
||||||
|
"@auto-scripts"
|
||||||
|
],
|
||||||
|
"post-update-cmd": [
|
||||||
|
"@auto-scripts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"symfony/symfony": "*"
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"symfony": {
|
||||||
|
"allow-contrib": false,
|
||||||
|
"require": "8.0.*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5486
composer.lock
generated
Normal file
5486
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
9
config/bundles.php
Normal file
9
config/bundles.php
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
||||||
|
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
|
||||||
|
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
|
||||||
|
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
|
||||||
|
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
||||||
|
];
|
||||||
19
config/packages/cache.yaml
Normal file
19
config/packages/cache.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
framework:
|
||||||
|
cache:
|
||||||
|
# Unique name of your app: used to compute stable namespaces for cache keys.
|
||||||
|
#prefix_seed: your_vendor_name/app_name
|
||||||
|
|
||||||
|
# The "app" cache stores to the filesystem by default.
|
||||||
|
# The data in this cache should persist between deploys.
|
||||||
|
# Other options include:
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
#app: cache.adapter.redis
|
||||||
|
#default_redis_provider: redis://localhost
|
||||||
|
|
||||||
|
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
|
||||||
|
#app: cache.adapter.apcu
|
||||||
|
|
||||||
|
# Namespaced pools use the above "app" backend by default
|
||||||
|
#pools:
|
||||||
|
#my.dedicated.cache: null
|
||||||
44
config/packages/doctrine.yaml
Normal file
44
config/packages/doctrine.yaml
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
doctrine:
|
||||||
|
dbal:
|
||||||
|
url: '%env(resolve:DATABASE_URL)%'
|
||||||
|
|
||||||
|
# IMPORTANT: You MUST configure your server version,
|
||||||
|
# either here or in the DATABASE_URL env var (see .env file)
|
||||||
|
#server_version: '16'
|
||||||
|
|
||||||
|
profiling_collect_backtrace: '%kernel.debug%'
|
||||||
|
orm:
|
||||||
|
validate_xml_mapping: true
|
||||||
|
naming_strategy: doctrine.orm.naming_strategy.underscore
|
||||||
|
auto_mapping: true
|
||||||
|
mappings:
|
||||||
|
App:
|
||||||
|
type: attribute
|
||||||
|
is_bundle: false
|
||||||
|
dir: '%kernel.project_dir%/src/Entity'
|
||||||
|
prefix: 'App\Entity'
|
||||||
|
alias: App
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
doctrine:
|
||||||
|
dbal:
|
||||||
|
# "TEST_TOKEN" is typically set by ParaTest
|
||||||
|
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||||
|
|
||||||
|
when@prod:
|
||||||
|
doctrine:
|
||||||
|
orm:
|
||||||
|
query_cache_driver:
|
||||||
|
type: pool
|
||||||
|
pool: doctrine.system_cache_pool
|
||||||
|
result_cache_driver:
|
||||||
|
type: pool
|
||||||
|
pool: doctrine.result_cache_pool
|
||||||
|
|
||||||
|
framework:
|
||||||
|
cache:
|
||||||
|
pools:
|
||||||
|
doctrine.result_cache_pool:
|
||||||
|
adapter: cache.app
|
||||||
|
doctrine.system_cache_pool:
|
||||||
|
adapter: cache.system
|
||||||
6
config/packages/doctrine_migrations.yaml
Normal file
6
config/packages/doctrine_migrations.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
doctrine_migrations:
|
||||||
|
migrations_paths:
|
||||||
|
# namespace is arbitrary but should be different from App\Migrations
|
||||||
|
# as migrations classes should NOT be autoloaded
|
||||||
|
'DoctrineMigrations': '%kernel.project_dir%/migrations'
|
||||||
|
enable_profiler: false
|
||||||
15
config/packages/framework.yaml
Normal file
15
config/packages/framework.yaml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
||||||
|
framework:
|
||||||
|
secret: '%env(APP_SECRET)%'
|
||||||
|
|
||||||
|
# Note that the session will be started ONLY if you read or write from it.
|
||||||
|
session: true
|
||||||
|
|
||||||
|
#esi: true
|
||||||
|
#fragments: true
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
framework:
|
||||||
|
test: true
|
||||||
|
session:
|
||||||
|
storage_factory_id: session.storage.factory.mock_file
|
||||||
3
config/packages/mailer.yaml
Normal file
3
config/packages/mailer.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
framework:
|
||||||
|
mailer:
|
||||||
|
dsn: '%env(MAILER_DSN)%'
|
||||||
22
config/packages/messenger.yaml
Normal file
22
config/packages/messenger.yaml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
framework:
|
||||||
|
messenger:
|
||||||
|
# Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
|
||||||
|
# failure_transport: failed
|
||||||
|
|
||||||
|
transports:
|
||||||
|
# https://symfony.com/doc/current/messenger.html#transport-configuration
|
||||||
|
# async: '%env(MESSENGER_TRANSPORT_DSN)%'
|
||||||
|
# failed: 'doctrine://default?queue_name=failed'
|
||||||
|
sync: 'sync://'
|
||||||
|
|
||||||
|
routing:
|
||||||
|
# Route your messages to the transports
|
||||||
|
# 'App\Message\YourMessage': async
|
||||||
|
|
||||||
|
# when@test:
|
||||||
|
# framework:
|
||||||
|
# messenger:
|
||||||
|
# transports:
|
||||||
|
# # replace with your transport name here (e.g., my_transport: 'in-memory://')
|
||||||
|
# # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test
|
||||||
|
# async: 'in-memory://'
|
||||||
3
config/packages/property_info.yaml
Normal file
3
config/packages/property_info.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
framework:
|
||||||
|
property_info:
|
||||||
|
with_constructor_extractor: true
|
||||||
10
config/packages/routing.yaml
Normal file
10
config/packages/routing.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
framework:
|
||||||
|
router:
|
||||||
|
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
||||||
|
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
||||||
|
default_uri: '%env(DEFAULT_URI)%'
|
||||||
|
|
||||||
|
when@prod:
|
||||||
|
framework:
|
||||||
|
router:
|
||||||
|
strict_requirements: null
|
||||||
45
config/packages/security.yaml
Normal file
45
config/packages/security.yaml
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
security:
|
||||||
|
password_hashers:
|
||||||
|
App\Entity\User:
|
||||||
|
algorithm: bcrypt
|
||||||
|
|
||||||
|
providers:
|
||||||
|
app_user_provider:
|
||||||
|
entity:
|
||||||
|
class: App\Entity\User
|
||||||
|
property: email
|
||||||
|
|
||||||
|
firewalls:
|
||||||
|
dev:
|
||||||
|
pattern: ^/(_profiler|_wdt|assets|build)/
|
||||||
|
security: false
|
||||||
|
main:
|
||||||
|
lazy: true
|
||||||
|
provider: app_user_provider
|
||||||
|
custom_authenticators:
|
||||||
|
- App\Security\JsonLoginAuthenticator
|
||||||
|
logout:
|
||||||
|
path: app_auth_logout
|
||||||
|
invalidate_session: true
|
||||||
|
remember_me:
|
||||||
|
secret: '%kernel.secret%'
|
||||||
|
lifetime: 86400
|
||||||
|
token_provider:
|
||||||
|
doctrine: true
|
||||||
|
|
||||||
|
access_control:
|
||||||
|
- { path: ^/api/login, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/api/logout, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/api/register, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/api/reset-request, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/api/reset-password, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/api/me, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/api, roles: ROLE_USER }
|
||||||
|
- { path: ^/, roles: PUBLIC_ACCESS }
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
security:
|
||||||
|
password_hashers:
|
||||||
|
App\Entity\User:
|
||||||
|
algorithm: auto
|
||||||
|
cost: 4
|
||||||
6
config/packages/twig.yaml
Normal file
6
config/packages/twig.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
twig:
|
||||||
|
file_name_pattern: '*.twig'
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
twig:
|
||||||
|
strict_variables: true
|
||||||
11
config/packages/validator.yaml
Normal file
11
config/packages/validator.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
framework:
|
||||||
|
validation:
|
||||||
|
# Enables validator auto-mapping support.
|
||||||
|
# For instance, basic validation constraints will be inferred from Doctrine's metadata.
|
||||||
|
#auto_mapping:
|
||||||
|
# App\Entity\: []
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
framework:
|
||||||
|
validation:
|
||||||
|
not_compromised_password: false
|
||||||
5
config/preload.php
Normal file
5
config/preload.php
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
|
||||||
|
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
|
||||||
|
}
|
||||||
1372
config/reference.php
Normal file
1372
config/reference.php
Normal file
File diff suppressed because it is too large
Load diff
11
config/routes.yaml
Normal file
11
config/routes.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# yaml-language-server: $schema=../vendor/symfony/routing/Loader/schema/routing.schema.json
|
||||||
|
|
||||||
|
# This file is the entry point to configure the routes of your app.
|
||||||
|
# Methods with the #[Route] attribute are automatically imported.
|
||||||
|
# See also https://symfony.com/doc/current/routing.html
|
||||||
|
|
||||||
|
# To list all registered routes, run the following command:
|
||||||
|
# bin/console debug:router
|
||||||
|
|
||||||
|
controllers:
|
||||||
|
resource: routing.controllers
|
||||||
4
config/routes/framework.yaml
Normal file
4
config/routes/framework.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
when@dev:
|
||||||
|
_errors:
|
||||||
|
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
|
||||||
|
prefix: /_error
|
||||||
3
config/routes/security.yaml
Normal file
3
config/routes/security.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
_security_logout:
|
||||||
|
resource: security.route_loader.logout
|
||||||
|
type: service
|
||||||
23
config/services.yaml
Normal file
23
config/services.yaml
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
|
||||||
|
|
||||||
|
# This file is the entry point to configure your own services.
|
||||||
|
# Files in the packages/ subdirectory configure your dependencies.
|
||||||
|
# See also https://symfony.com/doc/current/service_container/import.html
|
||||||
|
|
||||||
|
# Put parameters here that don't need to change on each machine where the app is deployed
|
||||||
|
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||||
|
parameters:
|
||||||
|
|
||||||
|
services:
|
||||||
|
# default configuration for services in *this* file
|
||||||
|
_defaults:
|
||||||
|
autowire: true # Automatically injects dependencies in your services.
|
||||||
|
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
|
||||||
|
|
||||||
|
# makes classes in src/ available to be used as services
|
||||||
|
# this creates a service per class whose id is the fully-qualified class name
|
||||||
|
App\:
|
||||||
|
resource: '../src/'
|
||||||
|
|
||||||
|
# add more service definitions when explicit configuration is needed
|
||||||
|
# please note that last definitions always *replace* previous ones
|
||||||
29
deploy.sh
Executable file
29
deploy.sh
Executable file
|
|
@ -0,0 +1,29 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
REMOTE="root@miniweb.kuehn.home"
|
||||||
|
REMOTE_DIR="/var/www/tracker"
|
||||||
|
SSH="ssh -p 30183"
|
||||||
|
|
||||||
|
rsync -av \
|
||||||
|
-e "$SSH" \
|
||||||
|
--exclude='.claude/' \
|
||||||
|
--exclude='.superpowers/' \
|
||||||
|
--exclude='.aider*' \
|
||||||
|
--exclude='vendor/' \
|
||||||
|
--exclude='var/' \
|
||||||
|
--exclude='docs/' \
|
||||||
|
--exclude='.env.local' \
|
||||||
|
--exclude='deploy.sh' \
|
||||||
|
--exclude='include/' \
|
||||||
|
--exclude='api.php' \
|
||||||
|
--exclude='index.php' \
|
||||||
|
/srv/http/zieltracker/ $REMOTE:$REMOTE_DIR/
|
||||||
|
|
||||||
|
# composer install auf dem Server
|
||||||
|
$SSH $REMOTE "cd $REMOTE_DIR && composer install --no-dev --no-interaction --optimize-autoloader 2>&1 | tail -5"
|
||||||
|
|
||||||
|
# Cache leeren
|
||||||
|
$SSH $REMOTE "cd $REMOTE_DIR && php bin/console cache:clear --env=prod --no-warmup 2>&1"
|
||||||
|
|
||||||
|
echo "Deploy fertig."
|
||||||
102
docs/api.md
Normal file
102
docs/api.md
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
# API Reference
|
||||||
|
|
||||||
|
All endpoints are under `/api/`. JSON in, JSON out. Auth via session cookie + remember-me.
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
### `POST /api/login`
|
||||||
|
```json
|
||||||
|
{ "email": "...", "password": "..." }
|
||||||
|
```
|
||||||
|
Returns `{ ok, email, name }` or `401`.
|
||||||
|
|
||||||
|
### `POST /api/logout`
|
||||||
|
Invalidates session. Returns `{ ok: true }`.
|
||||||
|
|
||||||
|
### `GET /api/me`
|
||||||
|
Returns `{ ok, email, id, name }` or `{ ok: false }` (401) if not logged in.
|
||||||
|
|
||||||
|
### `PATCH /api/me`
|
||||||
|
```json
|
||||||
|
{ "name": "Max" }
|
||||||
|
```
|
||||||
|
Updates display name. Returns `{ ok, name }`.
|
||||||
|
|
||||||
|
### `POST /api/register`
|
||||||
|
```json
|
||||||
|
{ "email": "...", "password": "...", "token": "<invite-token>", "name": "..." }
|
||||||
|
```
|
||||||
|
Requires a valid pending invite token. Registers + auto-logs in. Returns `{ ok, email, name }`.
|
||||||
|
|
||||||
|
### `POST /api/reset-request`
|
||||||
|
```json
|
||||||
|
{ "email": "..." }
|
||||||
|
```
|
||||||
|
Sends password reset mail. Always returns `{ ok: true }` (no email enumeration).
|
||||||
|
|
||||||
|
### `POST /api/reset-password`
|
||||||
|
```json
|
||||||
|
{ "selector": "...", "token": "...", "password": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /api/change-password`
|
||||||
|
```json
|
||||||
|
{ "old_password": "...", "new_password": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
### `GET /api/goals`
|
||||||
|
Returns array of goal objects for the authenticated user.
|
||||||
|
|
||||||
|
```json
|
||||||
|
[{
|
||||||
|
"id": "1",
|
||||||
|
"name": "Liegestütz",
|
||||||
|
"unit": "Stück",
|
||||||
|
"daily": 50,
|
||||||
|
"days": 30,
|
||||||
|
"start": "2026-04-01 00:00:00",
|
||||||
|
"sets": { "2026-04-01": [20, 30], "2026-04-02": [50] }
|
||||||
|
}]
|
||||||
|
```
|
||||||
|
|
||||||
|
`sets` is a JSON object keyed by date (`YYYY-MM-DD`), values are arrays of logged amounts.
|
||||||
|
|
||||||
|
### `POST /api/goals`
|
||||||
|
```json
|
||||||
|
{ "name": "...", "unit": "Stück", "daily": 50, "days": 30, "start": "2026-04-01" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `PATCH /api/goals/{id}`
|
||||||
|
Partial update — send only fields to change: `name`, `unit`, `daily`, `days`, `sets`.
|
||||||
|
|
||||||
|
### `DELETE /api/goals/{id}`
|
||||||
|
Deletes goal. Only owner can delete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Invites
|
||||||
|
|
||||||
|
### `POST /api/invite`
|
||||||
|
```json
|
||||||
|
{ "note": "Max" }
|
||||||
|
```
|
||||||
|
Creates a 7-day single-use invite token. Returns `{ url: "http://dudi.local/?invite=<token>" }`.
|
||||||
|
|
||||||
|
### `GET /api/invites`
|
||||||
|
Returns all invites created by the authenticated user.
|
||||||
|
|
||||||
|
```json
|
||||||
|
[{
|
||||||
|
"url": "http://dudi.local/?invite=...", // null if used or expired
|
||||||
|
"note": "Max",
|
||||||
|
"status": "pending", // "pending" | "used" | "expired"
|
||||||
|
"created_at": "...",
|
||||||
|
"expires_at": "...",
|
||||||
|
"used_at": null,
|
||||||
|
"used_by_email": null
|
||||||
|
}]
|
||||||
|
```
|
||||||
69
docs/deployment.md
Normal file
69
docs/deployment.md
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
# Deployment
|
||||||
|
|
||||||
|
## Server: miniweb.kuehn.home
|
||||||
|
|
||||||
|
- SSH: `ssh -p 30183 root@miniweb.kuehn.home`
|
||||||
|
- App dir: `/var/www/tracker/`
|
||||||
|
- Apache DocumentRoot: `/var/www/tracker/public/`
|
||||||
|
- Database: MariaDB, DB name `dd`, user `dd`
|
||||||
|
- URL: `http://miniweb.kuehn.home/dd/`
|
||||||
|
|
||||||
|
## First-time Setup (already done)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/tracker
|
||||||
|
composer install --no-dev --optimize-autoloader
|
||||||
|
# create .env.local with real credentials (see below)
|
||||||
|
php bin/console cache:clear --env=prod
|
||||||
|
```
|
||||||
|
|
||||||
|
### `.env.local` on server
|
||||||
|
```
|
||||||
|
APP_ENV=prod
|
||||||
|
APP_SECRET=<random 32 hex chars>
|
||||||
|
DATABASE_URL="mysql://dd:<password>@127.0.0.1:3306/dd?serverVersion=mariadb-8.3.0&charset=utf8mb4"
|
||||||
|
APP_URL=http://miniweb.kuehn.home/dd
|
||||||
|
DEFAULT_URI=http://miniweb.kuehn.home/dd
|
||||||
|
MAILER_DSN=smtp://user:pass@smtp.example.com:587
|
||||||
|
MAILER_FROM=noreply@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Apache VirtualHost (or adjust existing)
|
||||||
|
```apache
|
||||||
|
<VirtualHost *:80>
|
||||||
|
ServerName miniweb.kuehn.home
|
||||||
|
DocumentRoot /var/www/tracker/public
|
||||||
|
<Directory /var/www/tracker/public>
|
||||||
|
AllowOverride All
|
||||||
|
Require all granted
|
||||||
|
</Directory>
|
||||||
|
</VirtualHost>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database remember-me table
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS rememberme_token (
|
||||||
|
series VARCHAR(88) NOT NULL,
|
||||||
|
value VARCHAR(88) NOT NULL,
|
||||||
|
lastUsed DATETIME NOT NULL,
|
||||||
|
class VARCHAR(100) DEFAULT '' NOT NULL,
|
||||||
|
username VARCHAR(200) NOT NULL,
|
||||||
|
PRIMARY KEY (series)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploying Updates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This rsync's the project (excluding `vendor/`, `var/`, `.env.local`), runs `composer install --no-dev` and `cache:clear --env=prod` remotely.
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
- URL: `http://dudi.local/`
|
||||||
|
- Apache VirtualHost with DocumentRoot `/srv/http/zieltracker/public`
|
||||||
|
- `/etc/hosts`: `127.0.0.1 dudi.local`
|
||||||
|
- DB: MariaDB, db `zieltracker`, user `zieltracker`
|
||||||
|
- `APP_ENV=dev` (Symfony debug bar active)
|
||||||
66
docs/structure.md
Normal file
66
docs/structure.md
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
# Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
dudi/
|
||||||
|
├── bin/
|
||||||
|
│ └── console # Symfony CLI
|
||||||
|
├── config/
|
||||||
|
│ ├── packages/
|
||||||
|
│ │ ├── doctrine.yaml # ORM + DBAL config
|
||||||
|
│ │ ├── security.yaml # Firewall, providers, access control
|
||||||
|
│ │ ├── mailer.yaml
|
||||||
|
│ │ └── ...
|
||||||
|
│ └── routes.yaml # Auto-discovers #[Route] attributes
|
||||||
|
├── migrations/ # Legacy SQL migrations (pre-Symfony)
|
||||||
|
├── public/ # Apache DocumentRoot
|
||||||
|
│ ├── index.php # Symfony kernel entry point
|
||||||
|
│ ├── .htaccess # RewriteRule → index.php
|
||||||
|
│ ├── app.js # Frontend SPA (vanilla JS)
|
||||||
|
│ ├── style.css
|
||||||
|
│ └── logo.png
|
||||||
|
├── src/
|
||||||
|
│ ├── Controller/
|
||||||
|
│ │ ├── AppController.php # Catch-all → renders app.html.twig
|
||||||
|
│ │ ├── AuthController.php # /api/me, /api/register, /api/reset-*, /api/change-password
|
||||||
|
│ │ ├── GoalController.php # /api/goals CRUD
|
||||||
|
│ │ └── InviteController.php # /api/invite, /api/invites
|
||||||
|
│ ├── Entity/
|
||||||
|
│ │ ├── User.php # Maps to users table (delight-im/auth compatible schema)
|
||||||
|
│ │ ├── Goal.php
|
||||||
|
│ │ └── Invite.php
|
||||||
|
│ ├── Repository/
|
||||||
|
│ │ ├── UserRepository.php # implements PasswordUpgraderInterface
|
||||||
|
│ │ ├── GoalRepository.php
|
||||||
|
│ │ └── InviteRepository.php
|
||||||
|
│ ├── Security/
|
||||||
|
│ │ └── JsonLoginAuthenticator.php # Handles POST /api/login with JSON body
|
||||||
|
│ └── Kernel.php
|
||||||
|
├── templates/
|
||||||
|
│ └── app.html.twig # Full SPA shell + all <template> elements
|
||||||
|
├── .env # Generic defaults (committed)
|
||||||
|
├── .env.local # Local credentials (gitignored)
|
||||||
|
├── deploy.sh # rsync + remote composer install + cache:clear
|
||||||
|
└── symfony.lock
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Architecture
|
||||||
|
|
||||||
|
The frontend is a single-page app with no build step:
|
||||||
|
|
||||||
|
- `public/app.js` — all logic, ~700 lines vanilla JS
|
||||||
|
- `templates/app.html.twig` — rendered once server-side; contains all `<template id="tpl-*">` elements
|
||||||
|
- JS clones templates with `tpl(id)` helper and fills in dynamic values via `querySelector`
|
||||||
|
- No framework, no bundler, no npm
|
||||||
|
|
||||||
|
## Database Tables
|
||||||
|
|
||||||
|
| Table | Managed by | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `goals` | Doctrine ORM | User goals with JSON `sets` column |
|
||||||
|
| `invites` | Doctrine ORM | Invite tokens |
|
||||||
|
| `users` | Doctrine ORM | User accounts (bcrypt passwords) |
|
||||||
|
| `rememberme_token` | Symfony Security | 24h remember-me tokens |
|
||||||
|
| `users_resets` | Raw SQL in AuthController | Password reset tokens |
|
||||||
|
| `users_throttling` | Legacy (delight-im/auth) | Can be removed |
|
||||||
|
| `users_confirmations` | Legacy (delight-im/auth) | Can be removed |
|
||||||
|
| `schema_migrations` | Legacy | Can be removed |
|
||||||
562
docs/superpowers/plans/2026-04-17-refactor-collapsible.md
Normal file
562
docs/superpowers/plans/2026-04-17-refactor-collapsible.md
Normal file
|
|
@ -0,0 +1,562 @@
|
||||||
|
# Zieltracker Refactor & Collapsible Cards Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Split index.html into three files, add collapsible cards with compact meta-line, sort by daily completion status, replace 4-block stats with a "Heute" group, and constrain desktop width to 480px.
|
||||||
|
|
||||||
|
**Architecture:** Pure HTML/CSS/JS — no build step. Logic stays in `app.js`, styles in `style.css`, HTML skeleton in `index.html`. All changes are in-place; localStorage data format is unchanged.
|
||||||
|
|
||||||
|
**Tech Stack:** Vanilla JS (ES5), CSS custom properties, localStorage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| File | Action | Responsibility |
|
||||||
|
|------|--------|---------------|
|
||||||
|
| `index.html` | Modify | HTML skeleton only — links to style.css + app.js |
|
||||||
|
| `style.css` | Create | All styles including new classes |
|
||||||
|
| `app.js` | Create | All logic including new collapse/sort/render |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Create style.css
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `style.css`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create style.css** — extract the full `<style>` block from index.html (lines 12–103) and add new classes at the bottom:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=DM+Mono:wght@400;500&display=swap');
|
||||||
|
:root{--bg:#f5f4f0;--bg2:#fff;--bg3:#f0eeea;--border:rgba(0,0,0,.07);--border2:rgba(0,0,0,.12);--text:#1a1a1a;--text2:#666;--text3:#aaa;--green:#16a34a;--green-bg:rgba(22,163,74,.08);--blue:#2563eb;--blue-bg:rgba(37,99,235,.08);--amber:#d97706;--amber-bg:rgba(217,119,6,.08);--red:#dc2626;--red-bg:rgba(220,38,38,.08);--r:14px;--rs:8px}
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
|
||||||
|
body{font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--text);min-height:100dvh;padding-bottom:80px}
|
||||||
|
.main-wrap{max-width:480px;margin:0 auto}
|
||||||
|
.hdr{position:sticky;top:0;z-index:100;background:rgba(245,244,240,.92);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:16px 20px 14px;display:flex;align-items:center;justify-content:space-between}
|
||||||
|
.hdr-title{font-size:18px;font-weight:600;letter-spacing:-.3px}
|
||||||
|
.hdr-sub{font-size:12px;color:var(--text3);margin-top:1px;font-family:'DM Mono',monospace}
|
||||||
|
.hdr-btns{display:flex;gap:8px;align-items:center}
|
||||||
|
.btn-add{width:38px;height:38px;border-radius:50%;background:var(--text);color:var(--bg);border:none;cursor:pointer;font-size:22px;display:flex;align-items:center;justify-content:center;font-weight:300;transition:transform .15s}
|
||||||
|
.btn-add:active{transform:scale(.92)}
|
||||||
|
.btn-menu{width:38px;height:38px;border-radius:50%;background:var(--bg3);color:var(--text2);border:1px solid var(--border);cursor:pointer;font-size:20px;display:flex;align-items:center;justify-content:center;transition:transform .15s}
|
||||||
|
.btn-menu:active{transform:scale(.92)}
|
||||||
|
.main{padding:16px 16px 0}
|
||||||
|
.sec-lbl{font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.6px;font-family:'DM Mono',monospace;margin-bottom:6px;margin-top:4px;padding:0 2px}
|
||||||
|
.empty{text-align:center;padding:60px 20px;color:var(--text3);font-size:14px;line-height:1.8}
|
||||||
|
.card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);margin-bottom:8px;overflow:hidden}
|
||||||
|
.card.done{opacity:.7}
|
||||||
|
.card-hdr{padding:14px 16px 12px;display:flex;align-items:center;justify-content:space-between;gap:10px;cursor:pointer;user-select:none}
|
||||||
|
.name-wrap{display:flex;align-items:center;gap:6px}
|
||||||
|
.goal-name{font-size:16px;font-weight:600;letter-spacing:-.2px}
|
||||||
|
.btn-ren{background:none;border:none;color:var(--text3);cursor:pointer;font-size:14px;padding:2px 4px;line-height:1;flex-shrink:0}
|
||||||
|
.btn-ren:active{color:var(--blue)}
|
||||||
|
.ren-input{font-family:'DM Sans',sans-serif;font-size:16px;font-weight:600;background:var(--bg3);border:1px solid var(--blue);border-radius:6px;color:var(--text);padding:2px 8px;width:100%;letter-spacing:-.2px}
|
||||||
|
.ren-input:focus{outline:none}
|
||||||
|
.goal-meta{font-size:11px;color:var(--text3);margin-top:3px;font-family:'DM Mono',monospace;line-height:1.5}
|
||||||
|
.chevron{font-size:13px;color:var(--text3);margin-left:4px;flex-shrink:0}
|
||||||
|
.badge{font-size:10px;font-weight:600;padding:3px 8px;border-radius:99px;white-space:nowrap;flex-shrink:0;letter-spacing:.3px;text-transform:uppercase}
|
||||||
|
.b-ok{background:var(--green-bg);color:var(--green)}.b-done{background:var(--green-bg);color:var(--green)}.b-warn{background:var(--amber-bg);color:var(--amber)}.b-danger{background:var(--red-bg);color:var(--red)}.b-buf{background:var(--blue-bg);color:var(--blue)}
|
||||||
|
.prog-wrap{padding:0 16px 12px}
|
||||||
|
.prog-track{height:4px;background:var(--bg3);border-radius:99px;overflow:hidden}
|
||||||
|
.prog-fill{height:100%;border-radius:99px;transition:width .4s ease}
|
||||||
|
.prog-row{display:flex;justify-content:space-between;font-size:11px;color:var(--text3);margin-top:5px;font-family:'DM Mono',monospace}
|
||||||
|
.heute-stats{padding:0 16px 14px}
|
||||||
|
.heute-group{background:var(--bg3);border-radius:var(--rs);padding:8px 8px 6px}
|
||||||
|
.heute-lbl{font-size:9px;color:var(--text3);text-transform:uppercase;letter-spacing:.5px;text-align:center;margin-bottom:6px;font-family:'DM Mono',monospace}
|
||||||
|
.heute-inner{display:grid;grid-template-columns:1fr 1fr 1fr;gap:5px}
|
||||||
|
.stat{background:var(--bg2);border-radius:6px;padding:7px 6px 5px;text-align:center}
|
||||||
|
.slbl{font-size:9px;color:var(--text3);margin-bottom:2px}
|
||||||
|
.sval{font-size:14px;font-weight:600;font-family:'DM Mono',monospace}
|
||||||
|
.sunit{font-size:8px;color:var(--text3)}
|
||||||
|
.dots-sec{padding:0 16px 12px}
|
||||||
|
.dots-lbl{font-size:11px;color:var(--text3);margin-bottom:6px}
|
||||||
|
.dots-wrap{display:flex;gap:3px;flex-wrap:wrap}
|
||||||
|
.dot{width:26px;height:26px;border-radius:5px;display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:600;font-family:'DM Mono',monospace;transition:opacity .15s}
|
||||||
|
.df{background:var(--bg3);color:var(--text3)}
|
||||||
|
.dm{background:rgba(220,38,38,.12);color:var(--red)}
|
||||||
|
.dp{background:rgba(217,119,6,.12);color:var(--amber)}
|
||||||
|
.dd{background:rgba(22,163,74,.12);color:var(--green)}
|
||||||
|
.db{background:rgba(37,99,235,.12);color:var(--blue)}
|
||||||
|
.de{cursor:pointer}.de:active{opacity:.6;transform:scale(.9)}.dl{opacity:.6;cursor:default}
|
||||||
|
.rt{box-shadow:0 0 0 2px var(--text2)}.ry{box-shadow:0 0 0 2px var(--text3)}.rs{box-shadow:0 0 0 2px var(--blue) !important}
|
||||||
|
.legend{display:flex;gap:10px;flex-wrap:wrap;margin-top:8px}
|
||||||
|
.leg{display:flex;align-items:center;gap:4px;font-size:10px;color:var(--text3)}
|
||||||
|
.ldot{width:8px;height:8px;border-radius:2px}
|
||||||
|
.dpanel{margin:0 16px 14px;background:var(--bg3);border-radius:var(--rs);overflow:hidden;border:1px solid var(--border2)}
|
||||||
|
.dpanel-hdr{padding:10px 12px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--border)}
|
||||||
|
.dpanel-title{font-size:13px;font-weight:600}
|
||||||
|
.dpanel-sub{font-size:11px;color:var(--text2);font-family:'DM Mono',monospace}
|
||||||
|
.dpanel-body{padding:10px 12px}
|
||||||
|
.set-row{display:flex;align-items:center;justify-content:space-between;padding:5px 0;border-bottom:1px solid var(--border);font-size:13px}
|
||||||
|
.set-row:last-of-type{border-bottom:none}
|
||||||
|
.stime{font-size:10px;color:var(--text3);font-family:'DM Mono',monospace;margin-right:4px}
|
||||||
|
.sdel{background:none;border:none;color:var(--text3);cursor:pointer;font-size:16px;padding:2px 6px;border-radius:4px}
|
||||||
|
.sdel:active{color:var(--red)}
|
||||||
|
.add-row{display:flex;gap:8px;align-items:center;margin-top:10px}
|
||||||
|
.num-in{font-family:'DM Mono',monospace;font-size:15px;padding:8px 10px;border-radius:var(--rs);border:1px solid var(--border2);background:var(--bg2);color:var(--text);width:90px;text-align:center}
|
||||||
|
.num-in:focus{outline:none;border-color:var(--blue)}
|
||||||
|
.ulbl{font-size:12px;color:var(--text3)}
|
||||||
|
.btn-as{flex:1;padding:8px;border-radius:var(--rs);background:var(--blue-bg);color:var(--blue);border:1px solid rgba(37,99,235,.2);cursor:pointer;font-family:'DM Sans',sans-serif;font-size:13px;font-weight:600}
|
||||||
|
.btn-as:active{opacity:.7}
|
||||||
|
.nosets{font-size:12px;color:var(--text3);padding:4px 0 8px}
|
||||||
|
.card-foot{padding:8px 16px 12px;display:flex;justify-content:flex-end}
|
||||||
|
.btn-del{background:none;border:none;color:var(--text3);font-size:12px;cursor:pointer;padding:4px 0;font-family:'DM Sans',sans-serif}
|
||||||
|
.btn-del:active{color:var(--red)}
|
||||||
|
.ov{position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.5);display:flex;align-items:flex-end;animation:fi .2s ease}
|
||||||
|
@keyframes fi{from{opacity:0}to{opacity:1}}
|
||||||
|
.sheet{width:100%;background:var(--bg2);border-radius:20px 20px 0 0;border:1px solid var(--border2);padding:20px 20px 40px;animation:su .25s ease;max-height:90dvh;overflow-y:auto}
|
||||||
|
@keyframes su{from{transform:translateY(100%)}to{transform:translateY(0)}}
|
||||||
|
.shandle{width:36px;height:4px;border-radius:2px;background:var(--border2);margin:0 auto 20px}
|
||||||
|
.stitle{font-size:17px;font-weight:600;margin-bottom:4px}
|
||||||
|
.ssub{font-size:12px;color:var(--text3);margin-bottom:16px}
|
||||||
|
.fgrid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px}
|
||||||
|
.ff{margin-bottom:10px}
|
||||||
|
.ff label{font-size:11px;color:var(--text3);display:block;margin-bottom:4px;text-transform:uppercase;letter-spacing:.5px}
|
||||||
|
.fi{width:100%;font-family:'DM Sans',sans-serif;font-size:15px;padding:10px 12px;border-radius:var(--rs);border:1px solid var(--border2);background:var(--bg3);color:var(--text)}
|
||||||
|
.fi:focus{outline:none;border-color:var(--blue)}
|
||||||
|
.factions{display:flex;gap:8px;margin-top:16px}
|
||||||
|
.btn-p{flex:1;padding:13px;border-radius:var(--rs);background:var(--text);color:var(--bg);border:none;cursor:pointer;font-family:'DM Sans',sans-serif;font-size:15px;font-weight:600}
|
||||||
|
.btn-p:active{opacity:.8}
|
||||||
|
.btn-c{padding:13px 20px;border-radius:var(--rs);background:var(--bg3);color:var(--text2);border:1px solid var(--border);cursor:pointer;font-family:'DM Sans',sans-serif;font-size:15px}
|
||||||
|
.dbtn{width:100%;padding:13px 16px;border-radius:var(--rs);background:var(--bg3);border:1px solid var(--border);color:var(--text);font-family:'DM Sans',sans-serif;font-size:15px;text-align:left;cursor:pointer;margin-bottom:8px;display:flex;align-items:center;gap:12px}
|
||||||
|
.dbtn:active{background:var(--border2)}
|
||||||
|
.dico{font-size:18px;width:24px;text-align:center;flex-shrink:0}
|
||||||
|
.dlbl{flex:1}.dsub{font-size:11px;color:var(--text3);display:block;margin-top:1px}
|
||||||
|
.ddanger{color:var(--red)}
|
||||||
|
.ddiv{height:1px;background:var(--border);margin:12px 0}
|
||||||
|
.hint{margin:0 16px 12px;background:var(--blue-bg);border:1px solid rgba(37,99,235,.15);border-radius:var(--rs);padding:10px 12px;font-size:12px;color:var(--blue);line-height:1.5;display:flex;justify-content:space-between;align-items:center;gap:8px}
|
||||||
|
.hclose{background:none;border:none;color:var(--blue);cursor:pointer;font-size:16px;padding:0 2px}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify file exists**
|
||||||
|
```bash
|
||||||
|
ls -la /srv/http/zieltracker/style.css
|
||||||
|
```
|
||||||
|
Expected: file exists, ~4KB
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Create app.js
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create app.js** with the full logic — extracted from index.html plus all new functionality:
|
||||||
|
|
||||||
|
```js
|
||||||
|
var TODAY = new Date(); TODAY.setHours(0,0,0,0);
|
||||||
|
var goals, prefs, selDay={}, addAmt={}, renamingId=null, renameVal='', collapsed={};
|
||||||
|
|
||||||
|
function load(k,def){ try{ return JSON.parse(localStorage.getItem(k)||def); }catch(e){ return JSON.parse(def); } }
|
||||||
|
function save(){ localStorage.setItem('zt_g',JSON.stringify(goals)); }
|
||||||
|
function saveP(){ localStorage.setItem('zt_p',JSON.stringify(prefs)); }
|
||||||
|
goals = load('zt_g','[]');
|
||||||
|
prefs = load('zt_p','{}');
|
||||||
|
|
||||||
|
function tOff(g){ return Math.round((TODAY - new Date(g.start))/86400000); }
|
||||||
|
function o2d(g,i){ var d=new Date(new Date(g.start).getTime()+i*86400000); d.setHours(0,0,0,0); return d; }
|
||||||
|
function dTot(g,o){ return (g.sets[String(o)]||[]).reduce(function(a,b){return a+b.amount;},0); }
|
||||||
|
function fd(d){ return d.toLocaleDateString('de-DE',{weekday:'short',day:'numeric',month:'short'}); }
|
||||||
|
function fs(d){ return d.toLocaleDateString('de-DE',{day:'numeric',month:'short'}); }
|
||||||
|
function editable(g,o){ var t=tOff(g); return o===t||o===t-1; }
|
||||||
|
function now(){ var n=new Date(); return String(n.getHours()).padStart(2,'0')+':'+String(n.getMinutes()).padStart(2,'0'); }
|
||||||
|
|
||||||
|
// Returns CSS color for heute: X/Y display
|
||||||
|
function heuteColor(tdone, daily){
|
||||||
|
if(tdone===0) return 'var(--red)';
|
||||||
|
if(tdone>=daily*1.1) return 'var(--blue)';
|
||||||
|
if(tdone>=daily) return 'var(--green)';
|
||||||
|
return 'var(--amber)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// collapsed defaults to true — only false when explicitly opened
|
||||||
|
function isCollapsed(id){ return collapsed[id]!==false; }
|
||||||
|
function toggleCollapse(id){ collapsed[id]=!isCollapsed(id); render(); }
|
||||||
|
|
||||||
|
function calc(g){
|
||||||
|
var t=tOff(g), tot=g.daily*g.days;
|
||||||
|
var dr=Math.max(0,g.days-t-1);
|
||||||
|
var sd=new Date(g.start); sd.setHours(0,0,0,0);
|
||||||
|
var end=new Date(sd.getTime()+g.days*86400000);
|
||||||
|
var past=0;
|
||||||
|
for(var i=0;i<Math.min(t,g.days);i++) past+=dTot(g,i);
|
||||||
|
var tdone=dTot(g,t), tot2=past+tdone;
|
||||||
|
var dl=dr+1;
|
||||||
|
var remaining=Math.max(0,tot-past);
|
||||||
|
var pd=Math.ceil(remaining/Math.max(1,dl));
|
||||||
|
var st=Math.max(0,pd-tdone);
|
||||||
|
var expectedPast=Math.min(t,g.days)*g.daily;
|
||||||
|
var buf=(past-expectedPast)+Math.max(0,tdone-g.daily);
|
||||||
|
var deficit=Math.min(0,buf);
|
||||||
|
var surplus=Math.max(0,buf);
|
||||||
|
var dailyDelta=pd-g.daily;
|
||||||
|
var pct=Math.min(100,Math.round((tot2/tot)*100));
|
||||||
|
return{tot:tot,tOff:t,end:end,dr:dr,done:tot2,tdone:tdone,pd:pd,st:st,buf:buf,deficit:deficit,surplus:surplus,dailyDelta:dailyDelta,net:tdone-pd,pct:pct,ok:tdone>=pd};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dcls(g,i){
|
||||||
|
var t=tOff(g); if(i>t) return 'dot df';
|
||||||
|
var v=dTot(g,i);
|
||||||
|
var c=v===0?'dot dm':v>=g.daily*1.1?'dot db':v>=g.daily?'dot dd':'dot dp';
|
||||||
|
return c+(editable(g,i)?' de':' dl');
|
||||||
|
}
|
||||||
|
function dlbl(g,i){
|
||||||
|
var t=tOff(g); if(i>t) return String(i+1);
|
||||||
|
var v=dTot(g,i);
|
||||||
|
if(v===0) return '✕'; if(v>=g.daily*1.1) return '+'; if(v>=g.daily) return '✓';
|
||||||
|
return Math.round(v/g.daily*100)+'%';
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSet(gid,off){
|
||||||
|
var g=goals.filter(function(x){return x.id===gid;})[0];
|
||||||
|
if(!g||!editable(g,off)) return;
|
||||||
|
var k=gid+'_'+off, amt=parseInt(addAmt[k]||'0',10);
|
||||||
|
if(amt<=0) return;
|
||||||
|
if(!g.sets[String(off)]) g.sets[String(off)]=[];
|
||||||
|
g.sets[String(off)].push({amount:amt,time:off===tOff(g)?now():'—'});
|
||||||
|
addAmt[k]=''; save(); render();
|
||||||
|
}
|
||||||
|
function remSet(gid,off,idx){
|
||||||
|
var g=goals.filter(function(x){return x.id===gid;})[0];
|
||||||
|
if(!g||!editable(g,off)) return;
|
||||||
|
g.sets[String(off)].splice(idx,1); save(); render();
|
||||||
|
}
|
||||||
|
function delGoal(id){
|
||||||
|
if(!confirm('Ziel wirklich löschen?')) return;
|
||||||
|
goals=goals.filter(function(g){return g.id!==id;}); save(); render();
|
||||||
|
}
|
||||||
|
function selD(gid,off){
|
||||||
|
var g=goals.filter(function(x){return x.id===gid;})[0];
|
||||||
|
if(!g||!editable(g,off)) return;
|
||||||
|
selDay[gid]=selDay[gid]===off?null:off; render();
|
||||||
|
}
|
||||||
|
function startRen(id){
|
||||||
|
var g=goals.filter(function(x){return x.id===id;})[0]; if(!g) return;
|
||||||
|
renamingId=id; renameVal=g.name; render();
|
||||||
|
setTimeout(function(){ var el=document.getElementById('ri'+id); if(el){el.focus();el.select();} },50);
|
||||||
|
}
|
||||||
|
function commitRen(id){
|
||||||
|
var g=goals.filter(function(x){return x.id===id;})[0];
|
||||||
|
if(g&&renameVal.trim()) g.name=renameVal.trim();
|
||||||
|
renamingId=null; save(); render();
|
||||||
|
}
|
||||||
|
function cancelRen(){ renamingId=null; render(); }
|
||||||
|
|
||||||
|
function closeOv(){ var o=document.getElementById('ov'); o.style.display='none'; o.innerHTML=''; }
|
||||||
|
function showSheet(html){
|
||||||
|
var o=document.getElementById('ov');
|
||||||
|
o.style.cssText='display:flex;position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.5);align-items:flex-end;animation:fi .2s ease';
|
||||||
|
o.innerHTML='<div class="sheet">'+html+'</div>';
|
||||||
|
o.onclick=function(e){if(e.target===o)closeOv();};
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNew(){
|
||||||
|
showSheet(
|
||||||
|
'<div class="shandle"></div>'+
|
||||||
|
'<div class="stitle">Neues Ziel</div>'+
|
||||||
|
'<div class="ff"><label>Übung / Gewohnheit</label><input class="fi" id="fn" type="text" placeholder="Liegestütz, Plank …"/></div>'+
|
||||||
|
'<div class="fgrid">'+
|
||||||
|
'<div class="ff"><label>Einheit</label><input class="fi" id="fu" type="text" value="Stück"/></div>'+
|
||||||
|
'<div class="ff"><label>Tagesziel</label><input class="fi" id="fd" type="number" min="1" value="50"/></div>'+
|
||||||
|
'</div>'+
|
||||||
|
'<div class="ff"><label>Dauer in Tagen</label><input class="fi" id="fdy" type="number" min="7" max="365" value="30"/></div>'+
|
||||||
|
'<div class="factions"><button class="btn-p" id="fsub">Ziel starten</button><button class="btn-c" id="fcan">Abbrechen</button></div>'
|
||||||
|
);
|
||||||
|
document.getElementById('fn').focus();
|
||||||
|
document.getElementById('fsub').onclick=function(){
|
||||||
|
var name=(document.getElementById('fn').value||'').trim();
|
||||||
|
var unit=(document.getElementById('fu').value||'').trim()||'Stück';
|
||||||
|
var daily=parseInt(document.getElementById('fd').value,10)||1;
|
||||||
|
var days=parseInt(document.getElementById('fdy').value,10)||30;
|
||||||
|
if(!name){document.getElementById('fn').focus();return;}
|
||||||
|
goals.push({id:Date.now(),name:name,unit:unit,daily:daily,days:days,start:TODAY.toISOString(),sets:{}});
|
||||||
|
save(); closeOv(); render();
|
||||||
|
};
|
||||||
|
document.getElementById('fcan').onclick=closeOv;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openData(){
|
||||||
|
showSheet(
|
||||||
|
'<div class="shandle"></div>'+
|
||||||
|
'<div class="stitle">Daten verwalten</div>'+
|
||||||
|
'<div class="ssub">Export, Import und Backup</div>'+
|
||||||
|
'<button class="dbtn" id="dexp"><span class="dico">⬇</span><span class="dlbl">Exportieren<span class="dsub">Alle Ziele als JSON-Datei speichern</span></span></button>'+
|
||||||
|
'<button class="dbtn" id="dimp"><span class="dico">⬆</span><span class="dlbl">Importieren<span class="dsub">Backup laden oder zusammenführen</span></span></button>'+
|
||||||
|
'<div class="ddiv"></div>'+
|
||||||
|
'<button class="dbtn ddanger" id="dclr"><span class="dico">✕</span><span class="dlbl">Alle Daten löschen<span class="dsub">Kann nicht rückgängig gemacht werden</span></span></button>'+
|
||||||
|
'<button class="btn-c" id="dcls" style="width:100%;margin-top:4px;text-align:center">Schließen</button>'
|
||||||
|
);
|
||||||
|
document.getElementById('dcls').onclick=closeOv;
|
||||||
|
document.getElementById('dexp').onclick=function(){
|
||||||
|
var blob=new Blob([JSON.stringify({goals:goals,at:new Date().toISOString()},null,2)],{type:'application/json'});
|
||||||
|
var url=URL.createObjectURL(blob), a=document.createElement('a');
|
||||||
|
a.href=url; a.download='zieltracker-backup.json'; a.click(); URL.revokeObjectURL(url); closeOv();
|
||||||
|
};
|
||||||
|
document.getElementById('dimp').onclick=function(){
|
||||||
|
var inp=document.createElement('input'); inp.type='file'; inp.accept='.json';
|
||||||
|
inp.onchange=function(e){
|
||||||
|
var f=e.target.files[0]; if(!f) return;
|
||||||
|
var r=new FileReader(); r.onload=function(ev){
|
||||||
|
try{
|
||||||
|
var p=JSON.parse(ev.target.result);
|
||||||
|
if(!p.goals||!Array.isArray(p.goals)) throw new Error('Ungültiges Format');
|
||||||
|
if(!confirm(p.goals.length+' Ziel(e) importieren? Bestehende Daten bleiben erhalten.')) return;
|
||||||
|
var ex={}; goals.forEach(function(g){ex[g.id]=1;});
|
||||||
|
var add=0; p.goals.forEach(function(g){if(!ex[g.id]){goals.push(g);add++;}});
|
||||||
|
save(); closeOv(); render(); alert(add+' Ziel(e) importiert.');
|
||||||
|
}catch(err){alert('Fehler: '+err.message);}
|
||||||
|
}; r.readAsText(f);
|
||||||
|
}; inp.click();
|
||||||
|
};
|
||||||
|
document.getElementById('dclr').onclick=function(){
|
||||||
|
if(!confirm('Alle Daten löschen?')) return;
|
||||||
|
goals=[]; save(); closeOv(); render();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function panel(g,off){
|
||||||
|
var t=tOff(g), sets=g.sets[String(off)]||[], tot=dTot(g,off);
|
||||||
|
var lbl=off===t?'Heute':'Gestern', k=g.id+'_'+off;
|
||||||
|
var rows='';
|
||||||
|
for(var i=0;i<sets.length;i++){
|
||||||
|
var s=sets[i];
|
||||||
|
rows+='<div class="set-row"><span>'+(s.time!=='—'?'<span class="stime">'+s.time+' ·</span>':'')+' <strong>'+s.amount+'</strong> '+g.unit+'</span>'+
|
||||||
|
'<button class="sdel" data-g="'+g.id+'" data-o="'+off+'" data-i="'+i+'">×</button></div>';
|
||||||
|
}
|
||||||
|
return '<div class="dpanel">'+
|
||||||
|
'<div class="dpanel-hdr"><span class="dpanel-title">'+lbl+' — '+fd(o2d(g,off))+'</span><span class="dpanel-sub">'+tot+' / '+g.daily+' '+g.unit+'</span></div>'+
|
||||||
|
'<div class="dpanel-body">'+
|
||||||
|
(sets.length?rows:'<div class="nosets">Noch kein Eintrag</div>')+
|
||||||
|
'<div class="add-row">'+
|
||||||
|
'<input class="num-in" type="number" min="1" placeholder="'+g.daily+'" value="'+(addAmt[k]||'')+'" data-k="'+k+'" data-g="'+g.id+'" data-o="'+off+'"/>'+
|
||||||
|
'<span class="ulbl">'+g.unit+'</span>'+
|
||||||
|
'<button class="btn-as" data-g="'+g.id+'" data-o="'+off+'">+ Satz</button>'+
|
||||||
|
'</div>'+
|
||||||
|
'</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardHtml(g){
|
||||||
|
var c=calc(g), t=c.tOff;
|
||||||
|
var fc=c.surplus>0?'var(--blue)':c.st===0?'var(--green)':c.dailyDelta<=0?'var(--green)':c.dailyDelta<=g.daily*.2?'var(--amber)':'var(--red)';
|
||||||
|
var bc,bt;
|
||||||
|
if(c.ok&&c.surplus>0){bc='b-buf';bt='+'+c.surplus+' Puffer';}
|
||||||
|
else if(c.ok){bc='b-done';bt='Heute ✓';}
|
||||||
|
else if(c.dailyDelta<=0){bc='b-ok';bt='Im Plan';}
|
||||||
|
else if(c.dailyDelta<=g.daily*.2){bc='b-warn';bt='Leicht mehr';}
|
||||||
|
else{bc='b-danger';bt='-'+Math.abs(c.deficit)+' Rückstand';}
|
||||||
|
|
||||||
|
var nh=renamingId===g.id
|
||||||
|
?'<div class="name-wrap"><input class="ren-input" id="ri'+g.id+'" value="'+g.name.replace(/"/g,'"')+'" data-g="'+g.id+'"/></div>'
|
||||||
|
:'<div class="name-wrap"><div class="goal-name">'+g.name+'</div><button class="btn-ren" data-g="'+g.id+'">✎</button></div>';
|
||||||
|
|
||||||
|
var doneCls=c.ok?' done':'';
|
||||||
|
|
||||||
|
if(isCollapsed(g.id)){
|
||||||
|
var hc=heuteColor(c.tdone,g.daily);
|
||||||
|
var metaC='Noch '+c.dr+'T · endet '+fs(c.end)+' · heute: <span style="color:'+hc+'">'+c.tdone+'/'+g.daily+'</span> · total: '+c.done+'/'+c.tot;
|
||||||
|
return '<div class="card'+doneCls+'">'+
|
||||||
|
'<div class="card-hdr" data-g="'+g.id+'">'+
|
||||||
|
'<div style="flex:1;min-width:0">'+nh+'<div class="goal-meta">'+metaC+'</div></div>'+
|
||||||
|
'<span class="badge '+bc+'">'+bt+'</span>'+
|
||||||
|
'<span class="chevron">▸</span>'+
|
||||||
|
'</div>'+
|
||||||
|
'<div style="padding:0 16px 12px"><div class="prog-track"><div class="prog-fill" style="width:'+c.pct+'%;background:'+fc+'"></div></div></div>'+
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expanded
|
||||||
|
var sel=selDay[g.id];
|
||||||
|
var dots='';
|
||||||
|
for(var i=0;i<g.days;i++){
|
||||||
|
var it=i===t, iy=i===t-1, is=sel===i, ed=editable(g,i);
|
||||||
|
var cls=dcls(g,i)+(is?' rs':it?' rt':iy&&t>0?' ry':'');
|
||||||
|
dots+='<div class="'+cls+'"'+(ed?' data-g="'+g.id+'" data-d="'+i+'"':'')+'>'+dlbl(g,i)+'</div>';
|
||||||
|
}
|
||||||
|
var nc=heuteColor(c.tdone,g.daily);
|
||||||
|
return '<div class="card'+doneCls+'">'+
|
||||||
|
'<div class="card-hdr" data-g="'+g.id+'">'+
|
||||||
|
'<div style="flex:1;min-width:0">'+nh+'<div class="goal-meta">Noch '+c.dr+'T · endet '+fs(c.end)+'</div></div>'+
|
||||||
|
'<span class="badge '+bc+'">'+bt+'</span>'+
|
||||||
|
'<span class="chevron">▴</span>'+
|
||||||
|
'</div>'+
|
||||||
|
'<div class="prog-wrap"><div class="prog-track"><div class="prog-fill" style="width:'+c.pct+'%;background:'+fc+'"></div></div>'+
|
||||||
|
'<div class="prog-row"><span>'+c.done+' '+g.unit+' gemacht</span><span>'+c.pct+'% von '+c.tot+'</span></div></div>'+
|
||||||
|
'<div class="heute-stats"><div class="heute-group">'+
|
||||||
|
'<div class="heute-lbl">Heute</div>'+
|
||||||
|
'<div class="heute-inner">'+
|
||||||
|
'<div class="stat"><div class="slbl">Gemacht</div><div class="sval">'+c.tdone+'<div class="sunit">'+g.unit+'</div></div></div>'+
|
||||||
|
'<div class="stat"><div class="slbl">Tagesziel</div><div class="sval">'+g.daily+'<div class="sunit">'+g.unit+'</div></div></div>'+
|
||||||
|
'<div class="stat"><div class="slbl">Noch</div><div class="sval" style="color:'+nc+'">'+c.st+'<div class="sunit">'+g.unit+'</div></div></div>'+
|
||||||
|
'</div>'+
|
||||||
|
'</div></div>'+
|
||||||
|
'<div class="dots-sec"><div class="dots-lbl">Verlauf — heute & gestern bearbeitbar</div>'+
|
||||||
|
'<div class="dots-wrap">'+dots+'</div>'+
|
||||||
|
'<div class="legend">'+
|
||||||
|
'<span class="leg"><span class="ldot" style="background:rgba(37,99,235,.3)"></span>Puffer</span>'+
|
||||||
|
'<span class="leg"><span class="ldot" style="background:rgba(22,163,74,.3)"></span>Erreicht</span>'+
|
||||||
|
'<span class="leg"><span class="ldot" style="background:rgba(217,119,6,.3)"></span>Teilweise</span>'+
|
||||||
|
'<span class="leg"><span class="ldot" style="background:rgba(220,38,38,.3)"></span>Verpasst</span>'+
|
||||||
|
'</div></div>'+
|
||||||
|
(sel!=null?panel(g,sel):'')+
|
||||||
|
'<div class="card-foot"><button class="btn-del" data-g="'+g.id+'">Ziel löschen</button></div>'+
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(){
|
||||||
|
var m=document.getElementById('main'), h='';
|
||||||
|
if(!prefs.hd) h+='<div class="hint">Menü → "Zum Startbildschirm" für App-Icon<button class="hclose" id="hc">×</button></div>';
|
||||||
|
if(!goals.length){
|
||||||
|
h+='<div class="empty"><div style="font-size:40px;opacity:.4;margin-bottom:12px">🎯</div>Noch keine Ziele.<br>Tippe auf <strong>+</strong> um zu starten.</div>';
|
||||||
|
m.innerHTML=h; wire(); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: open first, done today at bottom
|
||||||
|
var open=[], done=[];
|
||||||
|
for(var gi=0;gi<goals.length;gi++){
|
||||||
|
var g=goals[gi], c=calc(g);
|
||||||
|
if(c.ok) done.push(g); else open.push(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(open.length){
|
||||||
|
h+='<div class="sec-lbl">Offen</div>';
|
||||||
|
for(var i=0;i<open.length;i++) h+=cardHtml(open[i]);
|
||||||
|
}
|
||||||
|
if(done.length){
|
||||||
|
h+='<div class="sec-lbl">Heute erledigt</div>';
|
||||||
|
for(var j=0;j<done.length;j++) h+=cardHtml(done[j]);
|
||||||
|
}
|
||||||
|
|
||||||
|
m.innerHTML=h; wire();
|
||||||
|
}
|
||||||
|
|
||||||
|
function wire(){
|
||||||
|
var hc=document.getElementById('hc');
|
||||||
|
if(hc) hc.onclick=function(){prefs.hd=1;saveP();this.closest('.hint').remove();};
|
||||||
|
|
||||||
|
document.querySelectorAll('.card-hdr[data-g]').forEach(function(el){
|
||||||
|
el.onclick=function(e){
|
||||||
|
if(e.target.classList.contains('btn-ren')||e.target.classList.contains('ren-input')) return;
|
||||||
|
toggleCollapse(parseInt(this.dataset.g,10));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.btn-ren').forEach(function(b){
|
||||||
|
b.onclick=function(e){e.stopPropagation();startRen(parseInt(this.dataset.g,10));};
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.ren-input').forEach(function(inp){
|
||||||
|
var gid=parseInt(inp.dataset.g,10);
|
||||||
|
inp.oninput=function(){renameVal=this.value;};
|
||||||
|
inp.onkeydown=function(e){if(e.key==='Enter')commitRen(gid);if(e.key==='Escape')cancelRen();};
|
||||||
|
inp.onblur=function(){commitRen(gid);};
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.de').forEach(function(d){
|
||||||
|
d.onclick=function(e){e.stopPropagation();selD(parseInt(this.dataset.g,10),parseInt(this.dataset.d,10));};
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.btn-as').forEach(function(b){
|
||||||
|
b.onclick=function(){addSet(parseInt(this.dataset.g,10),parseInt(this.dataset.o,10));};
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.num-in').forEach(function(inp){
|
||||||
|
var k=inp.dataset.k, g=parseInt(inp.dataset.g,10), o=parseInt(inp.dataset.o,10);
|
||||||
|
inp.oninput=function(){addAmt[k]=this.value;};
|
||||||
|
inp.onkeydown=function(e){if(e.key==='Enter')addSet(g,o);};
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.sdel').forEach(function(b){
|
||||||
|
b.onclick=function(){remSet(parseInt(this.dataset.g,10),parseInt(this.dataset.o,10),parseInt(this.dataset.i,10));};
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.btn-del').forEach(function(b){
|
||||||
|
b.onclick=function(){delGoal(parseInt(this.dataset.g,10));};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btnNew').onclick=openNew;
|
||||||
|
document.getElementById('btnData').onclick=openData;
|
||||||
|
document.getElementById('tlbl').textContent=TODAY.toLocaleDateString('de-DE',{weekday:'long',day:'numeric',month:'long'});
|
||||||
|
render();
|
||||||
|
|
||||||
|
function scheduleMidnight(){
|
||||||
|
var n=new Date();
|
||||||
|
var ms=new Date(n.getFullYear(),n.getMonth(),n.getDate()+1,0,0,5).getTime()-n.getTime();
|
||||||
|
setTimeout(function(){
|
||||||
|
TODAY=new Date();TODAY.setHours(0,0,0,0);selDay={};collapsed={};
|
||||||
|
document.getElementById('tlbl').textContent=TODAY.toLocaleDateString('de-DE',{weekday:'long',day:'numeric',month:'long'});
|
||||||
|
render();scheduleMidnight();
|
||||||
|
},ms);
|
||||||
|
}
|
||||||
|
scheduleMidnight();
|
||||||
|
document.addEventListener('visibilitychange',function(){
|
||||||
|
if(document.visibilityState==='visible'){
|
||||||
|
var n=new Date();n.setHours(0,0,0,0);
|
||||||
|
if(n.getTime()!==TODAY.getTime()){TODAY=n;selDay={};collapsed={};render();scheduleMidnight();}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify file exists**
|
||||||
|
```bash
|
||||||
|
ls -la /srv/http/zieltracker/app.js
|
||||||
|
```
|
||||||
|
Expected: file exists, ~8KB
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Rewrite index.html as clean skeleton
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `index.html`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace index.html** with a clean skeleton that references the new files:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
|
||||||
|
<meta name="theme-color" content="#f5f4f0"/>
|
||||||
|
<meta name="mobile-web-app-capable" content="yes"/>
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes"/>
|
||||||
|
<title>Zieltracker</title>
|
||||||
|
<link rel="stylesheet" href="style.css"/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="main-wrap">
|
||||||
|
<div class="hdr">
|
||||||
|
<div>
|
||||||
|
<div class="hdr-title">Zieltracker</div>
|
||||||
|
<div class="hdr-sub" id="tlbl"></div>
|
||||||
|
</div>
|
||||||
|
<div class="hdr-btns">
|
||||||
|
<button class="btn-menu" id="btnData">⋯</button>
|
||||||
|
<button class="btn-add" id="btnNew">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="main" id="main"></div>
|
||||||
|
</div>
|
||||||
|
<div id="ov" style="display:none"></div>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Open in browser and verify**
|
||||||
|
|
||||||
|
Open `http://localhost/zieltracker/` (or wherever it's served).
|
||||||
|
|
||||||
|
Check:
|
||||||
|
- Page loads without console errors
|
||||||
|
- Header and cards are centered on a wide screen (max ~480px content width)
|
||||||
|
- Existing goals show up as collapsed with meta-line `Noch XT · endet DD. Mon · heute: X/Y · total: X/Y`
|
||||||
|
- Clicking a card header expands it, showing Heute-Stats group + Dots + Entry panel
|
||||||
|
- Clicking again collapses it
|
||||||
|
- Goals with `ok=true` (today's quota met) appear under "Heute erledigt" with reduced opacity
|
||||||
|
- Rename pencil still works (click pencil → edit inline)
|
||||||
|
- Dot click still opens entry panel
|
||||||
|
- `+ Satz` still saves a set
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
```bash
|
||||||
|
cd /srv/http/zieltracker
|
||||||
|
git add index.html style.css app.js
|
||||||
|
git commit -m "refactor: split into files, collapsible cards, heute stats, desktop max-width"
|
||||||
|
```
|
||||||
578
docs/superpowers/plans/2026-04-21-registration-invites.md
Normal file
578
docs/superpowers/plans/2026-04-21-registration-invites.md
Normal file
|
|
@ -0,0 +1,578 @@
|
||||||
|
# Registration & Invite System Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add invite-only registration, referral tracking, and password reset to Zieltracker.
|
||||||
|
|
||||||
|
**Architecture:** Vanilla JS single-page app. A new `config.js` externalizes server URLs. PocketBase handles auth, email verification, and password reset natively via SMTP. A new `invites` collection tracks tokens with labels and referral state. All new UI flows (register tab, password reset, invite management) are added to `app.js` following the existing sheet pattern.
|
||||||
|
|
||||||
|
**Tech Stack:** Vanilla JS, PocketBase REST API, Brevo SMTP (configured in PocketBase admin)
|
||||||
|
|
||||||
|
> **Note:** This app has no test framework. Each task uses browser-based manual verification instead of automated tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| File | Action | Responsibility |
|
||||||
|
|---|---|---|
|
||||||
|
| `config.js` | Create | `PB_URL` and `APP_URL` globals |
|
||||||
|
| `index.html` | Modify | Add `<script src="config.js">` before `app.js` |
|
||||||
|
| `app.js` | Modify | Remove hardcoded `PB_URL`; add `inviteRecord`/`loginTab` globals; refactor `showLogin`; add password reset, registration, and invite management functions; update startup block |
|
||||||
|
| `style.css` | Modify | Add tab styles, password-reset link style, invite list/link styles |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Create config.js and wire it up
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `config.js`
|
||||||
|
- Modify: `index.html`
|
||||||
|
- Modify: `app.js` (remove line 4)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `config.js`**
|
||||||
|
|
||||||
|
```js
|
||||||
|
var PB_URL = 'http://miniweb.kuehn.home:8090';
|
||||||
|
var APP_URL = 'http://miniweb.kuehn.home';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add script tag to `index.html`**
|
||||||
|
|
||||||
|
In `index.html`, before `<script src="app.js"></script>`, add:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="config.js"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Remove hardcoded `PB_URL` from `app.js`**
|
||||||
|
|
||||||
|
Delete line 4 of `app.js`:
|
||||||
|
```js
|
||||||
|
var PB_URL = 'http://miniweb.kuehn.home:8090';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify in browser**
|
||||||
|
|
||||||
|
Open the app. Goals load, login/logout works, no console errors.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add config.js index.html app.js
|
||||||
|
git commit -m "feat: externalize server URLs into config.js"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: PocketBase — create `invites` collection (manual)
|
||||||
|
|
||||||
|
**Files:** None — PocketBase admin UI only
|
||||||
|
|
||||||
|
- [ ] **Step 1: Open PocketBase admin**
|
||||||
|
|
||||||
|
Navigate to `http://miniweb.kuehn.home:8090/_/` and log in.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create `invites` collection**
|
||||||
|
|
||||||
|
Collections → New collection → Name: `invites`
|
||||||
|
|
||||||
|
Add fields:
|
||||||
|
|
||||||
|
| Name | Type | Options |
|
||||||
|
|---|---|---|
|
||||||
|
| `token` | Text | Required |
|
||||||
|
| `label` | Text | Required |
|
||||||
|
| `created_by` | Relation | Collection: `users`, Required |
|
||||||
|
| `used_by` | Relation | Collection: `users`, Optional |
|
||||||
|
| `used_at` | Date | Optional |
|
||||||
|
|
||||||
|
- [ ] **Step 3: Set API rules**
|
||||||
|
|
||||||
|
In the `invites` collection → API rules:
|
||||||
|
|
||||||
|
| Rule | Value |
|
||||||
|
|---|---|
|
||||||
|
| List / View | *(empty — public)* |
|
||||||
|
| Create | `@request.auth.id != ""` |
|
||||||
|
| Update | `used_by = ""` |
|
||||||
|
| Delete | `@request.auth.id = created_by` |
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify**
|
||||||
|
|
||||||
|
In PocketBase admin, create a test invite record manually with any token string. Then open in browser:
|
||||||
|
`http://miniweb.kuehn.home:8090/api/collections/invites/records`
|
||||||
|
Should return the record as JSON.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: PocketBase — configure SMTP (manual)
|
||||||
|
|
||||||
|
**Files:** None — PocketBase admin UI only
|
||||||
|
|
||||||
|
- [ ] **Step 1: Get Brevo SMTP credentials**
|
||||||
|
|
||||||
|
Sign up at brevo.com (free tier). Go to:
|
||||||
|
SMTP & API → SMTP → Generate SMTP key
|
||||||
|
|
||||||
|
Note: host `smtp-relay.brevo.com`, port `587`, your Brevo login email, and the generated key.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Configure PocketBase SMTP**
|
||||||
|
|
||||||
|
PocketBase admin → Settings → Mail settings:
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Sender name | ZielTracker |
|
||||||
|
| Sender address | your-email@example.com |
|
||||||
|
| SMTP host | smtp-relay.brevo.com |
|
||||||
|
| SMTP port | 587 |
|
||||||
|
| Username | your-brevo-login@example.com |
|
||||||
|
| Password | (Brevo SMTP key) |
|
||||||
|
| TLS | enabled |
|
||||||
|
|
||||||
|
- [ ] **Step 3: Send test email**
|
||||||
|
|
||||||
|
PocketBase admin → Settings → Mail settings → "Send test email". Confirm arrival in inbox.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Add CSS for new UI elements
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `style.css`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Append styles to `style.css`**
|
||||||
|
|
||||||
|
```css
|
||||||
|
.ltabs{display:flex;gap:0;margin:0 0 16px;border-bottom:2px solid var(--border)}
|
||||||
|
.ltab{flex:1;padding:10px;background:none;border:none;font-size:15px;color:var(--text3);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-2px;transition:color .15s;font-family:'DM Sans',sans-serif}
|
||||||
|
.ltab.active{color:var(--text);border-bottom-color:var(--text)}
|
||||||
|
.pw-link{background:none;border:none;color:var(--text3);font-size:13px;padding:8px 0 0;text-decoration:underline;cursor:pointer;display:block;text-align:center;font-family:'DM Sans',sans-serif;width:100%}
|
||||||
|
.inv-row{display:flex;justify-content:space-between;align-items:center;padding:10px 0;border-bottom:1px solid var(--border)}
|
||||||
|
.inv-lbl{font-weight:500;font-size:15px}
|
||||||
|
.inv-link{background:var(--bg3);border:1px solid var(--border2);border-radius:var(--rs);padding:12px;font-size:13px;word-break:break-all;margin:8px 0 16px;font-family:'DM Mono',monospace;line-height:1.5}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify**
|
||||||
|
|
||||||
|
Open app in browser, confirm no visual regressions.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add style.css
|
||||||
|
git commit -m "feat: add CSS for login tabs, password-reset link, and invite rows"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Add globals and refactor startup block
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add globals near top of `app.js`**
|
||||||
|
|
||||||
|
After line 3 (`var authToken = '', authUserId = '';`), insert:
|
||||||
|
|
||||||
|
```js
|
||||||
|
var inviteRecord = null;
|
||||||
|
var loginTab = 'login';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace the startup block at the bottom of `app.js`**
|
||||||
|
|
||||||
|
The current startup block (after `scheduleMidnight` and `visibilitychange`) is:
|
||||||
|
|
||||||
|
```js
|
||||||
|
authToken=localStorage.getItem('zt_token')||'';
|
||||||
|
authUserId=localStorage.getItem('zt_uid')||'';
|
||||||
|
|
||||||
|
if(!authToken){
|
||||||
|
render(); showLogin();
|
||||||
|
} else {
|
||||||
|
pbGoals()
|
||||||
|
.then(function(g){ goals=g; render(); })
|
||||||
|
.catch(function(){ clearAuth(); render(); showLogin(); });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
authToken=localStorage.getItem('zt_token')||'';
|
||||||
|
authUserId=localStorage.getItem('zt_uid')||'';
|
||||||
|
|
||||||
|
function init(){
|
||||||
|
if(!authToken){ render(); showLogin(); }
|
||||||
|
else{
|
||||||
|
pbGoals()
|
||||||
|
.then(function(g){ goals=g; render(); })
|
||||||
|
.catch(function(){ clearAuth(); render(); showLogin(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var urlInviteToken=(new URLSearchParams(window.location.search)).get('invite');
|
||||||
|
if(urlInviteToken){
|
||||||
|
api('GET','/api/collections/invites/records?filter='+encodeURIComponent('(token="'+urlInviteToken+'"&&used_by="")'))
|
||||||
|
.then(function(res){
|
||||||
|
if(res.items&&res.items.length>0) inviteRecord=res.items[0];
|
||||||
|
})
|
||||||
|
.catch(function(){})
|
||||||
|
.then(function(){ init(); });
|
||||||
|
}else{
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify**
|
||||||
|
|
||||||
|
Open app normally (no `?invite` param). Works as before, no console errors.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app.js
|
||||||
|
git commit -m "feat: add invite globals and refactored startup with URL token check"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Password reset UI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `showPasswordReset` function after `showLogin` in `app.js`**
|
||||||
|
|
||||||
|
```js
|
||||||
|
function showPasswordReset(){
|
||||||
|
var o=document.getElementById('ov');
|
||||||
|
o.innerHTML='<div class="sheet">'+
|
||||||
|
'<div class="shandle"></div>'+
|
||||||
|
'<div class="stitle">Passwort zurücksetzen</div>'+
|
||||||
|
'<div class="ssub">Wir senden dir einen Reset-Link per E-Mail</div>'+
|
||||||
|
'<div class="ff"><label>E-Mail</label><input class="fi" id="premail" type="email" autocomplete="email"/></div>'+
|
||||||
|
'<div class="factions">'+
|
||||||
|
'<button class="btn-p" id="prsub">Link senden</button>'+
|
||||||
|
'<button class="btn-c" id="prback">Zurück</button>'+
|
||||||
|
'</div>'+
|
||||||
|
'</div>';
|
||||||
|
o.onclick=null;
|
||||||
|
setTimeout(function(){ var el=document.getElementById('premail'); if(el) el.focus(); },50);
|
||||||
|
document.getElementById('prback').onclick=function(){ loginTab='login'; showLogin(); };
|
||||||
|
document.getElementById('prsub').onclick=function(){
|
||||||
|
var email=document.getElementById('premail').value.trim();
|
||||||
|
if(!email) return;
|
||||||
|
var btn=this; btn.disabled=true; btn.textContent='…';
|
||||||
|
api('POST','/api/collections/users/request-password-reset',{email:email})
|
||||||
|
.then(function(){
|
||||||
|
document.querySelector('#ov .sheet').innerHTML=
|
||||||
|
'<div class="shandle"></div>'+
|
||||||
|
'<div class="stitle">E-Mail gesendet</div>'+
|
||||||
|
'<div class="ssub">Falls ein Konto mit dieser Adresse existiert, hast du einen Link erhalten.</div>'+
|
||||||
|
'<div class="factions"><button class="btn-p" id="prdone">Zum Login</button></div>';
|
||||||
|
document.getElementById('prdone').onclick=function(){ loginTab='login'; showLogin(); };
|
||||||
|
})
|
||||||
|
.catch(function(){
|
||||||
|
btn.disabled=false; btn.textContent='Link senden';
|
||||||
|
showToast('Fehler beim Senden');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify**
|
||||||
|
|
||||||
|
Open app. The login sheet still works. (The "Passwort vergessen?" link is wired in the next task.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app.js
|
||||||
|
git commit -m "feat: add password reset sheet"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Login sheet refactor + registration form
|
||||||
|
|
||||||
|
> All six functions in this task must be added together in one commit — `showLogin` calls `renderRegisterForm` and `wireRegisterForm` which are defined here.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the entire `showLogin` function and add all six helper functions**
|
||||||
|
|
||||||
|
Replace the existing `showLogin` function in `app.js` with the following block. This is a complete replacement — remove the old function and insert all six functions in its place:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function showLogin(err){
|
||||||
|
var o=document.getElementById('ov');
|
||||||
|
o.style.cssText='display:flex;position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.5);align-items:flex-end;animation:fi .2s ease';
|
||||||
|
var hasInvite=!!inviteRecord;
|
||||||
|
var tabsHtml=hasInvite
|
||||||
|
?'<div class="ltabs">'+
|
||||||
|
'<button class="ltab'+(loginTab==='login'?' active':'')+'" id="ltab-login">Anmelden</button>'+
|
||||||
|
'<button class="ltab'+(loginTab==='register'?' active':'')+'" id="ltab-reg">Registrieren</button>'+
|
||||||
|
'</div>'
|
||||||
|
:'';
|
||||||
|
var formHtml=(hasInvite&&loginTab==='register')?renderRegisterForm(err):renderLoginForm(err);
|
||||||
|
o.innerHTML='<div class="sheet">'+
|
||||||
|
'<div class="shandle"></div>'+
|
||||||
|
'<div class="stitle">Anmelden</div>'+
|
||||||
|
'<div class="ssub">ZielTracker</div>'+
|
||||||
|
tabsHtml+formHtml+
|
||||||
|
'</div>';
|
||||||
|
o.onclick=null;
|
||||||
|
if(hasInvite){
|
||||||
|
document.getElementById('ltab-login').onclick=function(){loginTab='login';showLogin();};
|
||||||
|
document.getElementById('ltab-reg').onclick=function(){loginTab='register';showLogin();};
|
||||||
|
}
|
||||||
|
if(hasInvite&&loginTab==='register') wireRegisterForm();
|
||||||
|
else wireLoginForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLoginForm(err){
|
||||||
|
return (err?'<div class="login-err">'+err+'</div>':'')+
|
||||||
|
'<div class="ff"><label>E-Mail</label><input class="fi" id="lemail" type="email" autocomplete="email"/></div>'+
|
||||||
|
'<div class="ff"><label>Passwort</label><input class="fi" id="lpass" type="password" autocomplete="current-password"/></div>'+
|
||||||
|
'<div class="factions"><button class="btn-p" id="lsub">Anmelden</button></div>'+
|
||||||
|
'<button class="pw-link" id="lpw">Passwort vergessen?</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireLoginForm(){
|
||||||
|
setTimeout(function(){
|
||||||
|
var le=document.getElementById('lemail'),lp=document.getElementById('lpass');
|
||||||
|
if(le){le.focus();le.onkeydown=function(e){if(e.key==='Enter')lp.focus();};}
|
||||||
|
if(lp) lp.onkeydown=function(e){if(e.key==='Enter')document.getElementById('lsub').click();};
|
||||||
|
var lpw=document.getElementById('lpw');
|
||||||
|
if(lpw) lpw.onclick=showPasswordReset;
|
||||||
|
},50);
|
||||||
|
document.getElementById('lsub').onclick=function(){
|
||||||
|
var email=document.getElementById('lemail').value.trim();
|
||||||
|
var pass=document.getElementById('lpass').value;
|
||||||
|
if(!email||!pass) return;
|
||||||
|
var btn=this; btn.disabled=true; btn.textContent='…';
|
||||||
|
api('POST','/api/collections/users/auth-with-password',{identity:email,password:pass})
|
||||||
|
.then(function(res){
|
||||||
|
authToken=res.token; authUserId=res.record.id;
|
||||||
|
localStorage.setItem('zt_token',authToken);
|
||||||
|
localStorage.setItem('zt_uid',authUserId);
|
||||||
|
return pbGoals();
|
||||||
|
})
|
||||||
|
.then(function(g){ goals=g; closeOv(); render(); })
|
||||||
|
.catch(function(e){
|
||||||
|
btn.disabled=false; btn.textContent='Anmelden';
|
||||||
|
if(authToken){ closeOv(); render(); }
|
||||||
|
else{ showLogin(e.status===400||e.status===401?'Falsche E-Mail oder Passwort':'Verbindungsfehler'); }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRegisterForm(err){
|
||||||
|
return (err?'<div class="login-err">'+err+'</div>':'')+
|
||||||
|
'<div class="ssub" style="margin-bottom:8px">Eingeladen als: <strong>'+inviteRecord.label+'</strong></div>'+
|
||||||
|
'<div class="ff"><label>E-Mail</label><input class="fi" id="remail" type="email" autocomplete="email"/></div>'+
|
||||||
|
'<div class="ff"><label>Passwort</label><input class="fi" id="rpass" type="password" autocomplete="new-password"/></div>'+
|
||||||
|
'<div class="ff"><label>Passwort wiederholen</label><input class="fi" id="rpass2" type="password" autocomplete="new-password"/></div>'+
|
||||||
|
'<div class="factions"><button class="btn-p" id="rsub">Registrieren</button></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireRegisterForm(){
|
||||||
|
setTimeout(function(){ var el=document.getElementById('remail'); if(el) el.focus(); },50);
|
||||||
|
document.getElementById('rsub').onclick=function(){
|
||||||
|
var email=document.getElementById('remail').value.trim();
|
||||||
|
var pass=document.getElementById('rpass').value;
|
||||||
|
var pass2=document.getElementById('rpass2').value;
|
||||||
|
if(!email||!pass||!pass2) return;
|
||||||
|
if(pass!==pass2){showLogin('Passwörter stimmen nicht überein');return;}
|
||||||
|
if(pass.length<8){showLogin('Passwort muss mindestens 8 Zeichen haben');return;}
|
||||||
|
var btn=this; btn.disabled=true; btn.textContent='…';
|
||||||
|
var savedInviteId=inviteRecord.id;
|
||||||
|
api('POST','/api/collections/users/records',{email:email,password:pass,passwordConfirm:pass2})
|
||||||
|
.then(function(){
|
||||||
|
return api('POST','/api/collections/users/auth-with-password',{identity:email,password:pass});
|
||||||
|
})
|
||||||
|
.then(function(res){
|
||||||
|
authToken=res.token; authUserId=res.record.id;
|
||||||
|
localStorage.setItem('zt_token',authToken);
|
||||||
|
localStorage.setItem('zt_uid',authUserId);
|
||||||
|
return api('PATCH','/api/collections/invites/records/'+savedInviteId,{used_by:authUserId,used_at:new Date().toISOString()});
|
||||||
|
})
|
||||||
|
.then(function(){
|
||||||
|
inviteRecord=null;
|
||||||
|
return pbGoals();
|
||||||
|
})
|
||||||
|
.then(function(g){ goals=g; closeOv(); render(); })
|
||||||
|
.catch(function(e){
|
||||||
|
btn.disabled=false; btn.textContent='Registrieren';
|
||||||
|
showLogin(e.status===400?'E-Mail bereits vergeben oder ungültig':'Registrierung fehlgeschlagen');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify login still works**
|
||||||
|
|
||||||
|
Open app normally (no `?invite` param). Login works as before. "Passwort vergessen?" link appears and opens the reset sheet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify registration flow**
|
||||||
|
|
||||||
|
1. In PocketBase admin, create a test invite: token = `testtoken123`, label = `Testkind`, used_by = empty.
|
||||||
|
2. Open `http://miniweb.kuehn.home/?invite=testtoken123` in a private window.
|
||||||
|
3. Login sheet shows two tabs: "Anmelden" / "Registrieren".
|
||||||
|
4. Click "Registrieren" → form shows "Eingeladen als: **Testkind**".
|
||||||
|
5. Enter mismatched passwords → error "Passwörter stimmen nicht überein".
|
||||||
|
6. Enter a 7-char password → error "Passwort muss mindestens 8 Zeichen haben".
|
||||||
|
7. Enter valid email + matching passwords (8+ chars) → user created, logged in, goals list shown.
|
||||||
|
8. In PocketBase admin, confirm invite record has `used_by` and `used_at` set.
|
||||||
|
9. Open the same link again in a private window → only "Anmelden" tab visible (token is used).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app.js
|
||||||
|
git commit -m "feat: refactor login sheet with tabs, registration form, and password-reset link"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Invite management UI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app.js`
|
||||||
|
- Modify: `style.css`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `genToken` utility function to `app.js`**
|
||||||
|
|
||||||
|
Add after the `now()` function (around line 16):
|
||||||
|
|
||||||
|
```js
|
||||||
|
function genToken(){
|
||||||
|
var c='ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789',t='';
|
||||||
|
for(var i=0;i<12;i++) t+=c[Math.floor(Math.random()*c.length)];
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `showInvites` and `showNewInvite` functions after `openData` in `app.js`**
|
||||||
|
|
||||||
|
```js
|
||||||
|
function showInvites(){
|
||||||
|
api('GET','/api/collections/invites/records?filter='+encodeURIComponent('(created_by="'+authUserId+'")&sort=-created&expand=used_by'))
|
||||||
|
.then(function(res){
|
||||||
|
var items=res.items||[];
|
||||||
|
var rows='';
|
||||||
|
for(var i=0;i<items.length;i++){
|
||||||
|
var inv=items[i];
|
||||||
|
var usedEmail=inv.expand&&inv.expand.used_by?inv.expand.used_by.email:'registriert';
|
||||||
|
var status=inv.used_by
|
||||||
|
?'<span style="color:var(--green)">✓ '+usedEmail+'</span>'
|
||||||
|
:'<span style="color:var(--text3)">Ausstehend</span>';
|
||||||
|
rows+='<div class="inv-row"><span class="inv-lbl">'+inv.label+'</span>'+status+'</div>';
|
||||||
|
}
|
||||||
|
showSheet(
|
||||||
|
'<div class="shandle"></div>'+
|
||||||
|
'<div class="stitle">Einladungen</div>'+
|
||||||
|
(rows||'<div style="color:var(--text3);padding:12px 0;font-size:14px">Noch keine Einladungen verschickt.</div>')+
|
||||||
|
'<div class="ddiv"></div>'+
|
||||||
|
'<button class="btn-p" id="inew" style="width:100%">+ Neue Einladung</button>'+
|
||||||
|
'<button class="btn-c" id="icls" style="width:100%;margin-top:4px;text-align:center">Schließen</button>'
|
||||||
|
);
|
||||||
|
document.getElementById('icls').onclick=closeOv;
|
||||||
|
document.getElementById('inew').onclick=showNewInvite;
|
||||||
|
})
|
||||||
|
.catch(function(){ showToast('Fehler beim Laden'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNewInvite(){
|
||||||
|
showSheet(
|
||||||
|
'<div class="shandle"></div>'+
|
||||||
|
'<div class="stitle">Neue Einladung</div>'+
|
||||||
|
'<div class="ff"><label>Name / Für wen?</label><input class="fi" id="ilabel" type="text" placeholder="z.B. Mama"/></div>'+
|
||||||
|
'<div class="factions">'+
|
||||||
|
'<button class="btn-p" id="isub">Einladung erstellen</button>'+
|
||||||
|
'<button class="btn-c" id="iback">Zurück</button>'+
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
setTimeout(function(){ var el=document.getElementById('ilabel'); if(el) el.focus(); },50);
|
||||||
|
document.getElementById('iback').onclick=showInvites;
|
||||||
|
document.getElementById('isub').onclick=function(){
|
||||||
|
var label=(document.getElementById('ilabel').value||'').trim();
|
||||||
|
if(!label) return;
|
||||||
|
var btn=this; btn.disabled=true; btn.textContent='…';
|
||||||
|
var token=genToken();
|
||||||
|
api('POST','/api/collections/invites/records',{token:token,label:label,created_by:authUserId})
|
||||||
|
.then(function(){
|
||||||
|
var link=APP_URL+'/?invite='+token;
|
||||||
|
showSheet(
|
||||||
|
'<div class="shandle"></div>'+
|
||||||
|
'<div class="stitle">Einladung erstellt</div>'+
|
||||||
|
'<div class="ssub">Teile diesen Link mit '+label+':</div>'+
|
||||||
|
'<div class="inv-link" id="ilink">'+link+'</div>'+
|
||||||
|
'<div class="factions">'+
|
||||||
|
'<button class="btn-p" id="icopy">Link kopieren</button>'+
|
||||||
|
'<button class="btn-c" id="idone">Fertig</button>'+
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
document.getElementById('idone').onclick=closeOv;
|
||||||
|
document.getElementById('icopy').onclick=function(){
|
||||||
|
navigator.clipboard.writeText(link).then(function(){ showToast('Link kopiert!'); });
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch(function(){ btn.disabled=false; btn.textContent='Einladung erstellen'; showToast('Fehler'); });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add "Einladungen" button to `openData`**
|
||||||
|
|
||||||
|
In `openData`, the sheet HTML starts with the export button. Add a new button and divider at the very beginning of the sheet content (before the export button):
|
||||||
|
|
||||||
|
Replace the beginning of `openData`'s sheet HTML:
|
||||||
|
```js
|
||||||
|
'<button class="dbtn" id="dexp"><span class="dico">⬇</span><span class="dlbl">Exportieren<span class="dsub">Alle Ziele als JSON-Datei speichern</span></span></button>'+
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
```js
|
||||||
|
'<button class="dbtn" id="dinv"><span class="dico">✉</span><span class="dlbl">Einladungen<span class="dsub">Freunde und Familie einladen</span></span></button>'+
|
||||||
|
'<div class="ddiv"></div>'+
|
||||||
|
'<button class="dbtn" id="dexp"><span class="dico">⬇</span><span class="dlbl">Exportieren<span class="dsub">Alle Ziele als JSON-Datei speichern</span></span></button>'+
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the event binding after `document.getElementById('dcls').onclick=closeOv;`:
|
||||||
|
```js
|
||||||
|
document.getElementById('dinv').onclick=function(){ closeOv(); showInvites(); };
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify invite management**
|
||||||
|
|
||||||
|
1. Log in, open ⋯ menu → "Einladungen" button appears at the top.
|
||||||
|
2. Click → invite list sheet opens (empty).
|
||||||
|
3. "Neue Einladung" → enter "Papa" → "Einladung erstellen".
|
||||||
|
4. Result sheet shows invite link with "Link kopieren" button.
|
||||||
|
5. "Link kopieren" → toast "Link kopiert!" appears, clipboard contains the URL.
|
||||||
|
6. "Fertig" → sheet closes.
|
||||||
|
7. Open ⋯ → Einladungen → "Papa" listed as "Ausstehend".
|
||||||
|
8. Open the invite link in a private window and register.
|
||||||
|
9. Back in original session: ⋯ → Einladungen → "Papa" shows "✓ [email]".
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app.js style.css
|
||||||
|
git commit -m "feat: add invite management UI with create, list, and referral tracking"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Done
|
||||||
|
|
||||||
|
After Task 8, the full feature set is live:
|
||||||
|
- `config.js` holds server URLs (update for production deployment)
|
||||||
|
- Invite links are the only way to register
|
||||||
|
- Password reset works via email (requires PocketBase SMTP configured in Task 3)
|
||||||
|
- Invite list shows referral status per person
|
||||||
107
docs/superpowers/specs/2026-04-17-refactor-design.md
Normal file
107
docs/superpowers/specs/2026-04-17-refactor-design.md
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
# Zieltracker — Refactor & Design-Update
|
||||||
|
|
||||||
|
**Datum:** 2026-04-17
|
||||||
|
|
||||||
|
## Überblick
|
||||||
|
|
||||||
|
Refactoring der Single-File-App in separate Dateien, neue einklappbare Karten, überarbeitete Stats, Desktop-Layout und Sortierung nach Tagesstatus.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Dateistruktur
|
||||||
|
|
||||||
|
Aus `index.html` (alles inline) werden drei Dateien:
|
||||||
|
|
||||||
|
- `index.html` — nur HTML-Gerüst + `<link>` + `<script>`
|
||||||
|
- `style.css` — alle Styles
|
||||||
|
- `app.js` — gesamte Logik
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Layout — Desktop max-width
|
||||||
|
|
||||||
|
```css
|
||||||
|
.main-wrap {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Header und Content-Bereich beide im `max-width`-Container
|
||||||
|
- Außerhalb: `background: var(--bg)` bleibt sichtbar als grauer Rand
|
||||||
|
- Auf Handy ändert sich nichts (412px Breite des OnePlus 9 Pro passt rein)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Einklappbare Karten
|
||||||
|
|
||||||
|
### Standardverhalten
|
||||||
|
- Alle Karten starten **eingeklappt** beim Laden
|
||||||
|
- Kein Persist in localStorage — jeder Reload beginnt komplett eingeklappt
|
||||||
|
- Klick auf Karte (Header-Bereich) klappt auf/zu
|
||||||
|
- Pfeil: `▸` eingeklappt, `▴` ausgeklappt
|
||||||
|
|
||||||
|
### Sortierung
|
||||||
|
1. **Offen** (heute noch nicht erledigt) — oben, normale Opazität
|
||||||
|
2. **Heute erledigt** — unten, leicht abgedunkelt (`opacity: 0.7`)
|
||||||
|
|
||||||
|
Trennlabels zwischen den Gruppen: `OFFEN` / `HEUTE ERLEDIGT` (10px, monospace, uppercase, grau)
|
||||||
|
|
||||||
|
### Eingeklappt zeigt
|
||||||
|
```
|
||||||
|
[Name] [✎]
|
||||||
|
[Noch XT · endet DD. Mon · heute: GEMACHT/TAGESZIEL · total: DONE/TOTAL]
|
||||||
|
[Badge] [▸]
|
||||||
|
[Fortschrittsbalken]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `heute: X/Y` farbkodiert: rot (0), amber (teilweise), grün (erreicht), blau (über Ziel)
|
||||||
|
- Fortschrittsbalken ohne Beschriftung darunter
|
||||||
|
|
||||||
|
### Ausgeklappt zeigt
|
||||||
|
```
|
||||||
|
[Name] [✎]
|
||||||
|
[Noch XT · endet DD. Mon]
|
||||||
|
[Badge] [▴]
|
||||||
|
[Fortschrittsbalken]
|
||||||
|
[X Stück gemacht 62% von 500] ← prog-row
|
||||||
|
[Heute-Stats-Block]
|
||||||
|
[Dots + Legende]
|
||||||
|
[Eingabe-Panel (wenn Tag auswählbar)]
|
||||||
|
[Ziel löschen]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Stats — Umbau
|
||||||
|
|
||||||
|
### Alt (4 Blöcke, immer sichtbar)
|
||||||
|
Heute noch · Gemacht · Puffer · Täglich
|
||||||
|
|
||||||
|
### Neu (1 Gruppe "Heute", 3 Blöcke, nur ausgeklappt)
|
||||||
|
| Gemacht | Tagesziel | Noch |
|
||||||
|
|---------|-----------|------|
|
||||||
|
| 30 Stk | 50 Stk | 20 Stk (farbig) |
|
||||||
|
|
||||||
|
- Gruppe hat Label "HEUTE" (9px monospace uppercase)
|
||||||
|
- `Noch` farbkodiert identisch zu `heute:` in der Meta-Zeile
|
||||||
|
- Puffer und Täglich entfallen als eigene Blöcke (Badge zeigt Puffer/Rückstand bereits)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Eingabe
|
||||||
|
|
||||||
|
Bleibt unverändert: Freie Zahleingabe + `+ Satz` Button.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Zusammenfassung der Änderungen
|
||||||
|
|
||||||
|
| Was | Vorher | Nachher |
|
||||||
|
|-----|--------|---------|
|
||||||
|
| Dateistruktur | 1 HTML-Datei | index.html + style.css + app.js |
|
||||||
|
| Desktop-Breite | volle Breite | max-width 480px, zentriert |
|
||||||
|
| Karten | immer ausgeklappt | eingeklappt, aufklappbar |
|
||||||
|
| Sortierung | Erstellungsreihenfolge | Offen zuerst, Erledigt unten |
|
||||||
|
| Stats | 4 Blöcke (Heute noch/Gemacht/Puffer/Täglich) | 3 Blöcke Gruppe "Heute" |
|
||||||
|
| Meta-Zeile (eingeklappt) | Noch XT · endet DD | + heute: X/Y · total: X/Y |
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
# Design: Registrierung & Einladungssystem
|
||||||
|
|
||||||
|
**Datum:** 2026-04-21
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Zieltracker für Freunde und Familie öffnen: Registrierung nur per Einladungslink, Passwort-Reset per E-Mail, Referral-Tracking pro User.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Konfiguration (`config.js`)
|
||||||
|
|
||||||
|
Neue Datei `config.js`, die vor `app.js` eingebunden wird und die zwei globalen Variablen definiert:
|
||||||
|
|
||||||
|
```js
|
||||||
|
var PB_URL = 'https://pb.kuehn.example.com';
|
||||||
|
var APP_URL = 'https://zieltracker.kuehn.example.com';
|
||||||
|
```
|
||||||
|
|
||||||
|
- `PB_URL` ersetzt die bisherige Hardcodierung in `app.js`
|
||||||
|
- `APP_URL` wird verwendet, um Einladungslinks zu bauen (`APP_URL + '/?invite=' + token`)
|
||||||
|
- Wird einmalig auf dem Server angepasst — kein UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. PocketBase: `invites`-Collection
|
||||||
|
|
||||||
|
| Feld | Typ | Constraints |
|
||||||
|
|---|---|---|
|
||||||
|
| `token` | text | unique, required |
|
||||||
|
| `label` | text | required (z.B. "Für Mama") |
|
||||||
|
| `created_by` | relation → users | required |
|
||||||
|
| `used_by` | relation → users | optional |
|
||||||
|
| `used_at` | date | optional |
|
||||||
|
|
||||||
|
**API-Regeln:**
|
||||||
|
- Lesen (einzelner Token via Filter): public — zum Validieren beim Registrieren
|
||||||
|
- Erstellen: nur authentifizierte User (`@request.auth.id != ""`)
|
||||||
|
- Update (`used_by`, `used_at`): public — wird beim Registrieren gesetzt
|
||||||
|
- Löschen: nur der Ersteller
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. PocketBase: E-Mail / SMTP
|
||||||
|
|
||||||
|
PocketBase Admin → Settings → Mail settings:
|
||||||
|
|
||||||
|
- Provider: Brevo oder Resend (externer SMTP-Dienst, kostenloser Tier)
|
||||||
|
- PocketBase schickt nativ: E-Mail-Verifikation nach Registrierung, Passwort-Reset-Link
|
||||||
|
- Kein eigener Code nötig
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. UI-Flows
|
||||||
|
|
||||||
|
### 4.1 Einladung erstellen (eingeloggter User)
|
||||||
|
|
||||||
|
Im "Daten verwalten"-Sheet (⋯-Menü):
|
||||||
|
- Neuer Button **"Einladungen"**
|
||||||
|
- Öffnet Sheet mit Liste aller eigenen Einladungen:
|
||||||
|
- Label + Status ("Ausstehend" oder "✓ Registriert")
|
||||||
|
- Button **"Neue Einladung"**:
|
||||||
|
1. Eingabe: Label (z.B. "Für Papa")
|
||||||
|
2. Generiert Token (12 zufällige alphanumerische Zeichen)
|
||||||
|
3. Schreibt Token in `invites`-Collection via PocketBase API
|
||||||
|
4. Zeigt fertigen Link zum Kopieren: `APP_URL/?invite=TOKEN`
|
||||||
|
|
||||||
|
### 4.2 Registrierung (Eingeladener)
|
||||||
|
|
||||||
|
- User öffnet Einladungslink: `https://zieltracker.../?invite=TOKEN`
|
||||||
|
- App liest `?invite`-Parameter aus URL
|
||||||
|
- App prüft Token gegen PocketBase (`invites`-Collection, Filter: `token=TOKEN && used_by=""`)
|
||||||
|
- Ist Token gültig und ungenutzt: Login-Sheet zeigt zusätzlichen Tab **"Registrieren"**
|
||||||
|
- Ist Token ungültig/bereits genutzt: nur Login, kein Registrierungs-Tab
|
||||||
|
- Registrierungsformular: E-Mail, Passwort, Passwort wiederholen
|
||||||
|
- Nach Erfolg:
|
||||||
|
1. PocketBase-User wird angelegt (Standard-Auth-Flow)
|
||||||
|
2. Token wird als `used_by = neuer User`, `used_at = jetzt` markiert
|
||||||
|
3. User ist eingeloggt, Ziele werden geladen
|
||||||
|
|
||||||
|
### 4.3 Passwort vergessen
|
||||||
|
|
||||||
|
Im Login-Sheet:
|
||||||
|
- Kleiner Link **"Passwort vergessen?"** unter dem Anmelden-Button
|
||||||
|
- Öffnet Mini-Sheet mit E-Mail-Eingabe
|
||||||
|
- Button **"Reset-Link senden"**
|
||||||
|
- Ruft PocketBase-Endpoint auf: `POST /api/collections/users/request-password-reset`
|
||||||
|
- Zeigt Bestätigung: "E-Mail wurde gesendet" (unabhängig ob Adresse existiert)
|
||||||
|
- PocketBase verschickt den Reset-Link — kein eigener Template-Code nötig
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Nicht im Scope
|
||||||
|
|
||||||
|
- Admin-Funktion zum Deaktivieren von Accounts
|
||||||
|
- Invite-Limit pro User
|
||||||
|
- Ablaufdatum für Einladungslinks
|
||||||
60
include/db.php
Normal file
60
include/db.php
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
|
||||||
|
$conn = \Doctrine\DBAL\DriverManager::getConnection([
|
||||||
|
'driver' => 'pdo_mysql',
|
||||||
|
'host' => DB_HOST,
|
||||||
|
'dbname' => DB_NAME,
|
||||||
|
'user' => DB_USER,
|
||||||
|
'password' => DB_PASS,
|
||||||
|
'charset' => 'utf8mb4',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// PDO für delight-im/auth
|
||||||
|
$pdo = $conn->getNativeConnection();
|
||||||
|
$auth = new \Delight\Auth\Auth($pdo);
|
||||||
|
|
||||||
|
// Tabellen anlegen falls nicht vorhanden
|
||||||
|
$conn->executeStatement('
|
||||||
|
CREATE TABLE IF NOT EXISTS goals (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
unit VARCHAR(50) NOT NULL DEFAULT \'Stück\',
|
||||||
|
daily FLOAT NOT NULL DEFAULT 1,
|
||||||
|
days INT NOT NULL DEFAULT 30,
|
||||||
|
start DATETIME NOT NULL,
|
||||||
|
sets JSON NOT NULL DEFAULT (\'[]\'),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||||
|
');
|
||||||
|
|
||||||
|
$conn->executeStatement('
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||||
|
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||||
|
');
|
||||||
|
|
||||||
|
$conn->executeStatement('
|
||||||
|
CREATE TABLE IF NOT EXISTS invites (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
token VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
note VARCHAR(255) DEFAULT NULL,
|
||||||
|
created_by INT NOT NULL,
|
||||||
|
used_by INT DEFAULT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TIMESTAMP NOT NULL,
|
||||||
|
used_at TIMESTAMP DEFAULT NULL
|
||||||
|
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||||
|
');
|
||||||
|
|
||||||
|
// Migrations ausführen
|
||||||
|
foreach (glob(__DIR__ . '/../migrations/*.sql') as $file) {
|
||||||
|
$version = basename($file, '.sql');
|
||||||
|
if (!$conn->fetchOne('SELECT 1 FROM schema_migrations WHERE version = ?', [$version])) {
|
||||||
|
$conn->executeStatement(file_get_contents($file));
|
||||||
|
$conn->executeStatement('INSERT INTO schema_migrations (version) VALUES (?)', [$version]);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
include/mailer.php
Normal file
19
include/mailer.php
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?php
|
||||||
|
use PHPMailer\PHPMailer\PHPMailer;
|
||||||
|
|
||||||
|
function sendMail(string $to, string $subject, string $body): void {
|
||||||
|
$mail = new PHPMailer(true);
|
||||||
|
$mail->isSMTP();
|
||||||
|
$mail->Host = SMTP_HOST;
|
||||||
|
$mail->SMTPAuth = true;
|
||||||
|
$mail->Username = SMTP_USER;
|
||||||
|
$mail->Password = SMTP_PASS;
|
||||||
|
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
|
||||||
|
$mail->Port = SMTP_PORT;
|
||||||
|
$mail->CharSet = 'UTF-8';
|
||||||
|
$mail->setFrom(SMTP_FROM, SMTP_FROM_NAME);
|
||||||
|
$mail->addAddress($to);
|
||||||
|
$mail->Subject = $subject;
|
||||||
|
$mail->Body = $body;
|
||||||
|
$mail->send();
|
||||||
|
}
|
||||||
8
index.php
Normal file
8
index.php
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
$twig = new \Twig\Environment(
|
||||||
|
new \Twig\Loader\FilesystemLoader(__DIR__ . '/templates'),
|
||||||
|
['cache' => false]
|
||||||
|
);
|
||||||
|
echo $twig->render('app.html.twig');
|
||||||
BIN
logo.png
Normal file
BIN
logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
1
migrations/001_add_invites_note.sql
Normal file
1
migrations/001_add_invites_note.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE invites ADD COLUMN IF NOT EXISTS note VARCHAR(255) DEFAULT NULL AFTER token;
|
||||||
17
public/.htaccess
Normal file
17
public/.htaccess
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
DirectoryIndex index.php
|
||||||
|
|
||||||
|
<IfModule mod_negotiation.c>
|
||||||
|
Options -MultiViews
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
<IfModule mod_rewrite.c>
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteBase /
|
||||||
|
|
||||||
|
# Composer-Dateien sperren (liegen außerhalb public/, trotzdem sicher)
|
||||||
|
RewriteRule ^(composer\.(json|lock))$ - [F,L]
|
||||||
|
|
||||||
|
# Static files direkt ausliefern
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteRule ^ index.php [L]
|
||||||
|
</IfModule>
|
||||||
716
public/app.js
Normal file
716
public/app.js
Normal file
|
|
@ -0,0 +1,716 @@
|
||||||
|
var TODAY = new Date(); TODAY.setHours(0,0,0,0);
|
||||||
|
var goals = [], prefs, selDay = {}, addAmt = {}, renamingId = null, renameVal = '', collapsed = {};
|
||||||
|
var userName = '';
|
||||||
|
|
||||||
|
function loadPref(k,def){ try{ return JSON.parse(localStorage.getItem(k)||def); }catch(e){ return JSON.parse(def); } }
|
||||||
|
function saveP(){ localStorage.setItem('zt_p',JSON.stringify(prefs)); }
|
||||||
|
prefs = loadPref('zt_p','{}');
|
||||||
|
|
||||||
|
function tOff(g){ return Math.round((TODAY - new Date(g.start))/86400000); }
|
||||||
|
function o2d(g,i){ var d=new Date(new Date(g.start).getTime()+i*86400000); d.setHours(0,0,0,0); return d; }
|
||||||
|
function dTot(g,o){ return (g.sets[String(o)]||[]).reduce(function(a,b){return a+b.amount;},0); }
|
||||||
|
function fd(d){ return d.toLocaleDateString('de-DE',{weekday:'short',day:'numeric',month:'short'}); }
|
||||||
|
function fs(d){ return d.toLocaleDateString('de-DE',{day:'numeric',month:'short'}); }
|
||||||
|
function editable(g,o){ var t=tOff(g); return o===t||o===t-1; }
|
||||||
|
function now(){ var n=new Date(); return String(n.getHours()).padStart(2,'0')+':'+String(n.getMinutes()).padStart(2,'0'); }
|
||||||
|
|
||||||
|
function heuteColor(tdone,daily){
|
||||||
|
if(tdone===0) return 'var(--red)';
|
||||||
|
if(tdone>=daily*1.1) return 'var(--blue)';
|
||||||
|
if(tdone>=daily) return 'var(--green)';
|
||||||
|
return 'var(--amber)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCollapsed(id){ return collapsed[id]!==false; }
|
||||||
|
function toggleCollapse(id){
|
||||||
|
var wasCollapsed=isCollapsed(id);
|
||||||
|
collapsed[id]=!wasCollapsed;
|
||||||
|
if(wasCollapsed){
|
||||||
|
var g=goals.filter(function(x){return x.id===id;})[0];
|
||||||
|
if(g) selDay[id]=tOff(g);
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function calc(g){
|
||||||
|
var t=tOff(g), tot=g.daily*g.days;
|
||||||
|
var dr=Math.max(0,g.days-t-1);
|
||||||
|
var sd=new Date(g.start); sd.setHours(0,0,0,0);
|
||||||
|
var end=new Date(sd.getTime()+g.days*86400000);
|
||||||
|
var past=0;
|
||||||
|
for(var i=0;i<Math.min(t,g.days);i++) past+=dTot(g,i);
|
||||||
|
var tdone=dTot(g,t), tot2=past+tdone;
|
||||||
|
var dl=dr+1;
|
||||||
|
var remaining=Math.max(0,tot-past);
|
||||||
|
var pd=Math.ceil(remaining/Math.max(1,dl));
|
||||||
|
var st=Math.max(0,pd-tdone);
|
||||||
|
var expectedPast=Math.min(t,g.days)*g.daily;
|
||||||
|
var buf=(past-expectedPast)+Math.max(0,tdone-g.daily);
|
||||||
|
var deficit=Math.min(0,buf);
|
||||||
|
var surplus=Math.max(0,buf);
|
||||||
|
var dailyDelta=pd-g.daily;
|
||||||
|
var pct=Math.min(100,Math.round((tot2/tot)*100));
|
||||||
|
return{tot:tot,tOff:t,end:end,dr:dr,done:tot2,tdone:tdone,pd:pd,st:st,buf:buf,deficit:deficit,surplus:surplus,dailyDelta:dailyDelta,net:tdone-pd,pct:pct,ok:tdone>=pd};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dcls(g,i){
|
||||||
|
var t=tOff(g); if(i>t) return 'dot df';
|
||||||
|
var v=dTot(g,i);
|
||||||
|
var c=v===0?'dot dm':v>=g.daily*1.1?'dot db':v>=g.daily?'dot dd':'dot dp';
|
||||||
|
return c+(editable(g,i)?' de':' dl');
|
||||||
|
}
|
||||||
|
function dlbl(g,i){
|
||||||
|
var t=tOff(g); if(i>t) return String(i+1);
|
||||||
|
var v=dTot(g,i);
|
||||||
|
if(v===0) return '✕'; if(v>=g.daily*1.1) return '+'; if(v>=g.daily) return '✓';
|
||||||
|
return Math.round(v/g.daily*100)+'%';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function api(method, path, body){
|
||||||
|
var opts = {method:method, credentials:'include', headers:{'Content-Type':'application/json'}};
|
||||||
|
if(body) opts.body = JSON.stringify(body);
|
||||||
|
return fetch('api/' + path, opts).then(function(res){
|
||||||
|
return res.json().then(function(data){
|
||||||
|
if(!res.ok){ var e=new Error(data.error||'Fehler'); e.status=res.status; throw e; }
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadGoals(){
|
||||||
|
return api('GET','goals').then(function(data){ return data; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveGoal(g){
|
||||||
|
api('PATCH','goals/'+g.id,{name:g.name,unit:g.unit,daily:g.daily,days:g.days,start:g.start,sets:g.sets})
|
||||||
|
.catch(function(e){
|
||||||
|
if(e.status===401){ showLogin(); }
|
||||||
|
else showToast('Speicherfehler');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toast ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function showToast(msg){
|
||||||
|
var t=document.createElement('div'); t.className='toast'; t.textContent=msg;
|
||||||
|
document.body.appendChild(t); setTimeout(function(){t.remove();},3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Goal-Aktionen ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function addSet(gid,off){
|
||||||
|
var g=goals.filter(function(x){return x.id===gid;})[0];
|
||||||
|
if(!g||!editable(g,off)) return;
|
||||||
|
var k=gid+'_'+off, amt=parseInt(addAmt[k]||'0',10);
|
||||||
|
if(amt<=0) return;
|
||||||
|
if(!g.sets[String(off)]) g.sets[String(off)]=[];
|
||||||
|
g.sets[String(off)].push({amount:amt,time:off===tOff(g)?now():'—'});
|
||||||
|
addAmt[k]=''; saveGoal(g); render();
|
||||||
|
}
|
||||||
|
function remSet(gid,off,idx){
|
||||||
|
var g=goals.filter(function(x){return x.id===gid;})[0];
|
||||||
|
if(!g||!editable(g,off)) return;
|
||||||
|
g.sets[String(off)].splice(idx,1); saveGoal(g); render();
|
||||||
|
}
|
||||||
|
function delGoal(id){
|
||||||
|
if(!confirm('Ziel wirklich löschen?')) return;
|
||||||
|
goals=goals.filter(function(g){return g.id!==id;});
|
||||||
|
render();
|
||||||
|
api('DELETE','goals/'+id).catch(function(){ showToast('Fehler beim Löschen'); });
|
||||||
|
}
|
||||||
|
function selD(gid,off){
|
||||||
|
var g=goals.filter(function(x){return x.id===gid;})[0];
|
||||||
|
if(!g||!editable(g,off)) return;
|
||||||
|
selDay[gid]=selDay[gid]===off?null:off; render();
|
||||||
|
}
|
||||||
|
function startRen(id){
|
||||||
|
var g=goals.filter(function(x){return x.id===id;})[0]; if(!g) return;
|
||||||
|
renamingId=id; renameVal=g.name; render();
|
||||||
|
setTimeout(function(){ var el=document.getElementById('ri'+id); if(el){el.focus();el.select();} },50);
|
||||||
|
}
|
||||||
|
function commitRen(id){
|
||||||
|
var g=goals.filter(function(x){return x.id===id;})[0];
|
||||||
|
if(g&&renameVal.trim()){g.name=renameVal.trim(); saveGoal(g);}
|
||||||
|
renamingId=null; render();
|
||||||
|
}
|
||||||
|
function cancelRen(){ renamingId=null; render(); }
|
||||||
|
|
||||||
|
// ── Template-Helper ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function tpl(id){
|
||||||
|
return document.getElementById(id).content.cloneNode(true).firstElementChild;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Overlays ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var OV_CSS='display:flex;position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.5);align-items:flex-end;justify-content:center;animation:fi .2s ease';
|
||||||
|
|
||||||
|
function closeOv(){
|
||||||
|
var o=document.getElementById('ov');
|
||||||
|
o.style.display='none';
|
||||||
|
o.innerHTML='';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSheet(content, dismissable){
|
||||||
|
var o=document.getElementById('ov');
|
||||||
|
o.style.cssText=OV_CSS;
|
||||||
|
var sheet=tpl('tpl-sheet');
|
||||||
|
sheet.appendChild(content);
|
||||||
|
o.innerHTML='';
|
||||||
|
o.appendChild(sheet);
|
||||||
|
o.onclick=dismissable!==false?function(e){if(e.target===o)closeOv();}:null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Login ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function showLogin(err){
|
||||||
|
var c=tpl('tpl-login');
|
||||||
|
if(err){ var e=c.querySelector('.login-err'); e.textContent=err; e.style.display=''; }
|
||||||
|
showSheet(c,false);
|
||||||
|
var email=c.querySelector('.lf-email'), pass=c.querySelector('.lf-pass'), sub=c.querySelector('.lf-sub');
|
||||||
|
setTimeout(function(){email.focus();},50);
|
||||||
|
email.onkeydown=function(e){if(e.key==='Enter')pass.focus();};
|
||||||
|
pass.onkeydown=function(e){if(e.key==='Enter')sub.click();};
|
||||||
|
c.querySelector('.lf-fgt').onclick=function(){showForgotPassword();};
|
||||||
|
sub.onclick=function(){
|
||||||
|
var ev=email.value.trim(), pv=pass.value;
|
||||||
|
if(!ev||!pv){ var errEl=c.querySelector('.login-err'); errEl.textContent='Bitte E-Mail und Passwort eingeben'; errEl.style.display=''; return; }
|
||||||
|
sub.disabled=true; sub.textContent='…';
|
||||||
|
api('POST','login',{email:ev,password:pv})
|
||||||
|
.then(function(){ return loadGoals(); })
|
||||||
|
.then(function(g){ goals=g; closeOv(); render(); })
|
||||||
|
.catch(function(err){
|
||||||
|
sub.disabled=false; sub.textContent='Anmelden';
|
||||||
|
showLogin(err.status===401?'Falsche E-Mail oder Passwort':err.status===429?'Zu viele Versuche':'Verbindungsfehler');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Passwort vergessen ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function showForgotPassword(){
|
||||||
|
var c=tpl('tpl-forgot-pw');
|
||||||
|
showSheet(c,false);
|
||||||
|
var email=c.querySelector('.fp-email'), errEl=c.querySelector('.login-err'), sub=c.querySelector('.fp-sub');
|
||||||
|
setTimeout(function(){email.focus();},50);
|
||||||
|
c.querySelector('.fp-back').onclick=function(){showLogin();};
|
||||||
|
sub.onclick=function(){
|
||||||
|
var ev=email.value.trim(); if(!ev) return;
|
||||||
|
sub.disabled=true; sub.textContent='…';
|
||||||
|
api('POST','reset-request',{email:ev})
|
||||||
|
.then(function(){
|
||||||
|
var conf=tpl('tpl-email-sent');
|
||||||
|
conf.querySelector('.es-ok').onclick=function(){showLogin();};
|
||||||
|
showSheet(conf,false);
|
||||||
|
})
|
||||||
|
.catch(function(err){
|
||||||
|
sub.disabled=false; sub.textContent='Link senden';
|
||||||
|
errEl.textContent=err.message||'Fehler'; errEl.style.display='';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Passwort zurücksetzen ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function showResetPassword(selector,token){
|
||||||
|
var c=tpl('tpl-reset-pw');
|
||||||
|
showSheet(c,false);
|
||||||
|
var pass=c.querySelector('.rp-pass'), errEl=c.querySelector('.login-err'), sub=c.querySelector('.rp-sub');
|
||||||
|
setTimeout(function(){pass.focus();},50);
|
||||||
|
sub.onclick=function(){
|
||||||
|
var pv=pass.value; if(!pv) return;
|
||||||
|
sub.disabled=true; sub.textContent='…';
|
||||||
|
api('POST','reset-password',{selector:selector,token:token,password:pv})
|
||||||
|
.then(function(){
|
||||||
|
var conf=tpl('tpl-pw-changed');
|
||||||
|
conf.querySelector('.pc-ok').onclick=function(){showLogin();};
|
||||||
|
showSheet(conf,false);
|
||||||
|
})
|
||||||
|
.catch(function(err){
|
||||||
|
sub.disabled=false; sub.textContent='Passwort setzen';
|
||||||
|
errEl.textContent=err.message||'Fehler'; errEl.style.display='';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Passwort ändern ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function showChangePassword(){
|
||||||
|
var c=tpl('tpl-change-pw');
|
||||||
|
showSheet(c,true);
|
||||||
|
var oldP=c.querySelector('.cp-old'), newP=c.querySelector('.cp-new'), newP2=c.querySelector('.cp-new2');
|
||||||
|
var errEl=c.querySelector('.login-err'), sub=c.querySelector('.cp-sub');
|
||||||
|
setTimeout(function(){oldP.focus();},50);
|
||||||
|
c.querySelector('.cp-can').onclick=closeOv;
|
||||||
|
sub.onclick=function(){
|
||||||
|
var o=oldP.value, n=newP.value, n2=newP2.value;
|
||||||
|
if(!o||!n||!n2) return;
|
||||||
|
if(n!==n2){ errEl.textContent='Die neuen Passwörter stimmen nicht überein'; errEl.style.display=''; return; }
|
||||||
|
sub.disabled=true; sub.textContent='…';
|
||||||
|
api('POST','change-password',{old_password:o,new_password:n})
|
||||||
|
.then(function(){ showToast('Passwort geändert'); closeOv(); })
|
||||||
|
.catch(function(err){
|
||||||
|
sub.disabled=false; sub.textContent='Ändern';
|
||||||
|
errEl.textContent=err.message||'Fehler'; errEl.style.display='';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Registrierung ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function showRegister(token){
|
||||||
|
var c=tpl('tpl-register');
|
||||||
|
showSheet(c,false);
|
||||||
|
var nameInp=c.querySelector('.rg-name'), email=c.querySelector('.rg-email');
|
||||||
|
var pass=c.querySelector('.rg-pass'), pass2=c.querySelector('.rg-pass2');
|
||||||
|
var errEl=c.querySelector('.login-err'), sub=c.querySelector('.rg-sub');
|
||||||
|
setTimeout(function(){nameInp.focus();},50);
|
||||||
|
nameInp.onkeydown=function(e){if(e.key==='Enter')email.focus();};
|
||||||
|
email.onkeydown=function(e){if(e.key==='Enter')pass.focus();};
|
||||||
|
pass.onkeydown=function(e){if(e.key==='Enter')pass2.focus();};
|
||||||
|
pass2.onkeydown=function(e){if(e.key==='Enter')sub.click();};
|
||||||
|
function checkMatch(){ if(pass2.value&&pass.value!==pass2.value){ errEl.textContent='Passwörter stimmen nicht überein'; errEl.style.display=''; } else { errEl.style.display='none'; } }
|
||||||
|
pass.oninput=checkMatch; pass2.oninput=checkMatch;
|
||||||
|
sub.onclick=function(){
|
||||||
|
var nv=nameInp.value.trim(), ev=email.value.trim(), pv=pass.value;
|
||||||
|
if(!nv||!ev||!pv){ errEl.textContent='Bitte alle Felder ausfüllen'; errEl.style.display=''; return; }
|
||||||
|
if(pv!==pass2.value){ errEl.textContent='Passwörter stimmen nicht überein'; errEl.style.display=''; return; }
|
||||||
|
sub.disabled=true; sub.textContent='…';
|
||||||
|
api('POST','register',{name:nv,email:ev,password:pv,token:token})
|
||||||
|
.then(function(r){ userName=r.name||''; return loadGoals(); })
|
||||||
|
.then(function(g){ goals=g; closeOv(); updateHeader(); render(); })
|
||||||
|
.catch(function(err){
|
||||||
|
sub.disabled=false; sub.textContent='Registrieren';
|
||||||
|
errEl.textContent=err.message||'Fehler'; errEl.style.display='';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Neues Ziel ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function openNew(){
|
||||||
|
var c=tpl('tpl-new-goal');
|
||||||
|
showSheet(c,true);
|
||||||
|
var name=c.querySelector('.ng-name'), unit=c.querySelector('.ng-unit');
|
||||||
|
var daily=c.querySelector('.ng-daily'), days=c.querySelector('.ng-days'), sub=c.querySelector('.ng-sub');
|
||||||
|
setTimeout(function(){name.focus();},50);
|
||||||
|
c.querySelector('.ng-can').onclick=closeOv;
|
||||||
|
sub.onclick=function(){
|
||||||
|
var nv=(name.value||'').trim(), uv=(unit.value||'').trim()||'Stück';
|
||||||
|
var dv=parseInt(daily.value,10)||1, dyv=parseInt(days.value,10)||30;
|
||||||
|
if(!nv){ name.focus(); return; }
|
||||||
|
sub.disabled=true;
|
||||||
|
api('POST','goals',{name:nv,unit:uv,daily:dv,days:dyv,start:TODAY.toISOString()})
|
||||||
|
.then(function(r){
|
||||||
|
goals.push({id:r.id,name:r.name,unit:r.unit,daily:r.daily,days:r.days,start:r.start,sets:r.sets||{}});
|
||||||
|
closeOv(); render();
|
||||||
|
}).catch(function(e){
|
||||||
|
sub.disabled=false;
|
||||||
|
if(e.status===401){ closeOv(); showLogin(); }
|
||||||
|
else showToast('Fehler beim Erstellen');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Daten-Menü ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function openData(){
|
||||||
|
var c=tpl('tpl-data-menu');
|
||||||
|
showSheet(c,true);
|
||||||
|
c.querySelector('.dm-cls').onclick=closeOv;
|
||||||
|
c.querySelector('.dm-name').onclick=function(){
|
||||||
|
var nc=tpl('tpl-change-name');
|
||||||
|
showSheet(nc,true);
|
||||||
|
var inp=nc.querySelector('.cn-name'), errEl=nc.querySelector('.login-err'), sub=nc.querySelector('.cn-sub');
|
||||||
|
inp.value=userName;
|
||||||
|
setTimeout(function(){inp.focus();inp.select();},50);
|
||||||
|
nc.querySelector('.cn-can').onclick=closeOv;
|
||||||
|
sub.onclick=function(){
|
||||||
|
var nv=inp.value.trim();
|
||||||
|
if(!nv){ errEl.textContent='Name darf nicht leer sein'; errEl.style.display=''; return; }
|
||||||
|
sub.disabled=true; sub.textContent='…';
|
||||||
|
api('PATCH','me',{name:nv})
|
||||||
|
.then(function(r){ userName=r.name; closeOv(); render(); showToast('Name gespeichert'); })
|
||||||
|
.catch(function(){ sub.disabled=false; sub.textContent='Speichern'; showToast('Fehler beim Speichern'); });
|
||||||
|
};
|
||||||
|
};
|
||||||
|
c.querySelector('.dm-cpw').onclick=function(){ closeOv(); showChangePassword(); };
|
||||||
|
c.querySelector('.dm-lgout').onclick=function(){
|
||||||
|
api('POST','logout').then(function(){ goals=[]; closeOv(); render(); showLogin(); });
|
||||||
|
};
|
||||||
|
|
||||||
|
c.querySelector('.dm-inv').onclick=function(){
|
||||||
|
var ic=tpl('tpl-invite-form');
|
||||||
|
showSheet(ic,true);
|
||||||
|
var invName=ic.querySelector('.inv-name');
|
||||||
|
setTimeout(function(){invName.focus();},50);
|
||||||
|
ic.querySelector('.inv-cancel').onclick=closeOv;
|
||||||
|
ic.querySelector('.inv-gen').onclick=function(){
|
||||||
|
var note=invName.value.trim(), btn=this;
|
||||||
|
btn.disabled=true; btn.textContent='…';
|
||||||
|
api('POST','invite',{note:note}).then(function(res){
|
||||||
|
var lc=tpl('tpl-invite-link');
|
||||||
|
lc.querySelector('.stitle').textContent='Einladungslink'+(note?' für '+note:'');
|
||||||
|
var urlInp=lc.querySelector('.il-url');
|
||||||
|
urlInp.value=res.url;
|
||||||
|
showSheet(lc,true);
|
||||||
|
lc.querySelector('.il-close').onclick=closeOv;
|
||||||
|
lc.querySelector('.il-copy').onclick=function(){
|
||||||
|
navigator.clipboard.writeText(res.url).then(function(){ showToast('Link kopiert!'); closeOv(); });
|
||||||
|
};
|
||||||
|
setTimeout(function(){urlInp.select();},50);
|
||||||
|
}).catch(function(){
|
||||||
|
btn.disabled=false; btn.textContent='Link generieren';
|
||||||
|
showToast('Fehler beim Generieren');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
c.querySelector('.dm-invlist').onclick=function(){
|
||||||
|
api('GET','invites').then(function(list){
|
||||||
|
var statusLabel={'pending':'Ausstehend','used':'Angenommen','expired':'Abgelaufen'};
|
||||||
|
var statusColor={'pending':'var(--amber)','used':'var(--green)','expired':'var(--red)'};
|
||||||
|
var lc=tpl('tpl-invite-list');
|
||||||
|
var body=lc.querySelector('.dpanel-body');
|
||||||
|
if(!list.length){
|
||||||
|
var empty=document.createElement('div');
|
||||||
|
empty.className='nosets'; empty.style.padding='16px';
|
||||||
|
empty.textContent='Noch keine Einladungen verschickt';
|
||||||
|
body.appendChild(empty);
|
||||||
|
} else {
|
||||||
|
for(var i=0;i<list.length;i++){
|
||||||
|
var inv=list[i];
|
||||||
|
var label=inv.note||new Date(inv.created_at).toLocaleDateString('de-DE',{day:'numeric',month:'short',year:'numeric'});
|
||||||
|
var detail=inv.used_by_email?('→ '+inv.used_by_email):(inv.status==='pending'?'läuft ab: '+new Date(inv.expires_at).toLocaleDateString('de-DE',{day:'numeric',month:'short'}):'');
|
||||||
|
var row=tpl('tpl-invite-row');
|
||||||
|
row.querySelector('.ir-label').textContent=label;
|
||||||
|
if(detail) row.querySelector('.ir-detail').textContent=' '+detail;
|
||||||
|
var st=row.querySelector('.ir-status');
|
||||||
|
st.textContent=statusLabel[inv.status]; st.style.color=statusColor[inv.status];
|
||||||
|
if(inv.url){
|
||||||
|
var cp=row.querySelector('.ir-copy'); cp.style.display='';
|
||||||
|
cp.onclick=function(url){ return function(){ navigator.clipboard.writeText(url).then(function(){ showToast('Link kopiert!'); }); }; }(inv.url);
|
||||||
|
}
|
||||||
|
body.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showSheet(lc,true);
|
||||||
|
lc.querySelector('.il-close').onclick=closeOv;
|
||||||
|
}).catch(function(){ showToast('Fehler beim Laden'); });
|
||||||
|
};
|
||||||
|
|
||||||
|
c.querySelector('.dm-exp').onclick=function(){
|
||||||
|
var blob=new Blob([JSON.stringify({goals:goals,at:new Date().toISOString()},null,2)],{type:'application/json'});
|
||||||
|
var url=URL.createObjectURL(blob), a=document.createElement('a');
|
||||||
|
a.href=url; a.download='dudi-backup.json'; a.click(); URL.revokeObjectURL(url); closeOv();
|
||||||
|
};
|
||||||
|
|
||||||
|
c.querySelector('.dm-imp').onclick=function(){
|
||||||
|
var inp=document.createElement('input'); inp.type='file'; inp.accept='.json';
|
||||||
|
inp.onchange=function(e){
|
||||||
|
var f=e.target.files[0]; if(!f) return;
|
||||||
|
var r=new FileReader(); r.onload=function(ev){
|
||||||
|
try{
|
||||||
|
var p=JSON.parse(ev.target.result);
|
||||||
|
if(!p.goals||!Array.isArray(p.goals)) throw new Error('Ungültiges Format');
|
||||||
|
if(!confirm(p.goals.length+' Ziel(e) importieren?')) return;
|
||||||
|
var promises=p.goals.map(function(g){
|
||||||
|
return api('POST','goals',{name:g.name,unit:g.unit,daily:g.daily,days:g.days,start:g.start,sets:g.sets||{}})
|
||||||
|
.then(function(r){ goals.push({id:r.id,name:r.name,unit:r.unit,daily:r.daily,days:r.days,start:r.start,sets:r.sets||{}}); });
|
||||||
|
});
|
||||||
|
Promise.all(promises).then(function(){ closeOv(); render(); alert(p.goals.length+' Ziel(e) importiert.'); });
|
||||||
|
}catch(err){ alert('Fehler: '+err.message); }
|
||||||
|
}; r.readAsText(f);
|
||||||
|
}; inp.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
c.querySelector('.dm-clr').onclick=function(){
|
||||||
|
if(!confirm('Alle Daten löschen?')) return;
|
||||||
|
var ids=goals.map(function(g){return g.id;}); goals=[]; render();
|
||||||
|
Promise.all(ids.map(function(id){return api('DELETE','goals/'+id);}))
|
||||||
|
.catch(function(){ showToast('Fehler beim Löschen'); });
|
||||||
|
closeOv();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Card-Bausteine ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildNameWrap(g){
|
||||||
|
if(renamingId===g.id){
|
||||||
|
var el=tpl('tpl-name-edit');
|
||||||
|
var inp=el.querySelector('.ren-input');
|
||||||
|
inp.id='ri'+g.id; inp.value=g.name; inp.dataset.g=g.id;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
var el=tpl('tpl-name-view');
|
||||||
|
el.querySelector('.goal-name').textContent=g.name;
|
||||||
|
el.querySelector('.btn-ren').dataset.g=g.id;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPanel(g,off){
|
||||||
|
var t=tOff(g), sets=g.sets[String(off)]||[], tot=dTot(g,off);
|
||||||
|
var lbl=off===t?'Heute':'Gestern', k=g.id+'_'+off;
|
||||||
|
var el=tpl('tpl-panel');
|
||||||
|
el.querySelector('.dpanel-title').textContent=lbl+' — '+fd(o2d(g,off));
|
||||||
|
el.querySelector('.dpanel-sub').textContent=tot+' / '+g.daily+' '+g.unit;
|
||||||
|
var body=el.querySelector('.dpanel-body');
|
||||||
|
if(sets.length){
|
||||||
|
for(var i=0;i<sets.length;i++){
|
||||||
|
var s=sets[i], row=tpl('tpl-set-row'), span=row.querySelector('span');
|
||||||
|
if(s.time!=='—'){
|
||||||
|
var st=document.createElement('span'); st.className='stime'; st.textContent=s.time+' ·';
|
||||||
|
span.appendChild(st); span.appendChild(document.createTextNode(' '));
|
||||||
|
}
|
||||||
|
var strong=document.createElement('strong'); strong.textContent=s.amount;
|
||||||
|
span.appendChild(strong); span.appendChild(document.createTextNode(' '+g.unit));
|
||||||
|
var btn=row.querySelector('.sdel');
|
||||||
|
btn.dataset.g=g.id; btn.dataset.o=off; btn.dataset.i=i;
|
||||||
|
body.appendChild(row);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
body.appendChild(tpl('tpl-nosets'));
|
||||||
|
}
|
||||||
|
var addRow=tpl('tpl-add-row');
|
||||||
|
var inp=addRow.querySelector('.num-in');
|
||||||
|
inp.placeholder=g.daily; inp.value=addAmt[k]||''; inp.dataset.k=k; inp.dataset.g=g.id; inp.dataset.o=off;
|
||||||
|
var abtn=addRow.querySelector('.btn-as');
|
||||||
|
abtn.dataset.g=g.id; abtn.dataset.o=off;
|
||||||
|
addRow.querySelector('.ulbl').textContent=g.unit;
|
||||||
|
body.appendChild(addRow);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCard(g){
|
||||||
|
var c=calc(g), t=c.tOff;
|
||||||
|
var fc=c.surplus>0?'var(--blue)':c.st===0?'var(--green)':c.dailyDelta<=0?'var(--green)':c.dailyDelta<=g.daily*.2?'var(--amber)':'var(--red)';
|
||||||
|
var bc,bt,bufStr=(c.buf>0?'+':'')+c.buf;
|
||||||
|
if(c.ok&&c.surplus>0){bc='b-buf';bt=bufStr;}
|
||||||
|
else if(c.ok){bc='b-done';bt=bufStr;}
|
||||||
|
else if(c.dailyDelta<=0){bc='b-ok';bt=bufStr;}
|
||||||
|
else if(c.dailyDelta<=g.daily*.2){bc='b-warn';bt=bufStr;}
|
||||||
|
else{bc='b-danger';bt=bufStr;}
|
||||||
|
|
||||||
|
var el;
|
||||||
|
if(isCollapsed(g.id)){
|
||||||
|
el=tpl('tpl-card-collapsed');
|
||||||
|
if(c.ok) el.classList.add('done');
|
||||||
|
el.querySelector('.card-hdr').dataset.g=g.id;
|
||||||
|
var bd=el.querySelector('.card-bd');
|
||||||
|
bd.insertBefore(buildNameWrap(g),bd.firstElementChild);
|
||||||
|
var hc=heuteColor(c.tdone,g.daily);
|
||||||
|
el.querySelector('.m-dr').textContent=c.dr;
|
||||||
|
el.querySelector('.m-end').textContent=fs(c.end);
|
||||||
|
var mH=el.querySelector('.m-heute'); mH.textContent=c.tdone+'/'+g.daily; mH.style.color=hc;
|
||||||
|
el.querySelector('.m-total').textContent=c.done+'/'+c.tot;
|
||||||
|
var badge=el.querySelector('.badge'); badge.className='badge '+bc; badge.textContent=bt;
|
||||||
|
var fill=el.querySelector('.prog-fill'); fill.style.width=c.pct+'%'; fill.style.background=fc;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
el=tpl('tpl-card-expanded');
|
||||||
|
if(c.ok) el.classList.add('done');
|
||||||
|
el.querySelector('.card-hdr').dataset.g=g.id;
|
||||||
|
var bd=el.querySelector('.card-bd');
|
||||||
|
bd.insertBefore(buildNameWrap(g),bd.firstElementChild);
|
||||||
|
el.querySelector('.m-dr').textContent=c.dr;
|
||||||
|
el.querySelector('.m-end').textContent=fs(c.end);
|
||||||
|
var badge=el.querySelector('.badge'); badge.className='badge '+bc; badge.textContent=bt;
|
||||||
|
var fill=el.querySelector('.prog-fill'); fill.style.width=c.pct+'%'; fill.style.background=fc;
|
||||||
|
el.querySelector('.pr-done').textContent=c.done+' '+g.unit+' gemacht';
|
||||||
|
el.querySelector('.pr-pct').textContent=c.pct+'% von '+c.tot;
|
||||||
|
el.querySelector('.sv-tdone').textContent=c.tdone;
|
||||||
|
el.querySelector('.sv-daily').textContent=g.daily;
|
||||||
|
el.querySelector('.sv-st').textContent=c.st;
|
||||||
|
el.querySelector('.sv-noch').style.color=heuteColor(c.tdone,g.daily);
|
||||||
|
el.querySelectorAll('.sunit').forEach(function(u){ u.textContent=g.unit; });
|
||||||
|
|
||||||
|
var sel=selDay[g.id]!=null?selDay[g.id]:t;
|
||||||
|
var dotsWrap=el.querySelector('.dots-wrap');
|
||||||
|
for(var i=0;i<g.days;i++){
|
||||||
|
var it=i===t, iy=i===t-1, is=sel===i, ed=editable(g,i);
|
||||||
|
var dot=tpl('tpl-dot');
|
||||||
|
dot.className=dcls(g,i)+(is?' rs':it?' rt':iy&&t>0?' ry':'');
|
||||||
|
if(ed){ dot.dataset.g=g.id; dot.dataset.d=i; }
|
||||||
|
dot.textContent=dlbl(g,i);
|
||||||
|
dotsWrap.appendChild(dot);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(sel!=null) el.insertBefore(buildPanel(g,sel),el.querySelector('.card-foot'));
|
||||||
|
el.querySelector('.btn-del').dataset.g=g.id;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Quick-Buchen ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildQuickBook(){
|
||||||
|
var active=goals.filter(function(g){ var c=calc(g); return tOff(g)<g.days&&!c.ok; });
|
||||||
|
if(!active.length) return null;
|
||||||
|
var frag=document.createDocumentFragment();
|
||||||
|
var lbl=document.createElement('div'); lbl.className='sec-lbl'; lbl.textContent='Quick-Buchen';
|
||||||
|
frag.appendChild(lbl);
|
||||||
|
var card=document.createElement('div'); card.className='card qb-card';
|
||||||
|
for(var i=0;i<active.length;i++){
|
||||||
|
var g=active[i], c=calc(g), k=g.id+'_'+c.tOff;
|
||||||
|
var row=tpl('tpl-qb-row');
|
||||||
|
row.querySelector('.qb-name').textContent=g.name;
|
||||||
|
var stat=row.querySelector('.qb-stat'); stat.textContent=c.tdone+'/'+g.daily; stat.style.color=heuteColor(c.tdone,g.daily);
|
||||||
|
var inp=row.querySelector('.num-in');
|
||||||
|
inp.placeholder=g.daily; inp.value=addAmt[k]||''; inp.dataset.k=k; inp.dataset.g=g.id; inp.dataset.o=c.tOff;
|
||||||
|
var btn=row.querySelector('.btn-as'); btn.dataset.g=g.id; btn.dataset.o=c.tOff;
|
||||||
|
card.appendChild(row);
|
||||||
|
}
|
||||||
|
frag.appendChild(card);
|
||||||
|
return frag;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function calcAwards(){
|
||||||
|
var units=0;
|
||||||
|
for(var i=0;i<goals.length;i++){
|
||||||
|
var g=goals[i];
|
||||||
|
if(tOff(g)>=g.days) units+=Math.floor(g.days/30);
|
||||||
|
}
|
||||||
|
var gold=Math.floor(units/25); units%=25;
|
||||||
|
var silver=Math.floor(units/5); var bronze=units%5;
|
||||||
|
return{gold:gold,silver:silver,bronze:bronze};
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(){
|
||||||
|
var m=document.getElementById('main');
|
||||||
|
var frag=document.createDocumentFragment();
|
||||||
|
|
||||||
|
if(!prefs.hd){
|
||||||
|
var hint=tpl('tpl-hint');
|
||||||
|
hint.querySelector('.hclose').onclick=function(){ prefs.hd=1; saveP(); hint.remove(); };
|
||||||
|
frag.appendChild(hint);
|
||||||
|
}
|
||||||
|
|
||||||
|
var aw=calcAwards();
|
||||||
|
if(aw.gold||aw.silver||aw.bronze){
|
||||||
|
var awards=document.createElement('div'); awards.className='awards';
|
||||||
|
var medals=[['🥇',aw.gold],['🥈',aw.silver],['🥉',aw.bronze]];
|
||||||
|
for(var mi=0;mi<medals.length;mi++){
|
||||||
|
for(var ai=0;ai<medals[mi][1];ai++){
|
||||||
|
var sp=document.createElement('span'); sp.className='aw'; sp.textContent=medals[mi][0];
|
||||||
|
awards.appendChild(sp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frag.appendChild(awards);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!goals.length){
|
||||||
|
frag.appendChild(tpl('tpl-empty'));
|
||||||
|
m.innerHTML=''; m.appendChild(frag); wire(); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(userName){
|
||||||
|
var gr=document.createElement('div'); gr.className='greeting'; gr.textContent='Hallo '+userName+'!';
|
||||||
|
frag.appendChild(gr);
|
||||||
|
}
|
||||||
|
|
||||||
|
var qb=buildQuickBook(); if(qb) frag.appendChild(qb);
|
||||||
|
|
||||||
|
var open=[],done=[];
|
||||||
|
for(var gi=0;gi<goals.length;gi++){
|
||||||
|
var g=goals[gi], c=calc(g);
|
||||||
|
if(c.ok) done.push(g); else open.push(g);
|
||||||
|
}
|
||||||
|
if(open.length){
|
||||||
|
var sl=document.createElement('div'); sl.className='sec-lbl'; sl.textContent='Offen';
|
||||||
|
frag.appendChild(sl);
|
||||||
|
for(var i=0;i<open.length;i++) frag.appendChild(buildCard(open[i]));
|
||||||
|
}
|
||||||
|
if(done.length){
|
||||||
|
var sl2=document.createElement('div'); sl2.className='sec-lbl'; sl2.textContent='Heute erledigt';
|
||||||
|
frag.appendChild(sl2);
|
||||||
|
for(var j=0;j<done.length;j++) frag.appendChild(buildCard(done[j]));
|
||||||
|
}
|
||||||
|
|
||||||
|
m.innerHTML=''; m.appendChild(frag); wire();
|
||||||
|
}
|
||||||
|
|
||||||
|
function wire(){
|
||||||
|
document.querySelectorAll('.card-hdr[data-g]').forEach(function(el){
|
||||||
|
el.onclick=function(e){
|
||||||
|
if(e.target.classList.contains('btn-ren')||e.target.classList.contains('ren-input')) return;
|
||||||
|
toggleCollapse(this.dataset.g);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.btn-ren').forEach(function(b){
|
||||||
|
b.onclick=function(e){e.stopPropagation();startRen(this.dataset.g);};
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.ren-input').forEach(function(inp){
|
||||||
|
var gid=inp.dataset.g;
|
||||||
|
inp.oninput=function(){renameVal=this.value;};
|
||||||
|
inp.onkeydown=function(e){if(e.key==='Enter')commitRen(gid);if(e.key==='Escape')cancelRen();};
|
||||||
|
inp.onblur=function(){commitRen(gid);};
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.de').forEach(function(d){
|
||||||
|
d.onclick=function(e){e.stopPropagation();selD(this.dataset.g,parseInt(this.dataset.d,10));};
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.btn-as').forEach(function(b){
|
||||||
|
b.onclick=function(){addSet(this.dataset.g,parseInt(this.dataset.o,10));};
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.num-in').forEach(function(inp){
|
||||||
|
var k=inp.dataset.k, g=inp.dataset.g, o=parseInt(inp.dataset.o,10);
|
||||||
|
inp.oninput=function(){addAmt[k]=this.value;};
|
||||||
|
inp.onkeydown=function(e){if(e.key==='Enter')addSet(g,o);};
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.sdel').forEach(function(b){
|
||||||
|
b.onclick=function(){remSet(this.dataset.g,parseInt(this.dataset.o,10),parseInt(this.dataset.i,10));};
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.btn-del').forEach(function(b){
|
||||||
|
b.onclick=function(){delGoal(this.dataset.g);};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function updateHeader(){
|
||||||
|
document.getElementById('tlbl').textContent=TODAY.toLocaleDateString('de-DE',{weekday:'long',day:'numeric',month:'long'});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btnNew').onclick=openNew;
|
||||||
|
document.getElementById('btnData').onclick=openData;
|
||||||
|
updateHeader();
|
||||||
|
|
||||||
|
var _qs=new URLSearchParams(window.location.search);
|
||||||
|
var inviteToken=_qs.get('invite');
|
||||||
|
var resetSelector=_qs.get('reset_selector');
|
||||||
|
var resetToken=_qs.get('reset_token');
|
||||||
|
if(inviteToken||resetSelector) history.replaceState(null,'',location.pathname);
|
||||||
|
|
||||||
|
if(resetSelector&&resetToken){
|
||||||
|
render(); showResetPassword(resetSelector,resetToken);
|
||||||
|
} else {
|
||||||
|
api('GET','me')
|
||||||
|
.then(function(r){ userName=r.name||''; updateHeader(); return loadGoals(); })
|
||||||
|
.then(function(g){ goals=g; render(); })
|
||||||
|
.catch(function(){
|
||||||
|
render();
|
||||||
|
if(inviteToken){ showRegister(inviteToken); }
|
||||||
|
else { showLogin(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleMidnight(){
|
||||||
|
var n=new Date();
|
||||||
|
var ms=new Date(n.getFullYear(),n.getMonth(),n.getDate()+1,0,0,5).getTime()-n.getTime();
|
||||||
|
setTimeout(function(){
|
||||||
|
TODAY=new Date();TODAY.setHours(0,0,0,0);selDay={};collapsed={};
|
||||||
|
updateHeader();render();scheduleMidnight();
|
||||||
|
},ms);
|
||||||
|
}
|
||||||
|
scheduleMidnight();
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange',function(){
|
||||||
|
if(document.visibilityState==='visible'){
|
||||||
|
var n=new Date();n.setHours(0,0,0,0);
|
||||||
|
if(n.getTime()!==TODAY.getTime()){TODAY=n;selDay={};collapsed={};render();scheduleMidnight();}
|
||||||
|
loadGoals().then(function(g){goals=g;render();}).catch(function(){});
|
||||||
|
}
|
||||||
|
});
|
||||||
9
public/index.php
Normal file
9
public/index.php
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Kernel;
|
||||||
|
|
||||||
|
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||||
|
|
||||||
|
return static function (array $context) {
|
||||||
|
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||||
|
};
|
||||||
BIN
public/logo.png
Normal file
BIN
public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
113
public/style.css
Normal file
113
public/style.css
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=DM+Mono:wght@400;500&display=swap');
|
||||||
|
:root{--bg:#f5f4f0;--bg2:#fff;--bg3:#f0eeea;--border:rgba(0,0,0,.07);--border2:rgba(0,0,0,.12);--text:#1a1a1a;--text2:#666;--text3:#aaa;--green:#16a34a;--green-bg:rgba(22,163,74,.08);--blue:#2563eb;--blue-bg:rgba(37,99,235,.08);--amber:#d97706;--amber-bg:rgba(217,119,6,.08);--red:#dc2626;--red-bg:rgba(220,38,38,.08);--r:14px;--rs:8px}
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
|
||||||
|
body{font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--text);min-height:100dvh;padding-bottom:80px}
|
||||||
|
.main-wrap{max-width:480px;margin:0 auto}
|
||||||
|
.hdr{position:sticky;top:0;z-index:100;background:rgba(245,244,240,.92);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:16px 20px 14px;display:flex;align-items:center;justify-content:space-between}
|
||||||
|
.hdr-title{font-size:18px;font-weight:600;letter-spacing:-.3px}
|
||||||
|
.hdr-logo{height:100px;width:auto;mix-blend-mode:multiply;display:block}
|
||||||
|
.hdr-sub{font-size:12px;color:var(--text3);margin-top:1px;font-family:'DM Mono',monospace}
|
||||||
|
.hdr-btns{display:flex;gap:8px;align-items:center}
|
||||||
|
.btn-add{width:38px;height:38px;border-radius:50%;background:var(--text);color:var(--bg);border:none;cursor:pointer;font-size:22px;display:flex;align-items:center;justify-content:center;font-weight:300;transition:transform .15s}
|
||||||
|
.btn-add:active{transform:scale(.92)}
|
||||||
|
.btn-menu{width:38px;height:38px;border-radius:50%;background:var(--bg3);color:var(--text2);border:1px solid var(--border);cursor:pointer;font-size:20px;display:flex;align-items:center;justify-content:center;transition:transform .15s}
|
||||||
|
.btn-menu:active{transform:scale(.92)}
|
||||||
|
.main{padding:16px 16px 0}
|
||||||
|
.greeting{font-size:17px;font-weight:600;letter-spacing:-.2px;padding:0 2px 10px}
|
||||||
|
.sec-lbl{font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.6px;font-family:'DM Mono',monospace;margin-bottom:6px;margin-top:4px;padding:0 2px}
|
||||||
|
.empty{text-align:center;padding:60px 20px;color:var(--text3);font-size:14px;line-height:1.8}
|
||||||
|
.card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);margin-bottom:8px;overflow:hidden}
|
||||||
|
.card.done{opacity:.7}
|
||||||
|
.card-hdr{padding:14px 16px 12px;display:flex;align-items:center;justify-content:space-between;gap:10px;cursor:pointer;user-select:none}
|
||||||
|
.name-wrap{display:flex;align-items:center;gap:6px}
|
||||||
|
.goal-name{font-size:16px;font-weight:600;letter-spacing:-.2px}
|
||||||
|
.btn-ren{background:none;border:none;color:var(--text3);cursor:pointer;font-size:14px;padding:2px 4px;line-height:1;flex-shrink:0}
|
||||||
|
.btn-ren:active{color:var(--blue)}
|
||||||
|
.ren-input{font-family:'DM Sans',sans-serif;font-size:16px;font-weight:600;background:var(--bg3);border:1px solid var(--blue);border-radius:6px;color:var(--text);padding:2px 8px;width:100%;letter-spacing:-.2px}
|
||||||
|
.ren-input:focus{outline:none}
|
||||||
|
.goal-meta{font-size:11px;color:var(--text3);margin-top:3px;font-family:'DM Mono',monospace;line-height:1.5}
|
||||||
|
.chevron{font-size:13px;color:var(--text3);margin-left:4px;flex-shrink:0}
|
||||||
|
.badge{font-size:10px;font-weight:600;padding:3px 8px;border-radius:99px;white-space:nowrap;flex-shrink:0;letter-spacing:.3px;text-transform:uppercase}
|
||||||
|
.b-ok{background:var(--green-bg);color:var(--green)}.b-done{background:var(--green-bg);color:var(--green)}.b-warn{background:var(--amber-bg);color:var(--amber)}.b-danger{background:var(--red-bg);color:var(--red)}.b-buf{background:var(--blue-bg);color:var(--blue)}
|
||||||
|
.prog-wrap{padding:0 16px 12px}
|
||||||
|
.prog-track{height:4px;background:var(--bg3);border-radius:99px;overflow:hidden}
|
||||||
|
.prog-fill{height:100%;border-radius:99px;transition:width .4s ease}
|
||||||
|
.prog-row{display:flex;justify-content:space-between;font-size:11px;color:var(--text3);margin-top:5px;font-family:'DM Mono',monospace}
|
||||||
|
.heute-stats{padding:0 16px 14px}
|
||||||
|
.heute-group{background:var(--bg3);border-radius:var(--rs);padding:8px 8px 6px}
|
||||||
|
.heute-lbl{font-size:9px;color:var(--text3);text-transform:uppercase;letter-spacing:.5px;text-align:center;margin-bottom:6px;font-family:'DM Mono',monospace}
|
||||||
|
.heute-inner{display:grid;grid-template-columns:1fr 1fr 1fr;gap:5px}
|
||||||
|
.stat{background:var(--bg2);border-radius:6px;padding:7px 6px 5px;text-align:center}
|
||||||
|
.slbl{font-size:9px;color:var(--text3);margin-bottom:2px}
|
||||||
|
.sval{font-size:14px;font-weight:600;font-family:'DM Mono',monospace}
|
||||||
|
.sunit{font-size:8px;color:var(--text3)}
|
||||||
|
.dots-sec{padding:0 16px 12px}
|
||||||
|
.dots-lbl{font-size:11px;color:var(--text3);margin-bottom:6px}
|
||||||
|
.dots-wrap{display:flex;gap:3px;flex-wrap:wrap}
|
||||||
|
.dot{width:26px;height:26px;border-radius:5px;display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:600;font-family:'DM Mono',monospace;transition:opacity .15s}
|
||||||
|
.df{background:var(--bg3);color:var(--text3)}
|
||||||
|
.dm{background:rgba(220,38,38,.12);color:var(--red)}
|
||||||
|
.dp{background:rgba(217,119,6,.12);color:var(--amber)}
|
||||||
|
.dd{background:rgba(22,163,74,.12);color:var(--green)}
|
||||||
|
.db{background:rgba(37,99,235,.12);color:var(--blue)}
|
||||||
|
.de{cursor:pointer}.de:active{opacity:.6;transform:scale(.9)}.dl{opacity:.6;cursor:default}
|
||||||
|
.rt{box-shadow:0 0 0 2px var(--text2)}.ry{box-shadow:0 0 0 2px var(--text3)}.rs{box-shadow:0 0 0 2px var(--blue) !important}
|
||||||
|
.legend{display:flex;gap:10px;flex-wrap:wrap;margin-top:8px}
|
||||||
|
.leg{display:flex;align-items:center;gap:4px;font-size:10px;color:var(--text3)}
|
||||||
|
.ldot{width:8px;height:8px;border-radius:2px}
|
||||||
|
.dpanel{margin:0 16px 14px;background:var(--bg3);border-radius:var(--rs);overflow:hidden;border:1px solid var(--border2)}
|
||||||
|
.dpanel-hdr{padding:10px 12px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--border)}
|
||||||
|
.dpanel-title{font-size:13px;font-weight:600}
|
||||||
|
.dpanel-sub{font-size:11px;color:var(--text2);font-family:'DM Mono',monospace}
|
||||||
|
.dpanel-body{padding:10px 12px}
|
||||||
|
.set-row{display:flex;align-items:center;justify-content:space-between;padding:5px 0;border-bottom:1px solid var(--border);font-size:13px}
|
||||||
|
.set-row:last-of-type{border-bottom:none}
|
||||||
|
.stime{font-size:10px;color:var(--text3);font-family:'DM Mono',monospace;margin-right:4px}
|
||||||
|
.sdel{background:none;border:none;color:var(--text3);cursor:pointer;font-size:16px;padding:2px 6px;border-radius:4px}
|
||||||
|
.sdel:active{color:var(--red)}
|
||||||
|
.add-row{display:flex;gap:8px;align-items:center;margin-top:10px}
|
||||||
|
.num-in{font-family:'DM Mono',monospace;font-size:15px;padding:8px 10px;border-radius:var(--rs);border:1px solid var(--border2);background:var(--bg2);color:var(--text);width:90px;text-align:center}
|
||||||
|
.num-in:focus{outline:none;border-color:var(--blue)}
|
||||||
|
.ulbl{font-size:12px;color:var(--text3)}
|
||||||
|
.btn-as{flex:1;padding:8px;border-radius:var(--rs);background:var(--blue-bg);color:var(--blue);border:1px solid rgba(37,99,235,.2);cursor:pointer;font-family:'DM Sans',sans-serif;font-size:13px;font-weight:600}
|
||||||
|
.btn-as:active{opacity:.7}
|
||||||
|
.nosets{font-size:12px;color:var(--text3);padding:4px 0 8px}
|
||||||
|
.card-foot{padding:8px 16px 12px;display:flex;justify-content:flex-end}
|
||||||
|
.btn-del{background:none;border:none;color:var(--text3);font-size:12px;cursor:pointer;padding:4px 0;font-family:'DM Sans',sans-serif}
|
||||||
|
.btn-del:active{color:var(--red)}
|
||||||
|
.qb-card{padding:0 16px}
|
||||||
|
.qb-row{display:flex;align-items:center;gap:8px;padding:10px 0;border-bottom:1px solid var(--border)}
|
||||||
|
.qb-row:last-child{border-bottom:none}
|
||||||
|
.qb-name{flex:1;font-size:16px;font-weight:600;letter-spacing:-.2px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;min-width:0}
|
||||||
|
.qb-stat{font-size:11px;font-family:'DM Mono',monospace;color:var(--text3);white-space:nowrap}
|
||||||
|
.qb-row .num-in{width:62px;padding:6px 6px;font-size:13px}
|
||||||
|
.qb-row .btn-as{flex:none;padding:8px 14px;font-size:13px}
|
||||||
|
.ov{position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.5);display:flex;align-items:flex-end;animation:fi .2s ease}
|
||||||
|
@keyframes fi{from{opacity:0}to{opacity:1}}
|
||||||
|
.sheet{width:100%;max-width:480px;background:var(--bg2);border-radius:20px 20px 0 0;border:1px solid var(--border2);padding:20px 20px 40px;animation:su .25s ease;max-height:90dvh;overflow-y:auto}
|
||||||
|
@keyframes su{from{transform:translateY(100%)}to{transform:translateY(0)}}
|
||||||
|
.shandle{width:36px;height:4px;border-radius:2px;background:var(--border2);margin:0 auto 20px}
|
||||||
|
.stitle{font-size:17px;font-weight:600;margin-bottom:4px}
|
||||||
|
.ssub{font-size:12px;color:var(--text3);margin-bottom:16px}
|
||||||
|
.fgrid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px}
|
||||||
|
.ff{margin-bottom:10px}
|
||||||
|
.ff label{font-size:11px;color:var(--text3);display:block;margin-bottom:4px;text-transform:uppercase;letter-spacing:.5px}
|
||||||
|
.fi{width:100%;font-family:'DM Sans',sans-serif;font-size:15px;padding:10px 12px;border-radius:var(--rs);border:1px solid var(--border2);background:var(--bg3);color:var(--text)}
|
||||||
|
.fi:focus{outline:none;border-color:var(--blue)}
|
||||||
|
.factions{display:flex;gap:8px;margin-top:16px}
|
||||||
|
.btn-p{flex:1;padding:13px;border-radius:var(--rs);background:var(--text);color:var(--bg);border:none;cursor:pointer;font-family:'DM Sans',sans-serif;font-size:15px;font-weight:600}
|
||||||
|
.btn-p:active{opacity:.8}
|
||||||
|
.btn-c{padding:13px 20px;border-radius:var(--rs);background:var(--bg3);color:var(--text2);border:1px solid var(--border);cursor:pointer;font-family:'DM Sans',sans-serif;font-size:15px}
|
||||||
|
.dbtn{width:100%;padding:13px 16px;border-radius:var(--rs);background:var(--bg3);border:1px solid var(--border);color:var(--text);font-family:'DM Sans',sans-serif;font-size:15px;text-align:left;cursor:pointer;margin-bottom:8px;display:flex;align-items:center;gap:12px}
|
||||||
|
.dbtn:active{background:var(--border2)}
|
||||||
|
.dico{font-size:18px;width:24px;text-align:center;flex-shrink:0}
|
||||||
|
.dlbl{flex:1}.dsub{font-size:11px;color:var(--text3);display:block;margin-top:1px}
|
||||||
|
.ddanger{color:var(--red)}
|
||||||
|
.ddiv{height:1px;background:var(--border);margin:12px 0}
|
||||||
|
.awards{display:flex;gap:4px;align-items:center;flex-wrap:wrap;padding:0 16px 10px}
|
||||||
|
.aw{font-size:24px;line-height:1}
|
||||||
|
.login-err{background:var(--red-bg);color:var(--red);border-radius:var(--rs);padding:10px 12px;font-size:13px;margin-bottom:12px}
|
||||||
|
.toast{position:fixed;bottom:100px;left:50%;transform:translateX(-50%);background:#1a1a1a;color:#fff;padding:10px 16px;border-radius:8px;font-size:13px;z-index:300;white-space:nowrap;animation:fi .2s ease}
|
||||||
|
.hint{margin:0 16px 12px;background:var(--blue-bg);border:1px solid rgba(37,99,235,.15);border-radius:var(--rs);padding:10px 12px;font-size:12px;color:var(--blue);line-height:1.5;display:flex;justify-content:space-between;align-items:center;gap:8px}
|
||||||
|
.hclose{background:none;border:none;color:var(--blue);cursor:pointer;font-size:16px;padding:0 2px}
|
||||||
|
.btn-lnk{background:none;border:none;color:var(--text2);font-size:13px;cursor:pointer;padding:4px 8px;text-decoration:underline;text-underline-offset:2px}
|
||||||
0
src/Controller/.gitignore
vendored
Normal file
0
src/Controller/.gitignore
vendored
Normal file
16
src/Controller/AppController.php
Normal file
16
src/Controller/AppController.php
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
class AppController extends AbstractController
|
||||||
|
{
|
||||||
|
#[Route('/{path}', name: 'app', requirements: ['path' => '.*'], priority: -10)]
|
||||||
|
public function index(): Response
|
||||||
|
{
|
||||||
|
return $this->render('app.html.twig');
|
||||||
|
}
|
||||||
|
}
|
||||||
212
src/Controller/AuthController.php
Normal file
212
src/Controller/AuthController.php
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\InviteRepository;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Mailer\MailerInterface;
|
||||||
|
use Symfony\Component\Mime\Email;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
|
||||||
|
|
||||||
|
#[Route('/api')]
|
||||||
|
class AuthController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
private readonly UserRepository $users,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
#[Route('/me', methods: ['GET'])]
|
||||||
|
public function me(): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->getUser();
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
return new JsonResponse(['ok' => false], Response::HTTP_UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
return new JsonResponse([
|
||||||
|
'ok' => true,
|
||||||
|
'email' => $user->getEmail(),
|
||||||
|
'id' => $user->getId(),
|
||||||
|
'name' => $user->getUsername() ?? '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/me', methods: ['PATCH'])]
|
||||||
|
public function updateMe(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->getUser();
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
return new JsonResponse(['error' => 'Unauthorized'], Response::HTTP_UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
$data = json_decode($request->getContent(), true) ?? [];
|
||||||
|
$name = trim($data['name'] ?? '');
|
||||||
|
if (!$name) {
|
||||||
|
return new JsonResponse(['error' => 'Name fehlt'], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
$user->setUsername($name);
|
||||||
|
$this->em->flush();
|
||||||
|
return new JsonResponse(['ok' => true, 'name' => $name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/logout', methods: ['POST'])]
|
||||||
|
public function logout(): never
|
||||||
|
{
|
||||||
|
throw new \LogicException('Handled by firewall.');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/register', methods: ['POST'])]
|
||||||
|
public function register(
|
||||||
|
Request $request,
|
||||||
|
InviteRepository $invites,
|
||||||
|
UserPasswordHasherInterface $hasher,
|
||||||
|
): JsonResponse {
|
||||||
|
$data = json_decode($request->getContent(), true) ?? [];
|
||||||
|
$email = trim($data['email'] ?? '');
|
||||||
|
$password = $data['password'] ?? '';
|
||||||
|
$token = trim($data['token'] ?? '');
|
||||||
|
$name = trim($data['name'] ?? '');
|
||||||
|
|
||||||
|
if (!$email || !$password || !$token) {
|
||||||
|
return new JsonResponse(['error' => 'Fehlende Felder'], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
if (strlen($password) < 8) {
|
||||||
|
return new JsonResponse(['error' => 'Passwort zu kurz (min. 8 Zeichen)'], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
$invite = $invites->findPendingByToken($token);
|
||||||
|
if (!$invite) {
|
||||||
|
return new JsonResponse(['error' => 'Ungültiger oder abgelaufener Einladungslink'], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
if ($this->users->findOneBy(['email' => $email])) {
|
||||||
|
return new JsonResponse(['error' => 'E-Mail bereits registriert'], Response::HTTP_CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = new User();
|
||||||
|
$user->setEmail($email)
|
||||||
|
->setPassword($hasher->hashPassword($user, $password))
|
||||||
|
->setUsername($name ?: null)
|
||||||
|
->setVerified(true);
|
||||||
|
|
||||||
|
$this->em->persist($user);
|
||||||
|
$this->em->flush(); // flush first to get user ID
|
||||||
|
|
||||||
|
$invite->setUsedBy($user->getId());
|
||||||
|
$invite->setUsedAt(new \DateTimeImmutable());
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return new JsonResponse(['ok' => true, 'email' => $email, 'name' => $name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/reset-request', methods: ['POST'])]
|
||||||
|
public function resetRequest(Request $request, MailerInterface $mailer): JsonResponse
|
||||||
|
{
|
||||||
|
$data = json_decode($request->getContent(), true) ?? [];
|
||||||
|
$email = trim($data['email'] ?? '');
|
||||||
|
if (!$email) {
|
||||||
|
return new JsonResponse(['error' => 'E-Mail fehlt'], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->users->findOneBy(['email' => $email]);
|
||||||
|
if ($user) {
|
||||||
|
$selector = bin2hex(random_bytes(12));
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
$hash = password_hash($token, PASSWORD_BCRYPT);
|
||||||
|
$expires = time() + 86400;
|
||||||
|
|
||||||
|
$this->em->getConnection()->executeStatement(
|
||||||
|
'INSERT INTO users_resets (user, selector, token, expires) VALUES (?, ?, ?, ?)',
|
||||||
|
[$user->getId(), $selector, $hash, $expires]
|
||||||
|
);
|
||||||
|
|
||||||
|
$appUrl = $_ENV['APP_URL'] ?? 'http://localhost';
|
||||||
|
$url = $appUrl . '/?reset_selector=' . rawurlencode($selector) . '&reset_token=' . rawurlencode($token);
|
||||||
|
$from = $_ENV['MAILER_FROM'] ?? 'noreply@example.com';
|
||||||
|
|
||||||
|
$mail = (new Email())
|
||||||
|
->from($from)
|
||||||
|
->to($email)
|
||||||
|
->subject('Dudi – Passwort zurücksetzen')
|
||||||
|
->text("Hallo,\n\nklicke auf den folgenden Link um dein Passwort zurückzusetzen (gültig 24h):\n\n$url\n\nFalls du das nicht angefragt hast, ignoriere diese E-Mail.");
|
||||||
|
|
||||||
|
try {
|
||||||
|
$mailer->send($mail);
|
||||||
|
} catch (\Exception) {
|
||||||
|
return new JsonResponse(['error' => 'Mail konnte nicht gesendet werden'], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/reset-password', methods: ['POST'])]
|
||||||
|
public function resetPassword(Request $request, UserPasswordHasherInterface $hasher): JsonResponse
|
||||||
|
{
|
||||||
|
$data = json_decode($request->getContent(), true) ?? [];
|
||||||
|
$selector = $data['selector'] ?? '';
|
||||||
|
$token = $data['token'] ?? '';
|
||||||
|
$password = $data['password'] ?? '';
|
||||||
|
|
||||||
|
if (!$selector || !$token || !$password) {
|
||||||
|
return new JsonResponse(['error' => 'Fehlende Felder'], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $this->em->getConnection()->fetchAssociative(
|
||||||
|
'SELECT * FROM users_resets WHERE selector = ? AND expires > ?',
|
||||||
|
[$selector, time()]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$row || !password_verify($token, $row['token'])) {
|
||||||
|
return new JsonResponse(['error' => 'Ungültiger oder abgelaufener Reset-Link'], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
if (strlen($password) < 8) {
|
||||||
|
return new JsonResponse(['error' => 'Passwort zu kurz (min. 8 Zeichen)'], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->users->find($row['user']);
|
||||||
|
if (!$user) {
|
||||||
|
return new JsonResponse(['error' => 'Benutzer nicht gefunden'], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->setPassword($hasher->hashPassword($user, $password));
|
||||||
|
$this->em->getConnection()->executeStatement('DELETE FROM users_resets WHERE selector = ?', [$selector]);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return new JsonResponse(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/change-password', methods: ['POST'])]
|
||||||
|
public function changePassword(Request $request, UserPasswordHasherInterface $hasher): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->getUser();
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
return new JsonResponse(['error' => 'Nicht eingeloggt'], Response::HTTP_UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($request->getContent(), true) ?? [];
|
||||||
|
$oldPass = $data['old_password'] ?? '';
|
||||||
|
$newPass = $data['new_password'] ?? '';
|
||||||
|
|
||||||
|
if (!$oldPass || !$newPass) {
|
||||||
|
return new JsonResponse(['error' => 'Fehlende Felder'], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
if (!$hasher->isPasswordValid($user, $oldPass)) {
|
||||||
|
return new JsonResponse(['error' => 'Aktuelles Passwort falsch'], Response::HTTP_UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
if (strlen($newPass) < 8) {
|
||||||
|
return new JsonResponse(['error' => 'Neues Passwort zu kurz (min. 8 Zeichen)'], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->setPassword($hasher->hashPassword($user, $newPass));
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return new JsonResponse(['ok' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
108
src/Controller/GoalController.php
Normal file
108
src/Controller/GoalController.php
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\Goal;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\GoalRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
#[Route('/api/goals')]
|
||||||
|
class GoalController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
private readonly GoalRepository $goals,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
#[Route('', methods: ['GET'])]
|
||||||
|
public function list(): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->getUser();
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
return new JsonResponse(['error' => 'Unauthorized'], Response::HTTP_UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
$goals = array_map(fn(Goal $g) => $g->toArray(), $this->goals->findByUser($user->getId()));
|
||||||
|
return new JsonResponse($goals);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('', methods: ['POST'])]
|
||||||
|
public function create(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->getUser();
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
return new JsonResponse(['error' => 'Unauthorized'], Response::HTTP_UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($request->getContent(), true) ?? [];
|
||||||
|
$name = trim($data['name'] ?? '');
|
||||||
|
if (!$name) {
|
||||||
|
return new JsonResponse(['error' => 'Name fehlt'], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$start = new \DateTime($data['start'] ?? 'now');
|
||||||
|
} catch (\Exception) {
|
||||||
|
$start = new \DateTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
$goal = new Goal();
|
||||||
|
$goal->setUserId($user->getId())
|
||||||
|
->setName($name)
|
||||||
|
->setUnit(trim($data['unit'] ?? 'Stück') ?: 'Stück')
|
||||||
|
->setDaily((float)($data['daily'] ?? 1))
|
||||||
|
->setDays((int)($data['days'] ?? 30))
|
||||||
|
->setStart($start)
|
||||||
|
->setSets(is_array($data['sets'] ?? null) ? $data['sets'] : []);
|
||||||
|
|
||||||
|
$this->em->persist($goal);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return new JsonResponse($goal->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/{id}', methods: ['PATCH'])]
|
||||||
|
public function update(int $id, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->getUser();
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
return new JsonResponse(['error' => 'Unauthorized'], Response::HTTP_UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
$goal = $this->goals->findOneBy(['id' => $id, 'userId' => $user->getId()]);
|
||||||
|
if (!$goal) {
|
||||||
|
return new JsonResponse(['error' => 'Nicht gefunden'], Response::HTTP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($request->getContent(), true) ?? [];
|
||||||
|
if (isset($data['name'])) $goal->setName((string)$data['name']);
|
||||||
|
if (isset($data['unit'])) $goal->setUnit((string)$data['unit']);
|
||||||
|
if (isset($data['daily'])) $goal->setDaily((float)$data['daily']);
|
||||||
|
if (isset($data['days'])) $goal->setDays((int)$data['days']);
|
||||||
|
if (isset($data['sets'])) $goal->setSets((array)$data['sets']);
|
||||||
|
|
||||||
|
$this->em->flush();
|
||||||
|
return new JsonResponse(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/{id}', methods: ['DELETE'])]
|
||||||
|
public function delete(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->getUser();
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
return new JsonResponse(['error' => 'Unauthorized'], Response::HTTP_UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
$goal = $this->goals->findOneBy(['id' => $id, 'userId' => $user->getId()]);
|
||||||
|
if ($goal) {
|
||||||
|
$this->em->remove($goal);
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
return new JsonResponse(['ok' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/Controller/InviteController.php
Normal file
90
src/Controller/InviteController.php
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\Invite;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\InviteRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
#[Route('/api')]
|
||||||
|
class InviteController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
private readonly InviteRepository $invites,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
#[Route('/invite', methods: ['POST'])]
|
||||||
|
public function create(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->getUser();
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
return new JsonResponse(['error' => 'Unauthorized'], Response::HTTP_UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($request->getContent(), true) ?? [];
|
||||||
|
$note = trim($data['note'] ?? '') ?: null;
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
|
||||||
|
$invite = new Invite();
|
||||||
|
$invite->setToken($token)
|
||||||
|
->setNote($note)
|
||||||
|
->setCreatedBy($user->getId())
|
||||||
|
->setExpiresAt(new \DateTimeImmutable('+7 days'));
|
||||||
|
|
||||||
|
$this->em->persist($invite);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$appUrl = $_ENV['APP_URL'] ?? 'http://localhost';
|
||||||
|
return new JsonResponse(['url' => $appUrl . '/?invite=' . $token]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/invites', methods: ['GET'])]
|
||||||
|
public function list(): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->getUser();
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
return new JsonResponse(['error' => 'Unauthorized'], Response::HTTP_UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $this->invites->findByCreator($user->getId());
|
||||||
|
$appUrl = $_ENV['APP_URL'] ?? 'http://localhost';
|
||||||
|
|
||||||
|
$result = array_map(function (Invite $inv) use ($appUrl) {
|
||||||
|
$pending = $inv->isPending();
|
||||||
|
$usedByEmail = null;
|
||||||
|
if ($inv->getUsedBy()) {
|
||||||
|
$usedByEmail = $this->em->getConnection()->fetchOne(
|
||||||
|
'SELECT email FROM users WHERE id = ?',
|
||||||
|
[$inv->getUsedBy()]
|
||||||
|
) ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($usedByEmail !== null) {
|
||||||
|
$status = 'used';
|
||||||
|
} elseif (!$pending) {
|
||||||
|
$status = 'expired';
|
||||||
|
} else {
|
||||||
|
$status = 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'url' => $status === 'pending' ? $appUrl . '/?invite=' . $inv->getToken() : null,
|
||||||
|
'created_at' => $inv->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||||
|
'expires_at' => $inv->getExpiresAt()->format('Y-m-d H:i:s'),
|
||||||
|
'used_at' => $inv->getUsedAt()?->format('Y-m-d H:i:s'),
|
||||||
|
'note' => $inv->getNote(),
|
||||||
|
'used_by_email' => $usedByEmail,
|
||||||
|
'status' => $status,
|
||||||
|
];
|
||||||
|
}, $rows);
|
||||||
|
|
||||||
|
return new JsonResponse($result);
|
||||||
|
}
|
||||||
|
}
|
||||||
0
src/Entity/.gitignore
vendored
Normal file
0
src/Entity/.gitignore
vendored
Normal file
75
src/Entity/Goal.php
Normal file
75
src/Entity/Goal.php
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\GoalRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: GoalRepository::class)]
|
||||||
|
#[ORM\Table(name: 'goals')]
|
||||||
|
class Goal
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private int $userId;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private string $name;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 50)]
|
||||||
|
private string $unit = 'Stück';
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private float $daily = 1;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private int $days = 30;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime')]
|
||||||
|
private \DateTimeInterface $start;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'json')]
|
||||||
|
private array $sets = [];
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable', options: ['default' => 'CURRENT_TIMESTAMP'])]
|
||||||
|
private \DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->createdAt = new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int { return $this->id; }
|
||||||
|
public function getUserId(): int { return $this->userId; }
|
||||||
|
public function setUserId(int $userId): static { $this->userId = $userId; return $this; }
|
||||||
|
public function getName(): string { return $this->name; }
|
||||||
|
public function setName(string $name): static { $this->name = $name; return $this; }
|
||||||
|
public function getUnit(): string { return $this->unit; }
|
||||||
|
public function setUnit(string $unit): static { $this->unit = $unit; return $this; }
|
||||||
|
public function getDaily(): float { return $this->daily; }
|
||||||
|
public function setDaily(float $daily): static { $this->daily = $daily; return $this; }
|
||||||
|
public function getDays(): int { return $this->days; }
|
||||||
|
public function setDays(int $days): static { $this->days = $days; return $this; }
|
||||||
|
public function getStart(): \DateTimeInterface { return $this->start; }
|
||||||
|
public function setStart(\DateTimeInterface $start): static { $this->start = $start; return $this; }
|
||||||
|
public function getSets(): array { return $this->sets; }
|
||||||
|
public function setSets(array $sets): static { $this->sets = $sets; return $this; }
|
||||||
|
public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => (string) $this->id,
|
||||||
|
'name' => $this->name,
|
||||||
|
'unit' => $this->unit,
|
||||||
|
'daily' => $this->daily,
|
||||||
|
'days' => $this->days,
|
||||||
|
'start' => $this->start->format('Y-m-d H:i:s'),
|
||||||
|
'sets' => $this->sets,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/Entity/Invite.php
Normal file
62
src/Entity/Invite.php
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\InviteRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: InviteRepository::class)]
|
||||||
|
#[ORM\Table(name: 'invites')]
|
||||||
|
class Invite
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 64, unique: true)]
|
||||||
|
private string $token;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
private ?string $note = null;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private int $createdBy;
|
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
private ?int $usedBy = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable', options: ['default' => 'CURRENT_TIMESTAMP'])]
|
||||||
|
private \DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private \DateTimeImmutable $expiresAt;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $usedAt = null;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->createdAt = new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int { return $this->id; }
|
||||||
|
public function getToken(): string { return $this->token; }
|
||||||
|
public function setToken(string $token): static { $this->token = $token; return $this; }
|
||||||
|
public function getNote(): ?string { return $this->note; }
|
||||||
|
public function setNote(?string $note): static { $this->note = $note; return $this; }
|
||||||
|
public function getCreatedBy(): int { return $this->createdBy; }
|
||||||
|
public function setCreatedBy(int $createdBy): static { $this->createdBy = $createdBy; return $this; }
|
||||||
|
public function getUsedBy(): ?int { return $this->usedBy; }
|
||||||
|
public function setUsedBy(?int $usedBy): static { $this->usedBy = $usedBy; return $this; }
|
||||||
|
public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
|
||||||
|
public function getExpiresAt(): \DateTimeImmutable { return $this->expiresAt; }
|
||||||
|
public function setExpiresAt(\DateTimeImmutable $expiresAt): static { $this->expiresAt = $expiresAt; return $this; }
|
||||||
|
public function getUsedAt(): ?\DateTimeImmutable { return $this->usedAt; }
|
||||||
|
public function setUsedAt(?\DateTimeImmutable $usedAt): static { $this->usedAt = $usedAt; return $this; }
|
||||||
|
|
||||||
|
public function isPending(): bool
|
||||||
|
{
|
||||||
|
return $this->usedBy === null && $this->expiresAt > new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/Entity/User.php
Normal file
59
src/Entity/User.php
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||||
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
||||||
|
#[ORM\Table(name: 'users')]
|
||||||
|
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer', options: ['unsigned' => true])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 249, unique: true)]
|
||||||
|
private string $email;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private string $password;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 100, nullable: true)]
|
||||||
|
private ?string $username = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'boolean', options: ['unsigned' => true, 'default' => 0])]
|
||||||
|
private bool $verified = false;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', options: ['unsigned' => true])]
|
||||||
|
private int $registered;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', nullable: true, options: ['unsigned' => true])]
|
||||||
|
private ?int $lastLogin = null;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->registered = time();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int { return $this->id; }
|
||||||
|
public function getEmail(): string { return $this->email; }
|
||||||
|
public function setEmail(string $email): static { $this->email = $email; return $this; }
|
||||||
|
public function getPassword(): string { return $this->password; }
|
||||||
|
public function setPassword(string $password): static { $this->password = $password; return $this; }
|
||||||
|
public function getUsername(): ?string { return $this->username; }
|
||||||
|
public function setUsername(?string $username): static { $this->username = $username; return $this; }
|
||||||
|
public function isVerified(): bool { return $this->verified; }
|
||||||
|
public function setVerified(bool $verified): static { $this->verified = $verified; return $this; }
|
||||||
|
public function getRegistered(): int { return $this->registered; }
|
||||||
|
public function getLastLogin(): ?int { return $this->lastLogin; }
|
||||||
|
public function setLastLogin(?int $lastLogin): static { $this->lastLogin = $lastLogin; return $this; }
|
||||||
|
|
||||||
|
// UserInterface
|
||||||
|
public function getUserIdentifier(): string { return $this->email; }
|
||||||
|
public function getRoles(): array { return ['ROLE_USER']; }
|
||||||
|
public function eraseCredentials(): void {}
|
||||||
|
}
|
||||||
11
src/Kernel.php
Normal file
11
src/Kernel.php
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
||||||
|
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
||||||
|
|
||||||
|
class Kernel extends BaseKernel
|
||||||
|
{
|
||||||
|
use MicroKernelTrait;
|
||||||
|
}
|
||||||
0
src/Repository/.gitignore
vendored
Normal file
0
src/Repository/.gitignore
vendored
Normal file
21
src/Repository/GoalRepository.php
Normal file
21
src/Repository/GoalRepository.php
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Goal;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
class GoalRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Goal::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Goal[] */
|
||||||
|
public function findByUser(int $userId): array
|
||||||
|
{
|
||||||
|
return $this->findBy(['userId' => $userId], ['createdAt' => 'ASC']);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/Repository/InviteRepository.php
Normal file
33
src/Repository/InviteRepository.php
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Invite;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
class InviteRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Invite::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findPendingByToken(string $token): ?Invite
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('i')
|
||||||
|
->where('i.token = :token')
|
||||||
|
->andWhere('i.usedBy IS NULL')
|
||||||
|
->andWhere('i.expiresAt > :now')
|
||||||
|
->setParameter('token', $token)
|
||||||
|
->setParameter('now', new \DateTimeImmutable())
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Invite[] */
|
||||||
|
public function findByCreator(int $userId): array
|
||||||
|
{
|
||||||
|
return $this->findBy(['createdBy' => $userId], ['createdAt' => 'DESC']);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/Repository/UserRepository.php
Normal file
27
src/Repository/UserRepository.php
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
|
||||||
|
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||||
|
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
|
||||||
|
|
||||||
|
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
|
||||||
|
{
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class));
|
||||||
|
}
|
||||||
|
$user->setPassword($newHashedPassword);
|
||||||
|
$this->getEntityManager()->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/Security/JsonLoginAuthenticator.php
Normal file
55
src/Security/JsonLoginAuthenticator.php
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Security;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||||
|
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||||||
|
|
||||||
|
class JsonLoginAuthenticator extends AbstractAuthenticator
|
||||||
|
{
|
||||||
|
public function supports(Request $request): ?bool
|
||||||
|
{
|
||||||
|
return $request->getPathInfo() === '/api/login' && $request->isMethod('POST');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function authenticate(Request $request): Passport
|
||||||
|
{
|
||||||
|
$data = json_decode($request->getContent(), true) ?? [];
|
||||||
|
$email = trim($data['email'] ?? '');
|
||||||
|
$password = $data['password'] ?? '';
|
||||||
|
|
||||||
|
if (!$email || !$password) {
|
||||||
|
throw new BadCredentialsException('Falsche E-Mail oder Passwort');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Passport(
|
||||||
|
new UserBadge($email),
|
||||||
|
new PasswordCredentials($password),
|
||||||
|
[new RememberMeBadge()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||||
|
{
|
||||||
|
$user = $token->getUser();
|
||||||
|
return new JsonResponse([
|
||||||
|
'ok' => true,
|
||||||
|
'email' => $user->getUserIdentifier(),
|
||||||
|
'name' => $user instanceof \App\Entity\User ? ($user->getUsername() ?? '') : '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
|
||||||
|
{
|
||||||
|
return new JsonResponse(['error' => 'Falsche E-Mail oder Passwort'], Response::HTTP_UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
}
|
||||||
113
style.css
Normal file
113
style.css
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=DM+Mono:wght@400;500&display=swap');
|
||||||
|
:root{--bg:#f5f4f0;--bg2:#fff;--bg3:#f0eeea;--border:rgba(0,0,0,.07);--border2:rgba(0,0,0,.12);--text:#1a1a1a;--text2:#666;--text3:#aaa;--green:#16a34a;--green-bg:rgba(22,163,74,.08);--blue:#2563eb;--blue-bg:rgba(37,99,235,.08);--amber:#d97706;--amber-bg:rgba(217,119,6,.08);--red:#dc2626;--red-bg:rgba(220,38,38,.08);--r:14px;--rs:8px}
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
|
||||||
|
body{font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--text);min-height:100dvh;padding-bottom:80px}
|
||||||
|
.main-wrap{max-width:480px;margin:0 auto}
|
||||||
|
.hdr{position:sticky;top:0;z-index:100;background:rgba(245,244,240,.92);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:16px 20px 14px;display:flex;align-items:center;justify-content:space-between}
|
||||||
|
.hdr-title{font-size:18px;font-weight:600;letter-spacing:-.3px}
|
||||||
|
.hdr-logo{height:100px;width:auto;mix-blend-mode:multiply;display:block}
|
||||||
|
.hdr-sub{font-size:12px;color:var(--text3);margin-top:1px;font-family:'DM Mono',monospace}
|
||||||
|
.hdr-btns{display:flex;gap:8px;align-items:center}
|
||||||
|
.btn-add{width:38px;height:38px;border-radius:50%;background:var(--text);color:var(--bg);border:none;cursor:pointer;font-size:22px;display:flex;align-items:center;justify-content:center;font-weight:300;transition:transform .15s}
|
||||||
|
.btn-add:active{transform:scale(.92)}
|
||||||
|
.btn-menu{width:38px;height:38px;border-radius:50%;background:var(--bg3);color:var(--text2);border:1px solid var(--border);cursor:pointer;font-size:20px;display:flex;align-items:center;justify-content:center;transition:transform .15s}
|
||||||
|
.btn-menu:active{transform:scale(.92)}
|
||||||
|
.main{padding:16px 16px 0}
|
||||||
|
.greeting{font-size:17px;font-weight:600;letter-spacing:-.2px;padding:0 2px 10px}
|
||||||
|
.sec-lbl{font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.6px;font-family:'DM Mono',monospace;margin-bottom:6px;margin-top:4px;padding:0 2px}
|
||||||
|
.empty{text-align:center;padding:60px 20px;color:var(--text3);font-size:14px;line-height:1.8}
|
||||||
|
.card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);margin-bottom:8px;overflow:hidden}
|
||||||
|
.card.done{opacity:.7}
|
||||||
|
.card-hdr{padding:14px 16px 12px;display:flex;align-items:center;justify-content:space-between;gap:10px;cursor:pointer;user-select:none}
|
||||||
|
.name-wrap{display:flex;align-items:center;gap:6px}
|
||||||
|
.goal-name{font-size:16px;font-weight:600;letter-spacing:-.2px}
|
||||||
|
.btn-ren{background:none;border:none;color:var(--text3);cursor:pointer;font-size:14px;padding:2px 4px;line-height:1;flex-shrink:0}
|
||||||
|
.btn-ren:active{color:var(--blue)}
|
||||||
|
.ren-input{font-family:'DM Sans',sans-serif;font-size:16px;font-weight:600;background:var(--bg3);border:1px solid var(--blue);border-radius:6px;color:var(--text);padding:2px 8px;width:100%;letter-spacing:-.2px}
|
||||||
|
.ren-input:focus{outline:none}
|
||||||
|
.goal-meta{font-size:11px;color:var(--text3);margin-top:3px;font-family:'DM Mono',monospace;line-height:1.5}
|
||||||
|
.chevron{font-size:13px;color:var(--text3);margin-left:4px;flex-shrink:0}
|
||||||
|
.badge{font-size:10px;font-weight:600;padding:3px 8px;border-radius:99px;white-space:nowrap;flex-shrink:0;letter-spacing:.3px;text-transform:uppercase}
|
||||||
|
.b-ok{background:var(--green-bg);color:var(--green)}.b-done{background:var(--green-bg);color:var(--green)}.b-warn{background:var(--amber-bg);color:var(--amber)}.b-danger{background:var(--red-bg);color:var(--red)}.b-buf{background:var(--blue-bg);color:var(--blue)}
|
||||||
|
.prog-wrap{padding:0 16px 12px}
|
||||||
|
.prog-track{height:4px;background:var(--bg3);border-radius:99px;overflow:hidden}
|
||||||
|
.prog-fill{height:100%;border-radius:99px;transition:width .4s ease}
|
||||||
|
.prog-row{display:flex;justify-content:space-between;font-size:11px;color:var(--text3);margin-top:5px;font-family:'DM Mono',monospace}
|
||||||
|
.heute-stats{padding:0 16px 14px}
|
||||||
|
.heute-group{background:var(--bg3);border-radius:var(--rs);padding:8px 8px 6px}
|
||||||
|
.heute-lbl{font-size:9px;color:var(--text3);text-transform:uppercase;letter-spacing:.5px;text-align:center;margin-bottom:6px;font-family:'DM Mono',monospace}
|
||||||
|
.heute-inner{display:grid;grid-template-columns:1fr 1fr 1fr;gap:5px}
|
||||||
|
.stat{background:var(--bg2);border-radius:6px;padding:7px 6px 5px;text-align:center}
|
||||||
|
.slbl{font-size:9px;color:var(--text3);margin-bottom:2px}
|
||||||
|
.sval{font-size:14px;font-weight:600;font-family:'DM Mono',monospace}
|
||||||
|
.sunit{font-size:8px;color:var(--text3)}
|
||||||
|
.dots-sec{padding:0 16px 12px}
|
||||||
|
.dots-lbl{font-size:11px;color:var(--text3);margin-bottom:6px}
|
||||||
|
.dots-wrap{display:flex;gap:3px;flex-wrap:wrap}
|
||||||
|
.dot{width:26px;height:26px;border-radius:5px;display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:600;font-family:'DM Mono',monospace;transition:opacity .15s}
|
||||||
|
.df{background:var(--bg3);color:var(--text3)}
|
||||||
|
.dm{background:rgba(220,38,38,.12);color:var(--red)}
|
||||||
|
.dp{background:rgba(217,119,6,.12);color:var(--amber)}
|
||||||
|
.dd{background:rgba(22,163,74,.12);color:var(--green)}
|
||||||
|
.db{background:rgba(37,99,235,.12);color:var(--blue)}
|
||||||
|
.de{cursor:pointer}.de:active{opacity:.6;transform:scale(.9)}.dl{opacity:.6;cursor:default}
|
||||||
|
.rt{box-shadow:0 0 0 2px var(--text2)}.ry{box-shadow:0 0 0 2px var(--text3)}.rs{box-shadow:0 0 0 2px var(--blue) !important}
|
||||||
|
.legend{display:flex;gap:10px;flex-wrap:wrap;margin-top:8px}
|
||||||
|
.leg{display:flex;align-items:center;gap:4px;font-size:10px;color:var(--text3)}
|
||||||
|
.ldot{width:8px;height:8px;border-radius:2px}
|
||||||
|
.dpanel{margin:0 16px 14px;background:var(--bg3);border-radius:var(--rs);overflow:hidden;border:1px solid var(--border2)}
|
||||||
|
.dpanel-hdr{padding:10px 12px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--border)}
|
||||||
|
.dpanel-title{font-size:13px;font-weight:600}
|
||||||
|
.dpanel-sub{font-size:11px;color:var(--text2);font-family:'DM Mono',monospace}
|
||||||
|
.dpanel-body{padding:10px 12px}
|
||||||
|
.set-row{display:flex;align-items:center;justify-content:space-between;padding:5px 0;border-bottom:1px solid var(--border);font-size:13px}
|
||||||
|
.set-row:last-of-type{border-bottom:none}
|
||||||
|
.stime{font-size:10px;color:var(--text3);font-family:'DM Mono',monospace;margin-right:4px}
|
||||||
|
.sdel{background:none;border:none;color:var(--text3);cursor:pointer;font-size:16px;padding:2px 6px;border-radius:4px}
|
||||||
|
.sdel:active{color:var(--red)}
|
||||||
|
.add-row{display:flex;gap:8px;align-items:center;margin-top:10px}
|
||||||
|
.num-in{font-family:'DM Mono',monospace;font-size:15px;padding:8px 10px;border-radius:var(--rs);border:1px solid var(--border2);background:var(--bg2);color:var(--text);width:90px;text-align:center}
|
||||||
|
.num-in:focus{outline:none;border-color:var(--blue)}
|
||||||
|
.ulbl{font-size:12px;color:var(--text3)}
|
||||||
|
.btn-as{flex:1;padding:8px;border-radius:var(--rs);background:var(--blue-bg);color:var(--blue);border:1px solid rgba(37,99,235,.2);cursor:pointer;font-family:'DM Sans',sans-serif;font-size:13px;font-weight:600}
|
||||||
|
.btn-as:active{opacity:.7}
|
||||||
|
.nosets{font-size:12px;color:var(--text3);padding:4px 0 8px}
|
||||||
|
.card-foot{padding:8px 16px 12px;display:flex;justify-content:flex-end}
|
||||||
|
.btn-del{background:none;border:none;color:var(--text3);font-size:12px;cursor:pointer;padding:4px 0;font-family:'DM Sans',sans-serif}
|
||||||
|
.btn-del:active{color:var(--red)}
|
||||||
|
.qb-card{padding:0 16px}
|
||||||
|
.qb-row{display:flex;align-items:center;gap:8px;padding:10px 0;border-bottom:1px solid var(--border)}
|
||||||
|
.qb-row:last-child{border-bottom:none}
|
||||||
|
.qb-name{flex:1;font-size:16px;font-weight:600;letter-spacing:-.2px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;min-width:0}
|
||||||
|
.qb-stat{font-size:11px;font-family:'DM Mono',monospace;color:var(--text3);white-space:nowrap}
|
||||||
|
.qb-row .num-in{width:62px;padding:6px 6px;font-size:13px}
|
||||||
|
.qb-row .btn-as{flex:none;padding:8px 14px;font-size:13px}
|
||||||
|
.ov{position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.5);display:flex;align-items:flex-end;animation:fi .2s ease}
|
||||||
|
@keyframes fi{from{opacity:0}to{opacity:1}}
|
||||||
|
.sheet{width:100%;max-width:480px;background:var(--bg2);border-radius:20px 20px 0 0;border:1px solid var(--border2);padding:20px 20px 40px;animation:su .25s ease;max-height:90dvh;overflow-y:auto}
|
||||||
|
@keyframes su{from{transform:translateY(100%)}to{transform:translateY(0)}}
|
||||||
|
.shandle{width:36px;height:4px;border-radius:2px;background:var(--border2);margin:0 auto 20px}
|
||||||
|
.stitle{font-size:17px;font-weight:600;margin-bottom:4px}
|
||||||
|
.ssub{font-size:12px;color:var(--text3);margin-bottom:16px}
|
||||||
|
.fgrid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px}
|
||||||
|
.ff{margin-bottom:10px}
|
||||||
|
.ff label{font-size:11px;color:var(--text3);display:block;margin-bottom:4px;text-transform:uppercase;letter-spacing:.5px}
|
||||||
|
.fi{width:100%;font-family:'DM Sans',sans-serif;font-size:15px;padding:10px 12px;border-radius:var(--rs);border:1px solid var(--border2);background:var(--bg3);color:var(--text)}
|
||||||
|
.fi:focus{outline:none;border-color:var(--blue)}
|
||||||
|
.factions{display:flex;gap:8px;margin-top:16px}
|
||||||
|
.btn-p{flex:1;padding:13px;border-radius:var(--rs);background:var(--text);color:var(--bg);border:none;cursor:pointer;font-family:'DM Sans',sans-serif;font-size:15px;font-weight:600}
|
||||||
|
.btn-p:active{opacity:.8}
|
||||||
|
.btn-c{padding:13px 20px;border-radius:var(--rs);background:var(--bg3);color:var(--text2);border:1px solid var(--border);cursor:pointer;font-family:'DM Sans',sans-serif;font-size:15px}
|
||||||
|
.dbtn{width:100%;padding:13px 16px;border-radius:var(--rs);background:var(--bg3);border:1px solid var(--border);color:var(--text);font-family:'DM Sans',sans-serif;font-size:15px;text-align:left;cursor:pointer;margin-bottom:8px;display:flex;align-items:center;gap:12px}
|
||||||
|
.dbtn:active{background:var(--border2)}
|
||||||
|
.dico{font-size:18px;width:24px;text-align:center;flex-shrink:0}
|
||||||
|
.dlbl{flex:1}.dsub{font-size:11px;color:var(--text3);display:block;margin-top:1px}
|
||||||
|
.ddanger{color:var(--red)}
|
||||||
|
.ddiv{height:1px;background:var(--border);margin:12px 0}
|
||||||
|
.awards{display:flex;gap:4px;align-items:center;flex-wrap:wrap;padding:0 16px 10px}
|
||||||
|
.aw{font-size:24px;line-height:1}
|
||||||
|
.login-err{background:var(--red-bg);color:var(--red);border-radius:var(--rs);padding:10px 12px;font-size:13px;margin-bottom:12px}
|
||||||
|
.toast{position:fixed;bottom:100px;left:50%;transform:translateX(-50%);background:#1a1a1a;color:#fff;padding:10px 16px;border-radius:8px;font-size:13px;z-index:300;white-space:nowrap;animation:fi .2s ease}
|
||||||
|
.hint{margin:0 16px 12px;background:var(--blue-bg);border:1px solid rgba(37,99,235,.15);border-radius:var(--rs);padding:10px 12px;font-size:12px;color:var(--blue);line-height:1.5;display:flex;justify-content:space-between;align-items:center;gap:8px}
|
||||||
|
.hclose{background:none;border:none;color:var(--blue);cursor:pointer;font-size:16px;padding:0 2px}
|
||||||
|
.btn-lnk{background:none;border:none;color:var(--text2);font-size:13px;cursor:pointer;padding:4px 8px;text-decoration:underline;text-underline-offset:2px}
|
||||||
170
symfony.lock
Normal file
170
symfony.lock
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
{
|
||||||
|
"doctrine/deprecations": {
|
||||||
|
"version": "1.1",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "1.0",
|
||||||
|
"ref": "fdd756167454623e21f1d769c5b814b243782a67"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"doctrine/doctrine-bundle": {
|
||||||
|
"version": "3.2",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "3.0",
|
||||||
|
"ref": "d39a3bd844edfe90c20ae520b804a3bf4f82b4ad"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/doctrine.yaml",
|
||||||
|
"src/Entity/.gitignore",
|
||||||
|
"src/Repository/.gitignore"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"doctrine/doctrine-migrations-bundle": {
|
||||||
|
"version": "4.0",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "3.1",
|
||||||
|
"ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/doctrine_migrations.yaml",
|
||||||
|
"migrations/.gitignore"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"symfony/console": {
|
||||||
|
"version": "8.0",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "5.3",
|
||||||
|
"ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"bin/console"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"symfony/flex": {
|
||||||
|
"version": "2.10",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "2.4",
|
||||||
|
"ref": "52e9754527a15e2b79d9a610f98185a1fe46622a"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
".env",
|
||||||
|
".env.dev"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"symfony/framework-bundle": {
|
||||||
|
"version": "8.0",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "7.4",
|
||||||
|
"ref": "d5dcd308c8becd725c9d8b91e31aab1ff0bbc30b"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/cache.yaml",
|
||||||
|
"config/packages/framework.yaml",
|
||||||
|
"config/preload.php",
|
||||||
|
"config/routes/framework.yaml",
|
||||||
|
"config/services.yaml",
|
||||||
|
"public/index.php",
|
||||||
|
"src/Controller/.gitignore",
|
||||||
|
"src/Kernel.php",
|
||||||
|
".editorconfig"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"symfony/mailer": {
|
||||||
|
"version": "8.0",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "4.3",
|
||||||
|
"ref": "09051cfde49476e3c12cd3a0e44289ace1c75a4f"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/mailer.yaml"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"symfony/messenger": {
|
||||||
|
"version": "8.0",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "6.0",
|
||||||
|
"ref": "d8936e2e2230637ef97e5eecc0eea074eecae58b"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/messenger.yaml"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"symfony/property-info": {
|
||||||
|
"version": "8.0",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "7.3",
|
||||||
|
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/property_info.yaml"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"symfony/routing": {
|
||||||
|
"version": "8.0",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "7.4",
|
||||||
|
"ref": "bc94c4fd86f393f3ab3947c18b830ea343e51ded"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/routing.yaml",
|
||||||
|
"config/routes.yaml"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"symfony/security-bundle": {
|
||||||
|
"version": "8.0",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "7.4",
|
||||||
|
"ref": "c42fee7802181cdd50f61b8622715829f5d2335c"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/security.yaml",
|
||||||
|
"config/routes/security.yaml"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"symfony/twig-bundle": {
|
||||||
|
"version": "8.0",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "6.4",
|
||||||
|
"ref": "f250159ebe99153d0c640a3e7742876fc7453f2c"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/twig.yaml",
|
||||||
|
"templates/base.html.twig"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"symfony/validator": {
|
||||||
|
"version": "8.0",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "7.0",
|
||||||
|
"ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/validator.yaml"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
329
templates/app.html.twig
Normal file
329
templates/app.html.twig
Normal file
|
|
@ -0,0 +1,329 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
|
||||||
|
<meta name="theme-color" content="#f5f4f0"/>
|
||||||
|
<meta name="mobile-web-app-capable" content="yes"/>
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes"/>
|
||||||
|
<title>Dudi</title>
|
||||||
|
<link rel="stylesheet" href="style.css"/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="main-wrap">
|
||||||
|
<div class="hdr">
|
||||||
|
<div>
|
||||||
|
<img src="logo.png" alt="Dudi" class="hdr-logo"/>
|
||||||
|
<div class="hdr-sub" id="tlbl"></div>
|
||||||
|
</div>
|
||||||
|
<div class="hdr-btns">
|
||||||
|
<button class="btn-menu" id="btnData">⋯</button>
|
||||||
|
<button class="btn-add" id="btnNew">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="main" id="main"></div>
|
||||||
|
</div>
|
||||||
|
<div id="ov" style="display:none"></div>
|
||||||
|
|
||||||
|
<!-- ── Templates ──────────────────────────────────────────────────────────── -->
|
||||||
|
|
||||||
|
<template id="tpl-hint">
|
||||||
|
<div class="hint">Menü → "Zum Startbildschirm" für App-Icon<button class="hclose">×</button></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-empty">
|
||||||
|
<div class="empty"><div style="font-size:40px;opacity:.4;margin-bottom:12px">🎯</div>Noch keine Ziele.<br>Tippe auf <strong>+</strong> um zu starten.</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-dot">
|
||||||
|
<div></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-nosets">
|
||||||
|
<div class="nosets">Noch kein Eintrag</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-set-row">
|
||||||
|
<div class="set-row">
|
||||||
|
<span></span>
|
||||||
|
<button class="sdel">×</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-add-row">
|
||||||
|
<div class="add-row">
|
||||||
|
<input class="num-in" type="number" min="1"/>
|
||||||
|
<span class="ulbl"></span>
|
||||||
|
<button class="btn-as">Eintragen</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-panel">
|
||||||
|
<div class="dpanel">
|
||||||
|
<div class="dpanel-hdr">
|
||||||
|
<span class="dpanel-title"></span>
|
||||||
|
<span class="dpanel-sub"></span>
|
||||||
|
</div>
|
||||||
|
<div class="dpanel-body"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-qb-row">
|
||||||
|
<div class="qb-row">
|
||||||
|
<div class="qb-name"></div>
|
||||||
|
<div class="qb-stat"></div>
|
||||||
|
<input class="num-in" type="number" min="1"/>
|
||||||
|
<button class="btn-as">Eintragen</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-name-view">
|
||||||
|
<div class="name-wrap">
|
||||||
|
<div class="goal-name"></div>
|
||||||
|
<button class="btn-ren">✎</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-name-edit">
|
||||||
|
<div class="name-wrap">
|
||||||
|
<input class="ren-input" type="text"/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-card-collapsed">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-hdr">
|
||||||
|
<div class="card-bd" style="flex:1;min-width:0">
|
||||||
|
<div class="goal-meta">Noch <span class="m-dr"></span>T · endet <span class="m-end"></span> · heute: <span class="m-heute"></span><br>total: <span class="m-total"></span></div>
|
||||||
|
</div>
|
||||||
|
<span class="badge"></span>
|
||||||
|
<span class="chevron">▸</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:0 16px 12px">
|
||||||
|
<div class="prog-track"><div class="prog-fill"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-card-expanded">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-hdr">
|
||||||
|
<div class="card-bd" style="flex:1;min-width:0">
|
||||||
|
<div class="goal-meta">Noch <span class="m-dr"></span>T · endet <span class="m-end"></span></div>
|
||||||
|
</div>
|
||||||
|
<span class="badge"></span>
|
||||||
|
<span class="chevron">▴</span>
|
||||||
|
</div>
|
||||||
|
<div class="prog-wrap">
|
||||||
|
<div class="prog-track"><div class="prog-fill"></div></div>
|
||||||
|
<div class="prog-row">
|
||||||
|
<span class="pr-done"></span>
|
||||||
|
<span class="pr-pct"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="heute-stats">
|
||||||
|
<div class="heute-group">
|
||||||
|
<div class="heute-lbl">Heute</div>
|
||||||
|
<div class="heute-inner">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="slbl">Gemacht</div>
|
||||||
|
<div class="sval"><span class="sv-tdone"></span><div class="sunit"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="slbl">Tagesziel</div>
|
||||||
|
<div class="sval"><span class="sv-daily"></span><div class="sunit"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="slbl">Noch</div>
|
||||||
|
<div class="sval sv-noch"><span class="sv-st"></span><div class="sunit"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dots-sec">
|
||||||
|
<div class="dots-lbl">Verlauf — heute & gestern bearbeitbar</div>
|
||||||
|
<div class="dots-wrap"></div>
|
||||||
|
<div class="legend">
|
||||||
|
<span class="leg"><span class="ldot" style="background:rgba(37,99,235,.3)"></span>Puffer</span>
|
||||||
|
<span class="leg"><span class="ldot" style="background:rgba(22,163,74,.3)"></span>Erreicht</span>
|
||||||
|
<span class="leg"><span class="ldot" style="background:rgba(217,119,6,.3)"></span>Teilweise</span>
|
||||||
|
<span class="leg"><span class="ldot" style="background:rgba(220,38,38,.3)"></span>Verpasst</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-foot"><button class="btn-del">Ziel löschen</button></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-sheet">
|
||||||
|
<div class="sheet">
|
||||||
|
<div class="shandle"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-login">
|
||||||
|
<div>
|
||||||
|
<div class="stitle">Anmelden</div>
|
||||||
|
<div class="ssub">Dudi</div>
|
||||||
|
<div class="login-err" style="display:none"></div>
|
||||||
|
<div class="ff"><label>E-Mail</label><input class="fi lf-email" type="email" autocomplete="email"/></div>
|
||||||
|
<div class="ff"><label>Passwort</label><input class="fi lf-pass" type="password" autocomplete="current-password"/></div>
|
||||||
|
<div class="factions"><button class="btn-p lf-sub">Anmelden</button></div>
|
||||||
|
<div style="text-align:center;margin-top:8px"><button class="btn-lnk lf-fgt">Passwort vergessen?</button></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-forgot-pw">
|
||||||
|
<div>
|
||||||
|
<div class="stitle">Passwort vergessen</div>
|
||||||
|
<div class="ssub">Wir schicken dir einen Reset-Link</div>
|
||||||
|
<div class="ff"><label>E-Mail</label><input class="fi fp-email" type="email" autocomplete="email"/></div>
|
||||||
|
<div class="login-err" style="display:none"></div>
|
||||||
|
<div class="factions">
|
||||||
|
<button class="btn-p fp-sub">Link senden</button>
|
||||||
|
<button class="btn-c fp-back">Zurück</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-email-sent">
|
||||||
|
<div>
|
||||||
|
<div class="stitle">E-Mail gesendet</div>
|
||||||
|
<div class="ssub">Falls die Adresse bekannt ist, erhältst du in Kürze einen Reset-Link.</div>
|
||||||
|
<div class="factions"><button class="btn-p es-ok">OK</button></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-reset-pw">
|
||||||
|
<div>
|
||||||
|
<div class="stitle">Neues Passwort</div>
|
||||||
|
<div class="ff"><label>Neues Passwort</label><input class="fi rp-pass" type="password" autocomplete="new-password" placeholder="mind. 8 Zeichen"/></div>
|
||||||
|
<div class="login-err" style="display:none"></div>
|
||||||
|
<div class="factions"><button class="btn-p rp-sub">Passwort setzen</button></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-pw-changed">
|
||||||
|
<div>
|
||||||
|
<div class="stitle">Passwort geändert</div>
|
||||||
|
<div class="ssub">Du kannst dich jetzt anmelden.</div>
|
||||||
|
<div class="factions"><button class="btn-p pc-ok">Anmelden</button></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-change-name">
|
||||||
|
<div>
|
||||||
|
<div class="stitle">Name ändern</div>
|
||||||
|
<div class="ff"><label>Dein Name</label><input class="fi cn-name" type="text" autocomplete="name"/></div>
|
||||||
|
<div class="login-err" style="display:none"></div>
|
||||||
|
<div class="factions">
|
||||||
|
<button class="btn-p cn-sub">Speichern</button>
|
||||||
|
<button class="btn-c cn-can">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-change-pw">
|
||||||
|
<div>
|
||||||
|
<div class="stitle">Passwort ändern</div>
|
||||||
|
<div class="ff"><label>Aktuelles Passwort</label><input class="fi cp-old" type="password" autocomplete="current-password"/></div>
|
||||||
|
<div class="ff"><label>Neues Passwort</label><input class="fi cp-new" type="password" autocomplete="new-password" placeholder="mind. 8 Zeichen"/></div>
|
||||||
|
<div class="ff"><label>Neues Passwort bestätigen</label><input class="fi cp-new2" type="password" autocomplete="new-password" placeholder="mind. 8 Zeichen"/></div>
|
||||||
|
<div class="login-err" style="display:none"></div>
|
||||||
|
<div class="factions">
|
||||||
|
<button class="btn-p cp-sub">Ändern</button>
|
||||||
|
<button class="btn-c cp-can">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-register">
|
||||||
|
<div>
|
||||||
|
<div class="stitle">Konto erstellen</div>
|
||||||
|
<div class="ssub">Du wurdest eingeladen</div>
|
||||||
|
<div class="ff"><label>Dein Name</label><input class="fi rg-name" type="text" autocomplete="name" placeholder="Wie sollen wir dich nennen?"/></div>
|
||||||
|
<div class="ff"><label>E-Mail</label><input class="fi rg-email" type="email" autocomplete="email"/></div>
|
||||||
|
<div class="ff"><label>Passwort</label><input class="fi rg-pass" type="password" autocomplete="new-password" placeholder="mind. 8 Zeichen"/></div>
|
||||||
|
<div class="ff"><label>Passwort bestätigen</label><input class="fi rg-pass2" type="password" autocomplete="new-password" placeholder="Passwort wiederholen"/></div>
|
||||||
|
<div class="login-err" style="display:none"></div>
|
||||||
|
<div class="factions"><button class="btn-p rg-sub">Registrieren</button></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-new-goal">
|
||||||
|
<div>
|
||||||
|
<div class="stitle">Neues Ziel</div>
|
||||||
|
<div class="ff"><label>Übung / Gewohnheit</label><input class="fi ng-name" type="text" placeholder="Liegestütz, Plank …"/></div>
|
||||||
|
<div class="fgrid">
|
||||||
|
<div class="ff"><label>Einheit</label><input class="fi ng-unit" type="text" value="Stück"/></div>
|
||||||
|
<div class="ff"><label>Tagesziel</label><input class="fi ng-daily" type="number" min="1" value="50"/></div>
|
||||||
|
</div>
|
||||||
|
<div class="ff"><label>Dauer in Tagen</label><input class="fi ng-days" type="number" min="7" max="365" value="30"/></div>
|
||||||
|
<div class="factions">
|
||||||
|
<button class="btn-p ng-sub">Ziel starten</button>
|
||||||
|
<button class="btn-c ng-can">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-data-menu">
|
||||||
|
<div>
|
||||||
|
<div class="stitle">Daten verwalten</div>
|
||||||
|
<div class="ssub">Export, Import und Backup</div>
|
||||||
|
<button class="dbtn dm-exp"><span class="dico">⬇</span><span class="dlbl">Exportieren<span class="dsub">Alle Ziele als JSON-Datei speichern</span></span></button>
|
||||||
|
<button class="dbtn dm-imp"><span class="dico">⬆</span><span class="dlbl">Importieren<span class="dsub">Backup laden oder zusammenführen</span></span></button>
|
||||||
|
<div class="ddiv"></div>
|
||||||
|
<button class="dbtn dm-inv"><span class="dico">🔗</span><span class="dlbl">Freund einladen<span class="dsub">Einladungslink generieren</span></span></button>
|
||||||
|
<button class="dbtn dm-invlist"><span class="dico">📋</span><span class="dlbl">Meine Einladungen<span class="dsub">Status aller gesendeten Einladungen</span></span></button>
|
||||||
|
<div class="ddiv"></div>
|
||||||
|
<button class="dbtn dm-name"><span class="dico">✏️</span><span class="dlbl">Name ändern</span></button>
|
||||||
|
<button class="dbtn dm-cpw"><span class="dico">🔑</span><span class="dlbl">Passwort ändern</span></button>
|
||||||
|
<button class="dbtn dm-lgout"><span class="dico">→</span><span class="dlbl">Abmelden</span></button>
|
||||||
|
<button class="btn-c dm-cls" style="width:100%;margin-top:4px;text-align:center">Schließen</button>
|
||||||
|
<div class="ddiv" style="margin-top:16px"></div>
|
||||||
|
<button class="dbtn ddanger dm-clr"><span class="dico">✕</span><span class="dlbl">Alle Daten löschen<span class="dsub">Kann nicht rückgängig gemacht werden</span></span></button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-invite-form">
|
||||||
|
<div>
|
||||||
|
<div class="stitle">Freund einladen</div>
|
||||||
|
<div class="ssub">Link gilt 7 Tage und kann nur einmal verwendet werden</div>
|
||||||
|
<div class="ff"><label>Name (für deine Übersicht)</label><input class="fi inv-name" type="text" placeholder="z.B. Max"/></div>
|
||||||
|
<div class="factions">
|
||||||
|
<button class="btn-p inv-gen">Link generieren</button>
|
||||||
|
<button class="btn-c inv-cancel">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-invite-link">
|
||||||
|
<div>
|
||||||
|
<div class="stitle"></div>
|
||||||
|
<div class="ssub">Link gilt 7 Tage und kann nur einmal verwendet werden</div>
|
||||||
|
<div class="ff"><input class="fi il-url" type="text" readonly/></div>
|
||||||
|
<div class="factions">
|
||||||
|
<button class="btn-p il-copy">Link kopieren</button>
|
||||||
|
<button class="btn-c il-close">Schließen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-invite-list">
|
||||||
|
<div>
|
||||||
|
<div class="stitle">Meine Einladungen</div>
|
||||||
|
<div class="dpanel-body"></div>
|
||||||
|
<div class="factions"><button class="btn-c il-close">Schließen</button></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-invite-row">
|
||||||
|
<div class="set-row">
|
||||||
|
<span style="flex:1"><strong class="ir-label"></strong><span class="ir-detail" style="opacity:.6;font-size:.85em"></span></span>
|
||||||
|
<button class="ir-copy btn-lnk" style="font-size:.8em;display:none">🔗 Link</button>
|
||||||
|
<span class="ir-status" style="font-size:.85em;font-weight:600"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in a new issue