Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0945d1d2ba | ||
|
|
9bb3bccd6d | ||
|
|
f469086bc0 | ||
|
|
1cdef43a87 | ||
|
|
23416ee382 | ||
|
|
47fa309885 |
@@ -0,0 +1,3 @@
|
|||||||
|
config/config.php
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -1,38 +1,77 @@
|
|||||||
# Habit Tracker
|
# Habit Tracker
|
||||||
|
|
||||||
## Projektziel
|
Habit Tracker ist eine PHP-Webanwendung, mit der Benutzer eigene Gewohnheiten anlegen, bearbeiten, filtern und taeglich abhaken koennen.
|
||||||
|
|
||||||
Der Habit Tracker ist eine Webanwendung, mit der Benutzer eigene Gewohnheiten anlegen, verwalten und täglich abhaken können. Ziel ist es, Fortschritte sichtbar zu machen und Benutzer beim Aufbau guter Routinen zu unterstützen.
|
## Funktionen
|
||||||
|
|
||||||
## Geplante Hauptfunktionen
|
- Registrierung und Login mit Passwort-Hashing
|
||||||
|
- Geschuetzter Benutzerbereich per Session
|
||||||
|
- Habits erstellen, bearbeiten, loeschen und fuer heute abhaken
|
||||||
|
- Kategorien und Statusfilter im Dashboard
|
||||||
|
- Tagesprotokoll ueber `habit_logs`
|
||||||
|
- Wochenfortschritt im Dashboard gegen das persoenliche Wochenziel
|
||||||
|
- Responsive Oberflaeche fuer Desktop und Smartphone
|
||||||
|
- Serverseitige Validierung und klare Fehlermeldungen
|
||||||
|
- Schutz gegen SQL-Injection durch PDO Prepared Statements
|
||||||
|
- Schutz gegen XSS durch escaped HTML-Ausgabe
|
||||||
|
- CSRF-Schutz fuer schreibende Formulare
|
||||||
|
|
||||||
- Registrierung und Login
|
## Technik
|
||||||
- Dashboard mit heutigen Habits
|
|
||||||
- Habits erstellen, bearbeiten und löschen
|
|
||||||
- Habits täglich abhaken
|
|
||||||
- Übersicht aller Habits mit Filter und Sortierung
|
|
||||||
- Detailansicht mit Verlauf, Erfolgsquote und Streak
|
|
||||||
- Kategorien für Habits
|
|
||||||
- Responsive Oberfläche für Desktop und Smartphone
|
|
||||||
|
|
||||||
## Geplante Datenbanktabellen
|
|
||||||
|
|
||||||
- users
|
|
||||||
- habits
|
|
||||||
- habit_logs
|
|
||||||
- categories
|
|
||||||
- reminders
|
|
||||||
|
|
||||||
## Technische Anforderungen
|
|
||||||
|
|
||||||
|
- PHP
|
||||||
|
- HTML/CSS
|
||||||
|
- SQL
|
||||||
|
- MySQL/PDO
|
||||||
- MVC-Struktur
|
- MVC-Struktur
|
||||||
- PHP, HTML, CSS, SQL
|
|
||||||
- Prepared Statements zum Schutz vor SQL-Injections
|
|
||||||
- htmlspecialchars zum Schutz vor XSS
|
|
||||||
- Passwortspeicherung mit password_hash
|
|
||||||
- Session-basierter Login
|
|
||||||
- Optional: Fetch API zum Abhaken ohne Neuladen der Seite
|
|
||||||
|
|
||||||
## Bewertungsziel
|
## MVC-Struktur
|
||||||
|
|
||||||
Das Projekt wird mit dem Ziel entwickelt, eine sehr gute Bewertung zu erreichen. Deshalb liegt der Fokus auf sauberer Struktur, guter Bedienbarkeit, Sicherheit, responsivem Design und einer überzeugenden Präsentation.
|
- `public/index.php`: Einstiegspunkt und Routing
|
||||||
|
- `app/Controllers/`: verarbeitet Anfragen und steuert den Ablauf
|
||||||
|
- `app/Models/`: kapselt Datenbankzugriffe
|
||||||
|
- `app/Views/`: erzeugt die HTML-Ausgabe
|
||||||
|
- `app/Core/`: gemeinsame Kernklassen fuer Datenbank und Views
|
||||||
|
- `app/helpers.php`: Hilfsfunktionen fuer Escaping, Sessions, Flash-Meldungen und CSRF
|
||||||
|
- `database/schema.sql`: Datenbankstruktur mit Startdaten
|
||||||
|
|
||||||
|
## Datenbank
|
||||||
|
|
||||||
|
Die Datenbank besteht aus 5 Tabellen:
|
||||||
|
|
||||||
|
- `users`: Benutzerkonten
|
||||||
|
- `categories`: Habit-Kategorien
|
||||||
|
- `habits`: Gewohnheiten der Benutzer
|
||||||
|
- `habit_logs`: taegliche Erledigungen
|
||||||
|
- `reminders`: vorbereitete Erinnerungen pro Habit
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
- Passwoerter werden mit `password_hash()` gespeichert.
|
||||||
|
- Login prueft Passwoerter mit `password_verify()`.
|
||||||
|
- SQL-Abfragen nutzen vorbereitete PDO-Statements.
|
||||||
|
- Ausgaben werden mit `htmlspecialchars()` escaped.
|
||||||
|
- Schreibende Formulare nutzen CSRF-Tokens.
|
||||||
|
- Nach erfolgreichem Login wird die Session-ID erneuert.
|
||||||
|
- Die echte `config/config.php` wird nicht mit Git hochgeladen.
|
||||||
|
|
||||||
|
## Einrichtung
|
||||||
|
|
||||||
|
1. `database/schema.sql` in phpMyAdmin importieren.
|
||||||
|
2. `config/config.example.php` zu `config/config.php` kopieren.
|
||||||
|
3. Datenbankdaten in `config/config.php` eintragen.
|
||||||
|
4. Projekt ueber `public/index.php` starten.
|
||||||
|
|
||||||
|
## Testablauf
|
||||||
|
|
||||||
|
1. Benutzer registrieren.
|
||||||
|
2. Einloggen.
|
||||||
|
3. Habit mit Kategorie und Wochenziel erstellen.
|
||||||
|
4. Habit im Dashboard filtern.
|
||||||
|
5. Habit fuer heute abhaken.
|
||||||
|
6. Wochenfortschritt im Dashboard kontrollieren.
|
||||||
|
7. Habit bearbeiten und loeschen.
|
||||||
|
8. Seite auf Smartphone-Breite pruefen.
|
||||||
|
|
||||||
|
## Bewertungsbezug
|
||||||
|
|
||||||
|
Das Projekt zeigt eine klare MVC-Aufteilung, Datenbankarbeit mit mehreren verknuepften Tabellen, serverseitige Validierung, Sicherheitsmassnahmen gegen XSS, SQL-Injection und CSRF sowie eine responsive Benutzeroberflaeche.
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class AuthController
|
||||||
|
{
|
||||||
|
public function showLogin(): void
|
||||||
|
{
|
||||||
|
View::render('auth/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function login(): void
|
||||||
|
{
|
||||||
|
verify_csrf_token();
|
||||||
|
|
||||||
|
$email = trim($_POST['email'] ?? '');
|
||||||
|
$password = $_POST['password'] ?? '';
|
||||||
|
|
||||||
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || $password === '') {
|
||||||
|
flash('error', 'Bitte gib eine gueltige E-Mail und dein Passwort ein.');
|
||||||
|
redirect('login');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::findByEmail($email);
|
||||||
|
|
||||||
|
if (!$user || !password_verify($password, $user['password_hash'])) {
|
||||||
|
flash('error', 'E-Mail oder Passwort ist nicht korrekt.');
|
||||||
|
redirect('login');
|
||||||
|
}
|
||||||
|
|
||||||
|
session_regenerate_id(true);
|
||||||
|
$_SESSION['user_id'] = (int) $user['id'];
|
||||||
|
$_SESSION['user_name'] = $user['name'];
|
||||||
|
redirect('dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function showRegister(): void
|
||||||
|
{
|
||||||
|
View::render('auth/register');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
verify_csrf_token();
|
||||||
|
|
||||||
|
$name = trim($_POST['name'] ?? '');
|
||||||
|
$email = trim($_POST['email'] ?? '');
|
||||||
|
$password = $_POST['password'] ?? '';
|
||||||
|
|
||||||
|
if ($name === '' || !filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($password) < 8) {
|
||||||
|
flash('error', 'Bitte Name, gueltige E-Mail und ein Passwort mit mindestens 8 Zeichen eingeben.');
|
||||||
|
redirect('register');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (User::findByEmail($email)) {
|
||||||
|
flash('error', 'Diese E-Mail ist bereits registriert.');
|
||||||
|
redirect('register');
|
||||||
|
}
|
||||||
|
|
||||||
|
User::create($name, $email, $password);
|
||||||
|
flash('success', 'Registrierung erfolgreich. Du kannst dich jetzt anmelden.');
|
||||||
|
redirect('login');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(): void
|
||||||
|
{
|
||||||
|
verify_csrf_token();
|
||||||
|
|
||||||
|
$_SESSION = [];
|
||||||
|
if (ini_get('session.use_cookies')) {
|
||||||
|
$params = session_get_cookie_params();
|
||||||
|
setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
|
||||||
|
}
|
||||||
|
session_destroy();
|
||||||
|
redirect('login');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class HabitController
|
||||||
|
{
|
||||||
|
public function dashboard(): void
|
||||||
|
{
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
$filters = [
|
||||||
|
'category_id' => $_GET['category_id'] ?? '',
|
||||||
|
'status' => $_GET['status'] ?? '',
|
||||||
|
];
|
||||||
|
|
||||||
|
View::render('habits/dashboard', [
|
||||||
|
'habits' => Habit::allForUser(current_user_id(), $filters),
|
||||||
|
'categories' => Category::all(),
|
||||||
|
'filters' => $filters,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(): void
|
||||||
|
{
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
View::render('habits/form', [
|
||||||
|
'habit' => null,
|
||||||
|
'categories' => Category::all(),
|
||||||
|
'errors' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(): void
|
||||||
|
{
|
||||||
|
require_login();
|
||||||
|
verify_csrf_token();
|
||||||
|
|
||||||
|
$data = $this->validatedData();
|
||||||
|
|
||||||
|
if (!empty($data['errors'])) {
|
||||||
|
View::render('habits/form', [
|
||||||
|
'habit' => $_POST,
|
||||||
|
'categories' => Category::all(),
|
||||||
|
'errors' => $data['errors'],
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Habit::create(current_user_id(), $data);
|
||||||
|
flash('success', 'Habit wurde erstellt.');
|
||||||
|
redirect('dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(): void
|
||||||
|
{
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
$habit = Habit::findForUser((int) ($_GET['id'] ?? 0), current_user_id());
|
||||||
|
|
||||||
|
if (!$habit) {
|
||||||
|
flash('error', 'Habit wurde nicht gefunden.');
|
||||||
|
redirect('dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
View::render('habits/form', [
|
||||||
|
'habit' => $habit,
|
||||||
|
'categories' => Category::all(),
|
||||||
|
'errors' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(): void
|
||||||
|
{
|
||||||
|
require_login();
|
||||||
|
verify_csrf_token();
|
||||||
|
|
||||||
|
$id = (int) ($_POST['id'] ?? 0);
|
||||||
|
$data = $this->validatedData();
|
||||||
|
|
||||||
|
if (!empty($data['errors'])) {
|
||||||
|
View::render('habits/form', [
|
||||||
|
'habit' => array_merge($_POST, ['id' => $id]),
|
||||||
|
'categories' => Category::all(),
|
||||||
|
'errors' => $data['errors'],
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Habit::update($id, current_user_id(), $data);
|
||||||
|
flash('success', 'Habit wurde gespeichert.');
|
||||||
|
redirect('dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(): void
|
||||||
|
{
|
||||||
|
require_login();
|
||||||
|
verify_csrf_token();
|
||||||
|
|
||||||
|
Habit::delete((int) ($_POST['id'] ?? 0), current_user_id());
|
||||||
|
flash('success', 'Habit wurde geloescht.');
|
||||||
|
redirect('dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggle(): void
|
||||||
|
{
|
||||||
|
require_login();
|
||||||
|
verify_csrf_token();
|
||||||
|
|
||||||
|
Habit::toggleToday((int) ($_POST['id'] ?? 0), current_user_id());
|
||||||
|
redirect('dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validatedData(): array
|
||||||
|
{
|
||||||
|
$title = trim($_POST['title'] ?? '');
|
||||||
|
$description = trim($_POST['description'] ?? '');
|
||||||
|
$categoryId = $_POST['category_id'] ?? '';
|
||||||
|
$targetPerWeek = (int) ($_POST['target_per_week'] ?? 0);
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
if ($title === '') {
|
||||||
|
$errors['title'] = 'Bitte gib einen Namen fuer den Habit ein.';
|
||||||
|
} elseif (mb_strlen($title) > 120) {
|
||||||
|
$errors['title'] = 'Der Name darf maximal 120 Zeichen lang sein.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mb_strlen($description) > 1000) {
|
||||||
|
$errors['description'] = 'Die Beschreibung darf maximal 1000 Zeichen lang sein.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($categoryId !== '' && !Category::exists((int) $categoryId)) {
|
||||||
|
$errors['category_id'] = 'Bitte waehle eine gueltige Kategorie aus.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($targetPerWeek < 1 || $targetPerWeek > 7) {
|
||||||
|
$errors['target_per_week'] = 'Bitte waehle einen Wert zwischen 1 und 7.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => $title,
|
||||||
|
'description' => $description,
|
||||||
|
'category_id' => $categoryId,
|
||||||
|
'target_per_week' => $targetPerWeek,
|
||||||
|
'errors' => $errors,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class Database
|
||||||
|
{
|
||||||
|
private static ?PDO $connection = null;
|
||||||
|
|
||||||
|
public static function connection(): PDO
|
||||||
|
{
|
||||||
|
if (self::$connection !== null) {
|
||||||
|
return self::$connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = config('database');
|
||||||
|
$dsn = sprintf(
|
||||||
|
'mysql:host=%s;dbname=%s;charset=%s',
|
||||||
|
$db['host'],
|
||||||
|
$db['dbname'],
|
||||||
|
$db['charset']
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
self::$connection = new PDO($dsn, $db['user'], $db['password'], [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
]);
|
||||||
|
} catch (PDOException $exception) {
|
||||||
|
throw new PDOException('Datenbankverbindung fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$connection;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class View
|
||||||
|
{
|
||||||
|
public static function render(string $view, array $data = []): void
|
||||||
|
{
|
||||||
|
extract($data, EXTR_SKIP);
|
||||||
|
|
||||||
|
$viewPath = __DIR__ . '/../Views/' . $view . '.php';
|
||||||
|
|
||||||
|
require __DIR__ . '/../Views/layout/header.php';
|
||||||
|
require $viewPath;
|
||||||
|
require __DIR__ . '/../Views/layout/footer.php';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class Category
|
||||||
|
{
|
||||||
|
public static function all(): array
|
||||||
|
{
|
||||||
|
return Database::connection()
|
||||||
|
->query('SELECT id, name, color FROM categories ORDER BY name')
|
||||||
|
->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function exists(int $id): bool
|
||||||
|
{
|
||||||
|
$statement = Database::connection()->prepare('SELECT id FROM categories WHERE id = :id LIMIT 1');
|
||||||
|
$statement->execute(['id' => $id]);
|
||||||
|
|
||||||
|
return (bool) $statement->fetch();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class Habit
|
||||||
|
{
|
||||||
|
public static function allForUser(int $userId, array $filters = []): array
|
||||||
|
{
|
||||||
|
$where = ['h.user_id = :user_id'];
|
||||||
|
$params = ['user_id' => $userId];
|
||||||
|
|
||||||
|
if (!empty($filters['category_id'])) {
|
||||||
|
$where[] = 'h.category_id = :category_id';
|
||||||
|
$params['category_id'] = (int) $filters['category_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($filters['status'] ?? '') === 'done') {
|
||||||
|
$where[] = 'hl.id IS NOT NULL';
|
||||||
|
} elseif (($filters['status'] ?? '') === 'open') {
|
||||||
|
$where[] = 'hl.id IS NULL';
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = 'SELECT h.*, c.name AS category_name, c.color AS category_color,
|
||||||
|
CASE WHEN hl.id IS NULL THEN 0 ELSE 1 END AS done_today,
|
||||||
|
(SELECT COUNT(*)
|
||||||
|
FROM habit_logs hwl
|
||||||
|
WHERE hwl.habit_id = h.id
|
||||||
|
AND YEARWEEK(hwl.completed_on, 1) = YEARWEEK(CURRENT_DATE, 1)) AS completed_this_week
|
||||||
|
FROM habits h
|
||||||
|
LEFT JOIN categories c ON c.id = h.category_id
|
||||||
|
LEFT JOIN habit_logs hl
|
||||||
|
ON hl.habit_id = h.id
|
||||||
|
AND hl.completed_on = CURRENT_DATE
|
||||||
|
WHERE ' . implode(' AND ', $where) . '
|
||||||
|
ORDER BY h.created_at DESC';
|
||||||
|
|
||||||
|
$statement = Database::connection()->prepare($sql);
|
||||||
|
$statement->execute($params);
|
||||||
|
|
||||||
|
return $statement->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function findForUser(int $id, int $userId): ?array
|
||||||
|
{
|
||||||
|
$statement = Database::connection()->prepare(
|
||||||
|
'SELECT * FROM habits WHERE id = :id AND user_id = :user_id LIMIT 1'
|
||||||
|
);
|
||||||
|
$statement->execute(['id' => $id, 'user_id' => $userId]);
|
||||||
|
|
||||||
|
$habit = $statement->fetch();
|
||||||
|
return $habit ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function create(int $userId, array $data): bool
|
||||||
|
{
|
||||||
|
$sql = 'INSERT INTO habits (user_id, category_id, title, description, target_per_week)
|
||||||
|
VALUES (:user_id, :category_id, :title, :description, :target_per_week)';
|
||||||
|
|
||||||
|
return Database::connection()->prepare($sql)->execute([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'category_id' => $data['category_id'] ?: null,
|
||||||
|
'title' => $data['title'],
|
||||||
|
'description' => $data['description'],
|
||||||
|
'target_per_week' => (int) $data['target_per_week'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function update(int $id, int $userId, array $data): bool
|
||||||
|
{
|
||||||
|
$sql = 'UPDATE habits
|
||||||
|
SET category_id = :category_id,
|
||||||
|
title = :title,
|
||||||
|
description = :description,
|
||||||
|
target_per_week = :target_per_week
|
||||||
|
WHERE id = :id AND user_id = :user_id';
|
||||||
|
|
||||||
|
return Database::connection()->prepare($sql)->execute([
|
||||||
|
'id' => $id,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'category_id' => $data['category_id'] ?: null,
|
||||||
|
'title' => $data['title'],
|
||||||
|
'description' => $data['description'],
|
||||||
|
'target_per_week' => (int) $data['target_per_week'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function delete(int $id, int $userId): bool
|
||||||
|
{
|
||||||
|
$statement = Database::connection()->prepare('DELETE FROM habits WHERE id = :id AND user_id = :user_id');
|
||||||
|
|
||||||
|
return $statement->execute(['id' => $id, 'user_id' => $userId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function toggleToday(int $id, int $userId): bool
|
||||||
|
{
|
||||||
|
$habit = self::findForUser($id, $userId);
|
||||||
|
|
||||||
|
if (!$habit) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::connection();
|
||||||
|
$check = $db->prepare(
|
||||||
|
'SELECT id FROM habit_logs WHERE habit_id = :habit_id AND completed_on = CURRENT_DATE LIMIT 1'
|
||||||
|
);
|
||||||
|
$check->execute(['habit_id' => $id]);
|
||||||
|
$log = $check->fetch();
|
||||||
|
|
||||||
|
if ($log) {
|
||||||
|
return $db->prepare('DELETE FROM habit_logs WHERE id = :id')->execute(['id' => $log['id']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$insert = $db->prepare('INSERT INTO habit_logs (habit_id, completed_on) VALUES (:habit_id, CURRENT_DATE)');
|
||||||
|
return $insert->execute(['habit_id' => $id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class User
|
||||||
|
{
|
||||||
|
public static function create(string $name, string $email, string $password): bool
|
||||||
|
{
|
||||||
|
$sql = 'INSERT INTO users (name, email, password_hash) VALUES (:name, :email, :password_hash)';
|
||||||
|
$statement = Database::connection()->prepare($sql);
|
||||||
|
|
||||||
|
return $statement->execute([
|
||||||
|
'name' => $name,
|
||||||
|
'email' => $email,
|
||||||
|
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function findByEmail(string $email): ?array
|
||||||
|
{
|
||||||
|
$statement = Database::connection()->prepare('SELECT * FROM users WHERE email = :email LIMIT 1');
|
||||||
|
$statement->execute(['email' => $email]);
|
||||||
|
|
||||||
|
$user = $statement->fetch();
|
||||||
|
return $user ?: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<section class="auth-panel">
|
||||||
|
<h1>Anmelden</h1>
|
||||||
|
<form method="post" action="index.php?route=login" class="form">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<label for="email">E-Mail</label>
|
||||||
|
<input id="email" name="email" type="email" autocomplete="email" required>
|
||||||
|
|
||||||
|
<label for="password">Passwort</label>
|
||||||
|
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||||
|
|
||||||
|
<button type="submit">Einloggen</button>
|
||||||
|
</form>
|
||||||
|
<p>Noch kein Konto? <a href="index.php?route=register">Jetzt registrieren</a></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<section class="auth-panel">
|
||||||
|
<h1>Registrieren</h1>
|
||||||
|
<form method="post" action="index.php?route=register" class="form">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input id="name" name="name" type="text" autocomplete="name" required>
|
||||||
|
|
||||||
|
<label for="email">E-Mail</label>
|
||||||
|
<input id="email" name="email" type="email" autocomplete="email" required>
|
||||||
|
|
||||||
|
<label for="password">Passwort</label>
|
||||||
|
<input id="password" name="password" type="password" minlength="8" autocomplete="new-password" required>
|
||||||
|
<small>Mindestens 8 Zeichen.</small>
|
||||||
|
|
||||||
|
<button type="submit">Konto erstellen</button>
|
||||||
|
</form>
|
||||||
|
<p>Schon registriert? <a href="index.php?route=login">Zum Login</a></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<section class="dashboard-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Heute</p>
|
||||||
|
<h1>Deine Habits</h1>
|
||||||
|
</div>
|
||||||
|
<a class="button" href="index.php?route=habits/create">Habit erstellen</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form method="get" action="index.php" class="filters">
|
||||||
|
<input type="hidden" name="route" value="dashboard">
|
||||||
|
|
||||||
|
<label for="category_id">Kategorie</label>
|
||||||
|
<select id="category_id" name="category_id">
|
||||||
|
<option value="">Alle Kategorien</option>
|
||||||
|
<?php foreach ($categories as $category): ?>
|
||||||
|
<option value="<?= (int) $category['id'] ?>" <?= (string) $filters['category_id'] === (string) $category['id'] ? 'selected' : '' ?>>
|
||||||
|
<?= e($category['name']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="status">Status</label>
|
||||||
|
<select id="status" name="status">
|
||||||
|
<option value="">Alle</option>
|
||||||
|
<option value="open" <?= $filters['status'] === 'open' ? 'selected' : '' ?>>Offen</option>
|
||||||
|
<option value="done" <?= $filters['status'] === 'done' ? 'selected' : '' ?>>Erledigt</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button type="submit">Filtern</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php if (empty($habits)): ?>
|
||||||
|
<p class="empty">Noch keine Habits gefunden. Erstelle deinen ersten Habit.</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<section class="habit-list">
|
||||||
|
<?php foreach ($habits as $habit): ?>
|
||||||
|
<article class="habit-card <?= $habit['done_today'] ? 'done' : '' ?>">
|
||||||
|
<div class="habit-main">
|
||||||
|
<span class="category-dot" style="background-color: <?= e($habit['category_color'] ?? '#9ca3af') ?>"></span>
|
||||||
|
<div>
|
||||||
|
<h2><?= e($habit['title']) ?></h2>
|
||||||
|
<p><?= e($habit['description']) ?></p>
|
||||||
|
<small>
|
||||||
|
<?= e($habit['category_name'] ?? 'Ohne Kategorie') ?> |
|
||||||
|
Woche: <?= (int) $habit['completed_this_week'] ?> / <?= (int) $habit['target_per_week'] ?> erledigt
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="habit-actions">
|
||||||
|
<form method="post" action="index.php?route=habits/toggle">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="id" value="<?= (int) $habit['id'] ?>">
|
||||||
|
<button type="submit"><?= $habit['done_today'] ? 'Heute erledigt' : 'Abhaken' ?></button>
|
||||||
|
</form>
|
||||||
|
<a href="index.php?route=habits/edit&id=<?= (int) $habit['id'] ?>">Bearbeiten</a>
|
||||||
|
<form method="post" action="index.php?route=habits/delete" onsubmit="return confirm('Diesen Habit wirklich loeschen?');">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="id" value="<?= (int) $habit['id'] ?>">
|
||||||
|
<button class="danger" type="submit">Loeschen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php $isEdit = !empty($habit['id']); ?>
|
||||||
|
|
||||||
|
<section class="form-panel">
|
||||||
|
<h1><?= $isEdit ? 'Habit bearbeiten' : 'Habit erstellen' ?></h1>
|
||||||
|
|
||||||
|
<form method="post" action="index.php?route=<?= $isEdit ? 'habits/update' : 'habits/store' ?>" class="form">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<?php if ($isEdit): ?>
|
||||||
|
<input type="hidden" name="id" value="<?= (int) $habit['id'] ?>">
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<label for="title">Name des Habits</label>
|
||||||
|
<input
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
type="text"
|
||||||
|
maxlength="120"
|
||||||
|
class="<?= !empty($errors['title']) ? 'input-error' : '' ?>"
|
||||||
|
value="<?= e($habit['title'] ?? '') ?>"
|
||||||
|
required
|
||||||
|
aria-describedby="title-error"
|
||||||
|
>
|
||||||
|
<?php if (!empty($errors['title'])): ?>
|
||||||
|
<small id="title-error" class="field-error"><?= e($errors['title']) ?></small>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<label for="description">Beschreibung <span>optional</span></label>
|
||||||
|
<textarea id="description" name="description" rows="4" maxlength="1000"><?= e($habit['description'] ?? '') ?></textarea>
|
||||||
|
<?php if (!empty($errors['description'])): ?>
|
||||||
|
<small class="field-error"><?= e($errors['description']) ?></small>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<label for="category_id">Kategorie</label>
|
||||||
|
<select id="category_id" name="category_id" class="<?= !empty($errors['category_id']) ? 'input-error' : '' ?>">
|
||||||
|
<option value="">Keine Kategorie</option>
|
||||||
|
<?php foreach ($categories as $category): ?>
|
||||||
|
<option value="<?= (int) $category['id'] ?>" <?= (string) ($habit['category_id'] ?? '') === (string) $category['id'] ? 'selected' : '' ?>>
|
||||||
|
<?= e($category['name']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<?php if (!empty($errors['category_id'])): ?>
|
||||||
|
<small class="field-error"><?= e($errors['category_id']) ?></small>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<label for="target_per_week">Ziel pro Woche</label>
|
||||||
|
<input
|
||||||
|
id="target_per_week"
|
||||||
|
name="target_per_week"
|
||||||
|
type="number"
|
||||||
|
class="<?= !empty($errors['target_per_week']) ? 'input-error' : '' ?>"
|
||||||
|
min="1"
|
||||||
|
max="7"
|
||||||
|
value="<?= e((string) ($habit['target_per_week'] ?? '3')) ?>"
|
||||||
|
required
|
||||||
|
aria-describedby="target-error"
|
||||||
|
>
|
||||||
|
<?php if (!empty($errors['target_per_week'])): ?>
|
||||||
|
<small id="target-error" class="field-error"><?= e($errors['target_per_week']) ?></small>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<button type="submit"><?= $isEdit ? 'Speichern' : 'Erstellen' ?></button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title><?= e(config('app_name')) ?></title>
|
||||||
|
<link rel="stylesheet" href="assets/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<a class="brand" href="index.php?route=dashboard">Habit Tracker</a>
|
||||||
|
<nav class="nav">
|
||||||
|
<?php if (current_user_id()): ?>
|
||||||
|
<a href="index.php?route=dashboard">Dashboard</a>
|
||||||
|
<a href="index.php?route=habits/create">Neuer Habit</a>
|
||||||
|
<form method="post" action="index.php?route=logout" class="logout-form">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<button type="submit" class="link-button">Logout</button>
|
||||||
|
</form>
|
||||||
|
<?php else: ?>
|
||||||
|
<a href="index.php?route=login">Login</a>
|
||||||
|
<a href="index.php?route=register">Registrieren</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="page">
|
||||||
|
<?php if ($message = flash('success')): ?>
|
||||||
|
<p class="notice success"><?= e($message) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($message = flash('error')): ?>
|
||||||
|
<p class="notice error"><?= e($message) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
function config(string $key, $default = null)
|
||||||
|
{
|
||||||
|
static $config = null;
|
||||||
|
|
||||||
|
if ($config === null) {
|
||||||
|
$config = require __DIR__ . '/../config/config.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = explode('.', $key);
|
||||||
|
$value = $config;
|
||||||
|
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
if (!is_array($value) || !array_key_exists($part, $value)) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
$value = $value[$part];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function e(?string $value): string
|
||||||
|
{
|
||||||
|
return htmlspecialchars($value ?? '', ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirect(string $route): never
|
||||||
|
{
|
||||||
|
header('Location: index.php?route=' . urlencode($route));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function current_user_id(): ?int
|
||||||
|
{
|
||||||
|
return $_SESSION['user_id'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function require_login(): void
|
||||||
|
{
|
||||||
|
if (!current_user_id()) {
|
||||||
|
redirect('login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flash(string $key, ?string $message = null): ?string
|
||||||
|
{
|
||||||
|
if ($message !== null) {
|
||||||
|
$_SESSION['flash'][$key] = $message;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $_SESSION['flash'][$key] ?? null;
|
||||||
|
unset($_SESSION['flash'][$key]);
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function csrf_token(): string
|
||||||
|
{
|
||||||
|
if (empty($_SESSION['csrf_token'])) {
|
||||||
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $_SESSION['csrf_token'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function csrf_field(): string
|
||||||
|
{
|
||||||
|
return '<input type="hidden" name="csrf_token" value="' . e(csrf_token()) . '">';
|
||||||
|
}
|
||||||
|
|
||||||
|
function verify_csrf_token(): void
|
||||||
|
{
|
||||||
|
$token = $_POST['csrf_token'] ?? '';
|
||||||
|
|
||||||
|
if (!is_string($token) || !hash_equals($_SESSION['csrf_token'] ?? '', $token)) {
|
||||||
|
http_response_code(419);
|
||||||
|
echo 'Sicherheitspruefung fehlgeschlagen. Bitte lade die Seite neu.';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'app_name' => 'Habit Tracker',
|
||||||
|
'base_path' => '',
|
||||||
|
'database' => [
|
||||||
|
'host' => 'localhost',
|
||||||
|
'dbname' => 'habittracker',
|
||||||
|
'user' => 'root',
|
||||||
|
'password' => '',
|
||||||
|
'charset' => 'utf8mb4',
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
CREATE DATABASE IF NOT EXISTS pbbfa24aal_habittracker
|
||||||
|
CHARACTER SET utf8mb4
|
||||||
|
COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
USE pbbfa24aal_habittracker;
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
email VARCHAR(190) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE categories (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(80) NOT NULL,
|
||||||
|
color VARCHAR(20) NOT NULL DEFAULT '#176b5b'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE habits (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
category_id INT NULL,
|
||||||
|
title VARCHAR(120) NOT NULL,
|
||||||
|
description TEXT NULL,
|
||||||
|
target_per_week TINYINT UNSIGNED NOT NULL DEFAULT 3,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE habit_logs (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
habit_id INT NOT NULL,
|
||||||
|
completed_on DATE NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY unique_habit_day (habit_id, completed_on),
|
||||||
|
FOREIGN KEY (habit_id) REFERENCES habits(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE reminders (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
habit_id INT NOT NULL,
|
||||||
|
reminder_time TIME NOT NULL,
|
||||||
|
weekday TINYINT UNSIGNED NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
FOREIGN KEY (habit_id) REFERENCES habits(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO categories (name, color) VALUES
|
||||||
|
('Gesundheit', '#176b5b'),
|
||||||
|
('Lernen', '#365c9a'),
|
||||||
|
('Sport', '#b95c20'),
|
||||||
|
('Alltag', '#6b4ba3');
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="refresh" content="0; url=public/">
|
||||||
|
<title>Habit Tracker</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p><a href="public/">Habit Tracker starten</a></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f6f7f9;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--text: #172033;
|
||||||
|
--muted: #657086;
|
||||||
|
--line: #d9dee8;
|
||||||
|
--primary: #176b5b;
|
||||||
|
--primary-dark: #0f4c40;
|
||||||
|
--danger: #b42318;
|
||||||
|
--success-bg: #e8f5ee;
|
||||||
|
--error-bg: #fdecec;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--panel);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 64px;
|
||||||
|
padding: 0 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-button {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--primary);
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-button:hover {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 1080px;
|
||||||
|
padding: 32px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice,
|
||||||
|
.empty,
|
||||||
|
.auth-panel,
|
||||||
|
.form-panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice.success {
|
||||||
|
background: var(--success-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice.error {
|
||||||
|
background: var(--error-bg);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel,
|
||||||
|
.form-panel {
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-head {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2 {
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button,
|
||||||
|
button {
|
||||||
|
background: var(--primary);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-block;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover,
|
||||||
|
.button:hover {
|
||||||
|
background: var(--primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form,
|
||||||
|
.filters {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form label,
|
||||||
|
.filters label {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form label span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
font: inherit;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
color: var(--danger);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.input-error {
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
align-items: end;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
grid-template-columns: 1fr 1fr auto;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-card {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-card.done {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-main {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-main p {
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-dot {
|
||||||
|
border-radius: 999px;
|
||||||
|
flex: 0 0 14px;
|
||||||
|
height: 14px;
|
||||||
|
margin-top: 5px;
|
||||||
|
width: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-actions {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.topbar,
|
||||||
|
.dashboard-head,
|
||||||
|
.habit-card {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
session_set_cookie_params([
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
||||||
|
]);
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
require __DIR__ . '/../app/helpers.php';
|
||||||
|
require __DIR__ . '/../app/Core/Database.php';
|
||||||
|
require __DIR__ . '/../app/Core/View.php';
|
||||||
|
require __DIR__ . '/../app/Models/User.php';
|
||||||
|
require __DIR__ . '/../app/Models/Category.php';
|
||||||
|
require __DIR__ . '/../app/Models/Habit.php';
|
||||||
|
require __DIR__ . '/../app/Controllers/AuthController.php';
|
||||||
|
require __DIR__ . '/../app/Controllers/HabitController.php';
|
||||||
|
|
||||||
|
$route = $_GET['route'] ?? 'dashboard';
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
|
||||||
|
$routes = [
|
||||||
|
'GET' => [
|
||||||
|
'login' => [AuthController::class, 'showLogin'],
|
||||||
|
'register' => [AuthController::class, 'showRegister'],
|
||||||
|
'dashboard' => [HabitController::class, 'dashboard'],
|
||||||
|
'habits/create' => [HabitController::class, 'create'],
|
||||||
|
'habits/edit' => [HabitController::class, 'edit'],
|
||||||
|
],
|
||||||
|
'POST' => [
|
||||||
|
'login' => [AuthController::class, 'login'],
|
||||||
|
'register' => [AuthController::class, 'register'],
|
||||||
|
'logout' => [AuthController::class, 'logout'],
|
||||||
|
'habits/store' => [HabitController::class, 'store'],
|
||||||
|
'habits/update' => [HabitController::class, 'update'],
|
||||||
|
'habits/delete' => [HabitController::class, 'delete'],
|
||||||
|
'habits/toggle' => [HabitController::class, 'toggle'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!isset($routes[$method][$route])) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo 'Seite nicht gefunden.';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$controllerClass, $action] = $routes[$method][$route];
|
||||||
|
(new $controllerClass())->$action();
|
||||||
Reference in New Issue
Block a user