commit bd8a322fc0ebb45b7a577b7a39a0ecc815d2182b
Author: Jason
Date: Fri Jun 26 20:56:30 2026 +0200
Initial Release: LΓΌtte Planer mit Google-Login-Vorbereitung
- Login-System (Mock fΓΌr Tests, Google OAuth vorbereitet)
- Reisen erstellen mit Emoji, Ziel und Datum
- Ablauf (Timeline), Packliste, Ideen, Notizen, Dokumente
- Team-System: Personen einladen per E-Mail + Einladungslinks
- Berechtigungen: Nur lesen / Bearbeiten / Admin
- E-Mail-Benachrichtigungen beim Einladen
Co-Authored-By: Claude Sonnet 4.6
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3fa0f53
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+data/
+*.sqlite
+*.sqlite-wal
+*.sqlite-shm
diff --git a/.htaccess b/.htaccess
new file mode 100644
index 0000000..2c1c184
--- /dev/null
+++ b/.htaccess
@@ -0,0 +1,10 @@
+Options -Indexes
+DirectoryIndex index.php
+
+
+ Require all denied
+
+
+
+ Require all denied
+
diff --git a/ablauf.php b/ablauf.php
new file mode 100644
index 0000000..5220563
--- /dev/null
+++ b/ablauf.php
@@ -0,0 +1,132 @@
+ $tripId,
+ 'datum' => $_POST['datum'] ?? '',
+ 'zeit' => $_POST['zeit'] ?? '',
+ 'titel' => trim($_POST['titel'] ?? ''),
+ 'beschreibung'=> trim($_POST['beschreibung'] ?? ''),
+ 'ort' => trim($_POST['ort'] ?? ''),
+ 'kategorie' => $_POST['kategorie'] ?? 'allgemein',
+ 'created_by' => $user['id'],
+ ]);
+ header('Location: /Develop/PlanerGoogel/ablauf.php?trip=' . $tripId); exit;
+}
+
+// Eintrag lΓΆschen
+if ($canEdit && $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete'])) {
+ dbExec('DELETE FROM ablauf WHERE id = ? AND trip_id = ?', [(int)$_POST['delete'], $tripId]);
+ header('Location: /Develop/PlanerGoogel/ablauf.php?trip=' . $tripId); exit;
+}
+
+$eintraege = dbAll(
+ 'SELECT a.*, u.name as user_name, u.farbe as user_farbe FROM ablauf a
+ LEFT JOIN users u ON u.id = a.created_by
+ WHERE a.trip_id = ?
+ ORDER BY a.datum ASC, a.zeit ASC, a.sort_order ASC, a.id ASC',
+ [$tripId]
+);
+
+// Nach Datum gruppieren
+$grouped = [];
+foreach ($eintraege as $e) {
+ $key = $e['datum'] ?: '_ohne';
+ $grouped[$key][] = $e;
+}
+
+$kategorien = [
+ 'allgemein' => ['icon' => 'π', 'label' => 'Allgemein'],
+ 'transport' => ['icon' => 'π', 'label' => 'Transport'],
+ 'hotel' => ['icon' => 'π¨', 'label' => 'Hotel'],
+ 'essen' => ['icon' => 'π½οΈ', 'label' => 'Essen'],
+ 'aktivitΓ€t' => ['icon' => 'π―', 'label' => 'AktivitΓ€t'],
+ 'sehenswΓΌrdigkeit' => ['icon' => 'ποΈ', 'label' => 'SehenswΓΌrdigkeit'],
+ 'einkaufen' => ['icon' => 'ποΈ', 'label' => 'Einkaufen'],
+ 'sonstiges' => ['icon' => 'π', 'label' => 'Sonstiges'],
+];
+
+$pageTitle = 'Ablauf';
+$activeTab = 'ablauf';
+include __DIR__ . '/includes/header.php';
+
+$countdown = null;
+if ($trip['start_datum']) {
+ $days = daysUntil($trip['start_datum']);
+ if ($days > 0) $countdown = "Noch {$days} Tage bis zur Reise!";
+ elseif ($days === 0) $countdown = "π Heute geht's los!";
+}
+?>
+
+
+
+ = $countdown ?>
+
+
+
+
+
+
+
+
+
+
+
+ ποΈ
Noch keine EintrΓ€ge im Ablauf.
+
+
+
+ $items): ?>
+
+
+ = $datum === '_ohne' ? 'Ohne Datum' : germanDate($datum) ?>
+
+
+
+
+
+
+ = h($e['zeit']) ?>
+ = $kategorien[$e['kategorie']]['icon'] ?? 'π' ?>
+
+
= h($e['titel']) ?>
+
π = h($e['ort']) ?>
+
= h($e['beschreibung']) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/css/style.css b/assets/css/style.css
new file mode 100644
index 0000000..0ba7eb8
--- /dev/null
+++ b/assets/css/style.css
@@ -0,0 +1,157 @@
+*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
+:root{
+ --bg:#f0f6ff;--white:#fff;--blue:#2563eb;--blue-lt:#3b82f6;--blue-dk:#1d4ed8;
+ --sky:#e8f0fe;--sky2:#dbeafe;--ink:#1e293b;--muted:#64748b;--border:#bfdbfe;
+ --radius:10px;--shadow:0 2px 14px rgba(37,99,235,.09);--shadow-lg:0 6px 28px rgba(37,99,235,.14);
+}
+body{background:var(--bg);font-family:'Inter',sans-serif;color:var(--ink);min-height:100vh;overflow-x:hidden;}
+
+/* ββ Header βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+.site-header{background:var(--blue-dk);padding:10px 16px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:50;box-shadow:0 2px 12px rgba(29,78,216,.3);gap:10px;}
+.header-home{display:flex;align-items:center;gap:8px;text-decoration:none;min-width:0;}
+.header-title{font-family:'Playfair Display',serif;font-size:1.1rem;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
+.header-right{display:flex;align-items:center;gap:10px;flex-shrink:0;}
+.header-logout{color:#93c5fd;font-size:1.1rem;text-decoration:none;padding:4px;}
+.header-logout:hover{color:#fff;}
+
+/* ββ Nav ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+.nav-bar{display:flex;align-items:center;padding:0 4px;background:var(--white);border-bottom:2px solid var(--border);position:sticky;top:48px;z-index:40;box-shadow:0 2px 8px rgba(37,99,235,.06);overflow-x:auto;scrollbar-width:none;}
+.nav-bar::-webkit-scrollbar{display:none;}
+.nav-bar a{display:block;text-decoration:none;font-size:.68rem;font-weight:500;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);padding:12px 12px;border-bottom:2px solid transparent;margin-bottom:-2px;transition:color .18s,border-color .18s;white-space:nowrap;}
+.nav-bar a:hover{color:var(--blue);}
+.nav-bar a.active{color:var(--blue);border-bottom:2.5px solid var(--blue);font-weight:600;}
+
+/* ββ Main / Layout ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+.main{padding:22px 16px 40px;max-width:960px;margin:0 auto;width:100%;}
+.page-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:18px;flex-wrap:wrap;gap:10px;}
+.section-head{font-family:'Playfair Display',serif;font-size:1.6rem;font-weight:400;color:var(--blue-dk);}
+.col-title{font-family:'Playfair Display',serif;font-size:1.1rem;color:var(--blue-dk);margin-bottom:12px;padding-bottom:8px;border-bottom:2px solid var(--sky2);}
+
+/* ββ Buttons ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+.btn{padding:8px 20px;border:none;border-radius:8px;cursor:pointer;font-family:'Inter',sans-serif;font-size:.83rem;font-weight:500;transition:background .18s,transform .1s;white-space:nowrap;text-decoration:none;display:inline-block;}
+.btn-primary{background:var(--blue);color:#fff;}
+.btn-primary:hover{background:var(--blue-dk);}
+.btn-secondary{padding:7px 14px;background:none;border:1.5px solid var(--border);border-radius:8px;color:var(--muted);font-size:.82rem;font-family:'Inter',sans-serif;font-weight:500;cursor:pointer;text-decoration:none;display:inline-block;transition:border-color .15s,color .15s;}
+.btn-secondary:hover{border-color:var(--blue);color:var(--blue);}
+.btn-edit{background:none;border:1.5px solid var(--border);cursor:pointer;font-size:.8rem;color:var(--blue);padding:5px 10px;border-radius:7px;transition:background .14s;display:inline-flex;align-items:center;text-decoration:none;}
+.btn-edit:hover{background:var(--sky);}
+.btn-delete{background:none;border:1.5px solid #fca5a5;cursor:pointer;font-size:.8rem;color:#ef4444;padding:5px 10px;border-radius:7px;display:inline-flex;align-items:center;}
+.btn-delete:hover{background:#fef2f2;}
+
+/* ββ Add Form βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+.add-form{display:flex;gap:9px;flex-wrap:wrap;margin-bottom:22px;padding:16px 14px;background:var(--sky);border:1.5px solid var(--border);border-radius:var(--radius);}
+.add-form input,.add-form select,.add-form textarea{flex:1;min-width:0;padding:8px 12px;background:var(--white);border:1.5px solid var(--border);border-radius:7px;font-family:'Inter',sans-serif;font-size:.83rem;color:var(--ink);outline:none;transition:border-color .18s;}
+.add-form input:focus,.add-form select:focus,.add-form textarea:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(37,99,235,.1);}
+.hidden{display:none!important;}
+
+/* ββ Alerts βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+.alert-success{background:#f0fdf4;border:1.5px solid #86efac;border-radius:10px;padding:12px 16px;font-size:.84rem;color:#15803d;margin-bottom:20px;}
+.countdown-bar{background:linear-gradient(135deg,var(--blue-dk),var(--blue-lt));color:#fff;border-radius:10px;padding:12px 18px;margin-bottom:18px;font-size:.88rem;text-align:center;}
+
+/* ββ Stats ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+.stats-bar{display:flex;gap:8px;margin-bottom:18px;flex-wrap:wrap;align-items:center;}
+.stat-pill{background:var(--white);border:1.5px solid var(--border);border-radius:20px;padding:5px 13px;font-size:.76rem;color:var(--muted);}
+.stat-pill strong{color:var(--blue);}
+.empty{text-align:center;padding:40px 16px;color:var(--muted);font-size:.84rem;}
+.empty-icon{font-size:2rem;margin-bottom:8px;}
+
+/* ββ Trip Grid (Startseite) βββββββββββββββββββββββββββββββββββββββββββββββββββ */
+.trip-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;}
+.trip-card{background:var(--white);border:1.5px solid var(--border);border-radius:14px;padding:20px;text-decoration:none;color:var(--ink);display:flex;gap:14px;align-items:flex-start;box-shadow:var(--shadow);transition:transform .15s,box-shadow .15s,border-color .15s;position:relative;}
+.trip-card:hover{transform:translateY(-3px);box-shadow:var(--shadow-lg);border-color:var(--blue-lt);}
+.trip-card-emoji{font-size:2rem;flex-shrink:0;line-height:1;}
+.trip-card-body{flex:1;min-width:0;}
+.trip-card-name{font-family:'Playfair Display',serif;font-size:1.1rem;color:var(--blue-dk);margin-bottom:4px;}
+.trip-card-ziel{font-size:.78rem;color:var(--muted);margin-bottom:3px;}
+.trip-card-date{font-size:.75rem;color:var(--muted);margin-bottom:8px;}
+.trip-card-members{display:flex;gap:4px;flex-wrap:wrap;align-items:center;}
+.members-more{font-size:.72rem;color:var(--muted);padding:0 6px;}
+.countdown-badge{display:inline-block;background:var(--sky2);color:var(--blue-dk);border-radius:20px;padding:1px 8px;font-size:.68rem;font-weight:500;margin-left:6px;}
+.countdown-badge.active{background:#fef9c3;color:#854d0e;}
+.trip-card-badge{position:absolute;top:12px;right:12px;background:var(--sky2);color:var(--blue-dk);border-radius:20px;padding:2px 8px;font-size:.65rem;font-weight:600;}
+
+/* ββ Timeline (Ablauf) ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+.timeline{display:flex;flex-direction:column;gap:20px;}
+.tl-group-date{font-size:.72rem;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--blue);margin-bottom:10px;padding-left:32px;}
+.tl-item{position:relative;padding-left:32px;margin-bottom:12px;}
+.tl-dot{position:absolute;left:5px;top:14px;width:12px;height:12px;background:var(--blue);border-radius:50%;border:3px solid var(--bg);box-shadow:0 0 0 2px var(--blue-lt);}
+.tl-item-inner{background:var(--white);border:1.5px solid var(--border);border-radius:var(--radius);padding:14px 16px;box-shadow:var(--shadow);}
+.tl-item-inner:hover{border-color:var(--blue-lt);}
+.tl-meta{display:flex;align-items:center;gap:8px;margin-bottom:4px;}
+.tl-time{font-size:.72rem;font-weight:600;color:var(--blue);letter-spacing:.05em;}
+.tl-kat{font-size:.9rem;}
+.tl-title{font-family:'Playfair Display',serif;font-size:1.08rem;color:var(--ink);margin-bottom:3px;}
+.tl-ort{font-size:.76rem;color:var(--muted);margin-bottom:3px;}
+.tl-desc{font-size:.8rem;color:var(--muted);line-height:1.5;}
+
+/* ββ Packliste ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+.pack-progress{height:8px;background:var(--sky2);border-radius:10px;margin-bottom:12px;overflow:hidden;}
+.pack-progress-bar{height:100%;background:linear-gradient(90deg,var(--blue),var(--blue-lt));border-radius:10px;transition:width .4s;}
+.pack-group{margin-bottom:22px;}
+.pack-group-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;font-size:.8rem;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;}
+.pack-group-count{font-size:.75rem;color:var(--blue);}
+.todo-list{display:flex;flex-direction:column;gap:7px;}
+.todo-item{display:flex;align-items:center;gap:9px;background:var(--white);border:1.5px solid var(--border);border-radius:8px;padding:10px 12px;box-shadow:var(--shadow);}
+.todo-item.done{opacity:.5;}
+.todo-check-form{display:contents;}
+.todo-check{width:22px;height:22px;border-radius:50%;border:2px solid var(--blue);background:none;cursor:pointer;flex-shrink:0;display:flex;align-items:center;justify-content:center;transition:background .14s;padding:0;}
+.todo-check.checked{background:var(--blue);}
+.todo-check.checked::after{content:'β';color:#fff;font-size:.65rem;font-weight:700;}
+.todo-content{flex:1;min-width:0;}
+.todo-row1{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
+.todo-text{flex:1;min-width:0;font-size:.88rem;}
+.todo-item.done .todo-text{text-decoration:line-through;color:var(--muted);}
+.badge-menge{font-size:.68rem;background:var(--sky2);color:var(--blue-dk);border-radius:10px;padding:1px 7px;}
+.who-badge{font-size:.68rem;padding:2px 8px;border-radius:20px;font-weight:500;}
+
+/* ββ Ideen ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+.ideen-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:14px;}
+.idee-card{background:var(--white);border:1.5px solid var(--border);border-radius:var(--radius);padding:16px;box-shadow:var(--shadow);}
+.idee-header{display:flex;align-items:flex-start;gap:8px;margin-bottom:8px;}
+.idee-icon{font-size:1.2rem;flex-shrink:0;}
+.idee-title{flex:1;font-weight:500;font-size:.92rem;}
+.idee-desc{font-size:.8rem;color:var(--muted);margin-bottom:8px;line-height:1.5;}
+.idee-link{font-size:.76rem;color:var(--blue);text-decoration:none;display:block;margin-bottom:10px;word-break:break-all;}
+.idee-link:hover{text-decoration:underline;}
+.idee-actions{display:flex;gap:6px;flex-wrap:wrap;}
+.tl-badge{display:inline-block;padding:2px 8px;border-radius:20px;font-size:.68rem;font-weight:500;white-space:nowrap;}
+.badge-plan{background:#eff6ff;color:#2563eb;border:1px solid #bfdbfe;}
+.badge-active{background:#fefce8;color:#b45309;border:1px solid #fde68a;}
+.badge-done{background:#f0fdf4;color:#16a34a;border:1px solid #bbf7d0;}
+
+/* ββ Notizen ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+.notizen-list{display:flex;flex-direction:column;gap:16px;}
+.notiz-card{background:var(--white);border:1.5px solid var(--border);border-radius:var(--radius);padding:18px;box-shadow:var(--shadow);}
+.notiz-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;}
+.notiz-title{font-family:'Playfair Display',serif;font-size:1.05rem;color:var(--blue-dk);}
+.notiz-inhalt{font-size:.85rem;color:var(--ink);line-height:1.6;white-space:pre-wrap;}
+
+/* ββ Dokumente ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+.doc-list{display:flex;flex-direction:column;gap:10px;}
+.doc-item{background:var(--white);border:1.5px solid var(--border);border-radius:var(--radius);padding:14px 16px;display:flex;align-items:center;gap:12px;box-shadow:var(--shadow);}
+.doc-icon{font-size:1.5rem;flex-shrink:0;}
+.doc-info{flex:1;min-width:0;}
+.doc-name{font-weight:500;font-size:.88rem;margin-bottom:2px;}
+.doc-meta{font-size:.73rem;color:var(--muted);}
+.doc-actions{display:flex;gap:6px;flex-shrink:0;}
+
+/* ββ Team βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+.settings-section{background:var(--white);border:1.5px solid var(--border);border-radius:var(--radius);padding:20px;margin-bottom:20px;box-shadow:var(--shadow);}
+.member-list{display:flex;flex-direction:column;gap:10px;margin-bottom:20px;}
+.member-item{display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
+.member-info{flex:1;min-width:0;}
+.member-name{font-weight:500;font-size:.88rem;}
+.member-email{font-size:.73rem;color:var(--muted);}
+.access-badge{font-size:.68rem;padding:2px 9px;border-radius:20px;font-weight:600;}
+.access-admin{background:#fef3c7;color:#92400e;border:1px solid #fde68a;}
+.access-edit{background:#dbeafe;color:#1e40af;border:1px solid #bfdbfe;}
+.access-see{background:#f1f5f9;color:#475569;border:1px solid #cbd5e1;}
+.invite-form{background:var(--sky);border-radius:10px;padding:14px 16px;}
+
+@media(max-width:600px){
+ .trip-grid{grid-template-columns:1fr;}
+ .ideen-grid{grid-template-columns:1fr;}
+ .add-form{flex-direction:column;}
+ .doc-actions{flex-direction:column;}
+ .member-item{flex-wrap:wrap;}
+}
diff --git a/assets/js/app.js b/assets/js/app.js
new file mode 100644
index 0000000..218966f
--- /dev/null
+++ b/assets/js/app.js
@@ -0,0 +1,10 @@
+function toggleForm(id) {
+ const el = document.getElementById(id);
+ if (el) el.classList.toggle('hidden');
+}
+
+// Auto-close flash messages
+document.addEventListener('DOMContentLoaded', () => {
+ const alerts = document.querySelectorAll('.alert-success');
+ alerts.forEach(a => setTimeout(() => { a.style.opacity = '0'; a.style.transition = 'opacity .4s'; setTimeout(() => a.remove(), 400); }, 4000));
+});
diff --git a/config.php b/config.php
new file mode 100644
index 0000000..cfdb8df
--- /dev/null
+++ b/config.php
@@ -0,0 +1,236 @@
+= 2) return strtoupper(mb_substr($parts[0], 0, 1) . mb_substr($parts[1], 0, 1));
+ return strtoupper(mb_substr($name, 0, 2));
+}
+
+function avatarHtml(array $user, string $size = '32px'): string {
+ $initials = userInitials($user['name']);
+ $farbe = htmlspecialchars($user['farbe'] ?? '#2563eb');
+ $name = htmlspecialchars($user['name']);
+ if (!empty($user['avatar_url'])) {
+ return "
";
+ }
+ return "{$initials}";
+}
+
+// ββ XSS-Schutz ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+function h(string $s): string {
+ return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
+}
+
+// ββ Packliste-Kategorien ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+$PACKEN_KATEGORIEN = [
+ 'kleidung' => ['label' => 'Kleidung', 'icon' => 'π'],
+ 'dokumente' => ['label' => 'Dokumente', 'icon' => 'π'],
+ 'elektronik' => ['label' => 'Elektronik', 'icon' => 'π'],
+ 'medizin' => ['label' => 'Medizin', 'icon' => 'π'],
+ 'toilette' => ['label' => 'Toilette', 'icon' => 'π§΄'],
+ 'essen' => ['label' => 'Essen & Trinken', 'icon' => 'πΏ'],
+ 'strand' => ['label' => 'Strand', 'icon' => 'π'],
+ 'sport' => ['label' => 'Sport', 'icon' => 'β½'],
+ 'sonstiges' => ['label' => 'Sonstiges', 'icon' => 'π¦'],
+];
+
+$UPLOAD_TYPEN = [
+ 'application/pdf' => 'PDF',
+ 'image/jpeg' => 'JPG',
+ 'image/png' => 'PNG',
+ 'image/gif' => 'GIF',
+ 'image/webp' => 'WEBP',
+ 'application/msword' => 'Word',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'Word',
+];
+
+// ββ E-Mail-Versand ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+function sendMail(string $to, string $subject, string $htmlBody): bool {
+ $fromName = '=?UTF-8?B?' . base64_encode(MAIL_FROM_NAME) . '?=';
+ $subjectEncoded = '=?UTF-8?B?' . base64_encode($subject) . '?=';
+ $boundary = md5(uniqid());
+
+ $headers = "From: {$fromName} <" . MAIL_FROM . ">\r\n";
+ $headers .= "Reply-To: " . MAIL_FROM . "\r\n";
+ $headers .= "MIME-Version: 1.0\r\n";
+ $headers .= "Content-Type: multipart/alternative; boundary=\"{$boundary}\"\r\n";
+ $headers .= "X-Mailer: PHP/" . phpversion();
+
+ $plain = strip_tags(str_replace(['
', '
', '
', '
', ''], "\n", $htmlBody));
+ $plain = preg_replace('/\n{3,}/', "\n\n", trim($plain));
+
+ $body = "--{$boundary}\r\n";
+ $body .= "Content-Type: text/plain; charset=UTF-8\r\n";
+ $body .= "Content-Transfer-Encoding: base64\r\n\r\n";
+ $body .= chunk_split(base64_encode($plain)) . "\r\n";
+ $body .= "--{$boundary}\r\n";
+ $body .= "Content-Type: text/html; charset=UTF-8\r\n";
+ $body .= "Content-Transfer-Encoding: base64\r\n\r\n";
+ $body .= chunk_split(base64_encode($htmlBody)) . "\r\n";
+ $body .= "--{$boundary}--";
+
+ return mail($to, $subjectEncoded, $body, $headers);
+}
+
+function sendInviteMail(string $toEmail, string $tripName, string $inviteLink, string $inviterName): bool {
+ $subject = $inviterName . ' laedt dich zu "' . $tripName . '" ein';
+ $html = '
+
+
+
Hallo!
+
+ ' . htmlspecialchars($inviterName) . ' hat dich zur Reise
+ β' . htmlspecialchars($tripName) . '" eingeladen.
+
+
+
+ Oder kopiere diesen Link in deinen Browser:
+ ' . htmlspecialchars($inviteLink) . '
+
+
+
LΓΌtte Planer Β· Reisen planen & teilen
+
+ ';
+
+ return sendMail($toEmail, $subject, $html);
+}
+
+function sendAddedToTripMail(string $toEmail, string $toName, string $tripName, string $tripUrl, string $inviterName): bool {
+ $subject = 'Du wurdest zu "' . $tripName . '" hinzugefuegt';
+ $html = '
+
+
+
Hallo ' . htmlspecialchars($toName) . '!
+
+ ' . htmlspecialchars($inviterName) . ' hat dich zur Reise
+ β' . htmlspecialchars($tripName) . '" hinzugefΓΌgt.
+
+
+
+
LΓΌtte Planer Β· Reisen planen & teilen
+
+ ';
+
+ return sendMail($toEmail, $subject, $html);
+}
diff --git a/db.php b/db.php
new file mode 100644
index 0000000..7a0bdc1
--- /dev/null
+++ b/db.php
@@ -0,0 +1,48 @@
+setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+ $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
+ $pdo->exec('PRAGMA foreign_keys = ON');
+ $pdo->exec('PRAGMA journal_mode = WAL');
+ if ($isNew) {
+ $pdo->exec(file_get_contents(__DIR__ . '/db.sql'));
+ }
+ }
+ return $pdo;
+}
+
+function dbAll(string $sql, array $params = []): array {
+ $stmt = db()->prepare($sql);
+ $stmt->execute($params);
+ return $stmt->fetchAll();
+}
+
+function dbOne(string $sql, array $params = []): ?array {
+ $stmt = db()->prepare($sql);
+ $stmt->execute($params);
+ $row = $stmt->fetch();
+ return $row === false ? null : $row;
+}
+
+function dbExec(string $sql, array $params = []): void {
+ $stmt = db()->prepare($sql);
+ $stmt->execute($params);
+}
+
+function dbInsert(string $table, array $data): int {
+ $cols = array_keys($data);
+ $sql = 'INSERT INTO ' . $table . ' (' . implode(',', $cols) . ') VALUES (' .
+ implode(',', array_map(fn($c) => ':' . $c, $cols)) . ')';
+ dbExec($sql, $data);
+ return (int) db()->lastInsertId();
+}
+
+function dbLastId(): int {
+ return (int) db()->lastInsertId();
+}
diff --git a/db.sql b/db.sql
new file mode 100644
index 0000000..bd1a3b2
--- /dev/null
+++ b/db.sql
@@ -0,0 +1,103 @@
+PRAGMA foreign_keys = ON;
+PRAGMA journal_mode = WAL;
+
+CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ google_id TEXT UNIQUE,
+ email TEXT NOT NULL UNIQUE,
+ name TEXT NOT NULL,
+ avatar_url TEXT,
+ farbe TEXT DEFAULT '#2563eb',
+ created_at INTEGER DEFAULT (strftime('%s','now'))
+);
+
+CREATE TABLE IF NOT EXISTS reisen (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ ziel TEXT,
+ start_datum TEXT,
+ end_datum TEXT,
+ cover_emoji TEXT DEFAULT 'βοΈ',
+ beschreibung TEXT,
+ created_by INTEGER REFERENCES users(id),
+ created_at INTEGER DEFAULT (strftime('%s','now'))
+);
+
+CREATE TABLE IF NOT EXISTS reise_zugriff (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ trip_id TEXT NOT NULL REFERENCES reisen(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ access TEXT NOT NULL DEFAULT 'see',
+ invited_by INTEGER REFERENCES users(id),
+ joined_at INTEGER DEFAULT (strftime('%s','now')),
+ UNIQUE(trip_id, user_id)
+);
+
+CREATE TABLE IF NOT EXISTS reise_einladungen (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ trip_id TEXT NOT NULL REFERENCES reisen(id) ON DELETE CASCADE,
+ email TEXT NOT NULL,
+ token TEXT NOT NULL UNIQUE,
+ access TEXT DEFAULT 'see',
+ invited_by INTEGER REFERENCES users(id),
+ created_at INTEGER DEFAULT (strftime('%s','now')),
+ used_at INTEGER
+);
+
+CREATE TABLE IF NOT EXISTS ablauf (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ trip_id TEXT NOT NULL REFERENCES reisen(id) ON DELETE CASCADE,
+ datum TEXT,
+ zeit TEXT,
+ titel TEXT NOT NULL,
+ beschreibung TEXT,
+ ort TEXT,
+ kategorie TEXT DEFAULT 'allgemein',
+ sort_order INTEGER DEFAULT 0,
+ created_by INTEGER REFERENCES users(id),
+ created_at INTEGER DEFAULT (strftime('%s','now'))
+);
+
+CREATE TABLE IF NOT EXISTS packliste (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ trip_id TEXT NOT NULL REFERENCES reisen(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ menge TEXT,
+ kat TEXT DEFAULT 'sonstiges',
+ packed INTEGER DEFAULT 0,
+ assigned_to INTEGER REFERENCES users(id),
+ created_by INTEGER REFERENCES users(id),
+ created_at INTEGER DEFAULT (strftime('%s','now'))
+);
+
+CREATE TABLE IF NOT EXISTS ideen (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ trip_id TEXT NOT NULL REFERENCES reisen(id) ON DELETE CASCADE,
+ titel TEXT NOT NULL,
+ beschreibung TEXT,
+ link TEXT,
+ status TEXT DEFAULT 'offen',
+ created_by INTEGER REFERENCES users(id),
+ created_at INTEGER DEFAULT (strftime('%s','now'))
+);
+
+CREATE TABLE IF NOT EXISTS notizen (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ trip_id TEXT NOT NULL REFERENCES reisen(id) ON DELETE CASCADE,
+ titel TEXT NOT NULL,
+ inhalt TEXT,
+ created_by INTEGER REFERENCES users(id),
+ updated_at INTEGER DEFAULT (strftime('%s','now')),
+ created_at INTEGER DEFAULT (strftime('%s','now'))
+);
+
+CREATE TABLE IF NOT EXISTS dokumente (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ trip_id TEXT NOT NULL REFERENCES reisen(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ dateiname TEXT NOT NULL,
+ mime_type TEXT,
+ groesse INTEGER,
+ created_by INTEGER REFERENCES users(id),
+ created_at INTEGER DEFAULT (strftime('%s','now'))
+);
diff --git a/dokumente.php b/dokumente.php
new file mode 100644
index 0000000..9c1e633
--- /dev/null
+++ b/dokumente.php
@@ -0,0 +1,108 @@
+ $tripId, 'name' => $name, 'dateiname' => $filename, 'mime_type' => $mime, 'groesse' => $file['size'], 'created_by' => $user['id']]);
+ }
+ }
+ }
+ header('Location: /Develop/PlanerGoogel/dokumente.php?trip=' . $tripId); exit;
+}
+
+// LΓΆschen
+if ($canEdit && $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete'])) {
+ $doc = dbOne('SELECT * FROM dokumente WHERE id = ? AND trip_id = ?', [(int)$_POST['delete'], $tripId]);
+ if ($doc) {
+ @unlink($uploadDir . $doc['dateiname']);
+ dbExec('DELETE FROM dokumente WHERE id = ?', [$doc['id']]);
+ }
+ header('Location: /Develop/PlanerGoogel/dokumente.php?trip=' . $tripId); exit;
+}
+
+// Download
+if (isset($_GET['dl'])) {
+ $doc = dbOne('SELECT * FROM dokumente WHERE id = ? AND trip_id = ?', [(int)$_GET['dl'], $tripId]);
+ if ($doc && file_exists($uploadDir . $doc['dateiname'])) {
+ header('Content-Type: ' . $doc['mime_type']);
+ header('Content-Disposition: attachment; filename="' . addslashes($doc['name']) . '"');
+ readfile($uploadDir . $doc['dateiname']); exit;
+ }
+ http_response_code(404); exit;
+}
+
+$docs = dbAll('SELECT d.*, u.name as user_name FROM dokumente d LEFT JOIN users u ON u.id = d.created_by WHERE d.trip_id = ? ORDER BY d.created_at DESC', [$tripId]);
+
+function formatSize(int $bytes): string {
+ if ($bytes < 1024) return $bytes . ' B';
+ if ($bytes < 1048576) return round($bytes / 1024, 1) . ' KB';
+ return round($bytes / 1048576, 1) . ' MB';
+}
+
+$pageTitle = 'Dokumente';
+$activeTab = 'dokumente';
+include __DIR__ . '/includes/header.php';
+?>
+
+
+
+
+
+
+
+
+
+ π
Noch keine Dokumente hochgeladen.
+
+
+
+
+
= $icon ?>
+
+
= h($doc['name']) ?>
+
= formatSize((int)$doc['groesse']) ?> Β· = date('d.m.Y', $doc['created_at']) ?>= $doc['user_name'] ? ' Β· ' . h($doc['user_name']) : '' ?>
+
+
+
+
+
+
+
+
+
diff --git a/icons/icon-192.png b/icons/icon-192.png
new file mode 100644
index 0000000..7d3a73e
Binary files /dev/null and b/icons/icon-192.png differ
diff --git a/icons/icon-512.png b/icons/icon-512.png
new file mode 100644
index 0000000..d58448b
Binary files /dev/null and b/icons/icon-512.png differ
diff --git a/ideen.php b/ideen.php
new file mode 100644
index 0000000..30e15b1
--- /dev/null
+++ b/ideen.php
@@ -0,0 +1,107 @@
+ $tripId,
+ 'titel' => trim($_POST['titel'] ?? ''),
+ 'beschreibung'=> trim($_POST['beschreibung'] ?? ''),
+ 'link' => trim($_POST['link'] ?? ''),
+ 'status' => 'offen',
+ 'created_by' => $user['id'],
+ ]);
+ header('Location: /Develop/PlanerGoogel/ideen.php?trip=' . $tripId); exit;
+}
+
+if ($canEdit && $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['status'])) {
+ $statuses = ['offen', 'geplant', 'erledigt'];
+ $newStatus = $_POST['status'];
+ if (in_array($newStatus, $statuses)) {
+ dbExec('UPDATE ideen SET status = ? WHERE id = ? AND trip_id = ?', [$newStatus, (int)$_POST['id'], $tripId]);
+ }
+ header('Location: /Develop/PlanerGoogel/ideen.php?trip=' . $tripId); exit;
+}
+
+if ($canEdit && $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete'])) {
+ dbExec('DELETE FROM ideen WHERE id = ? AND trip_id = ?', [(int)$_POST['delete'], $tripId]);
+ header('Location: /Develop/PlanerGoogel/ideen.php?trip=' . $tripId); exit;
+}
+
+$ideen = dbAll(
+ 'SELECT i.*, u.name as user_name FROM ideen i
+ LEFT JOIN users u ON u.id = i.created_by
+ WHERE i.trip_id = ?
+ ORDER BY CASE i.status WHEN "geplant" THEN 0 WHEN "offen" THEN 1 ELSE 2 END, i.id DESC',
+ [$tripId]
+);
+
+$statusMap = [
+ 'offen' => ['label' => 'Offen', 'class' => 'badge-plan', 'icon' => 'π‘'],
+ 'geplant' => ['label' => 'Geplant', 'class' => 'badge-active', 'icon' => 'π
'],
+ 'erledigt' => ['label' => 'Erledigt', 'class' => 'badge-done', 'icon' => 'β
'],
+];
+
+$pageTitle = 'Ideen';
+$activeTab = 'ideen';
+include __DIR__ . '/includes/header.php';
+?>
+
+
+
+
+
+
+
+
+
+
+
+ π‘
Noch keine Ideen gesammelt.
+
+
+
+
+
+
diff --git a/includes/footer.php b/includes/footer.php
new file mode 100644
index 0000000..2c72665
--- /dev/null
+++ b/includes/footer.php
@@ -0,0 +1,3 @@
+
+