Initial habit tracker project
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
config/config.php
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,75 @@
|
||||
# Habit Tracker
|
||||
|
||||
Habit Tracker ist eine PHP-Webanwendung, mit der Benutzer eigene Gewohnheiten anlegen, bearbeiten, filtern und taeglich abhaken koennen.
|
||||
|
||||
## Funktionen
|
||||
|
||||
- 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`
|
||||
- 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
|
||||
|
||||
## Technik
|
||||
|
||||
- PHP
|
||||
- HTML/CSS
|
||||
- SQL
|
||||
- MySQL/PDO
|
||||
- MVC-Struktur
|
||||
|
||||
## MVC-Struktur
|
||||
|
||||
- `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. Habit bearbeiten und loeschen.
|
||||
7. 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,137 @@
|
||||
<?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.';
|
||||
}
|
||||
|
||||
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,35 @@
|
||||
<?php
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
|
||||
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,11 @@
|
||||
<?php
|
||||
|
||||
class Category
|
||||
{
|
||||
public static function all(): array
|
||||
{
|
||||
return Database::connection()
|
||||
->query('SELECT id, name, color FROM categories ORDER BY name')
|
||||
->fetchAll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?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
|
||||
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,67 @@
|
||||
<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') ?> |
|
||||
Ziel: <?= (int) $habit['target_per_week'] ?>x pro Woche
|
||||
</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,58 @@
|
||||
<?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"
|
||||
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"><?= e($habit['description'] ?? '') ?></textarea>
|
||||
|
||||
<label for="category_id">Kategorie</label>
|
||||
<select id="category_id" name="category_id">
|
||||
<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>
|
||||
|
||||
<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');
|
||||
@@ -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