Sfoglia il codice sorgente

Creating v1 of the packing list generator
* Schema for database created
* index, list and packing.php for enduser
* admin interface to change activities, activity groups, items,
item groups as well as mappings

sebastian 1 settimana fa
parent
commit
77e2e5eed1
17 ha cambiato i file con 1978 aggiunte e 56 eliminazioni
  1. 15 0
      admin/.htaccess
  2. 130 0
      admin/activities.php
  3. 117 0
      admin/activity_groups.php
  4. 28 0
      admin/auth.php
  5. 44 0
      admin/index.php
  6. 100 0
      admin/item_groups.php
  7. 115 0
      admin/items.php
  8. 116 0
      admin/layout.php
  9. 81 0
      admin/login.php
  10. 3 0
      admin/logout.php
  11. 161 0
      admin/mappings.php
  12. 13 0
      config.php
  13. 26 0
      db.php
  14. 306 56
      index.php
  15. 53 0
      list.php
  16. 422 0
      packing.php
  17. 248 0
      schema.sql

+ 15 - 0
admin/.htaccess

@@ -0,0 +1,15 @@
+# Admin area – only PHP files are served; all others are blocked.
+# Authentication is handled by admin/auth.php (PHP session), NOT HTTP Basic Auth,
+# so no AuthType directive is needed here.
+
+Options -Indexes
+
+# Deny access to everything by default, then selectively allow PHP files
+<FilesMatch "\.php$">
+    Require all granted
+</FilesMatch>
+
+# Deny everything that is NOT a .php file
+<FilesMatch "^(?!.*\.php$)">
+    Require all denied
+</FilesMatch>

+ 130 - 0
admin/activities.php

@@ -0,0 +1,130 @@
+<?php
+require_once __DIR__ . '/auth.php';
+require_once __DIR__ . '/layout.php';
+require_admin();
+
+$pdo = get_pdo();
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    $action = $_POST['action'] ?? '';
+
+    if ($action === 'add') {
+        $name  = trim($_POST['activityName'] ?? '');
+        $gid   = (int)$_POST['groupID'];
+        $order = (int)($_POST['sortOrder'] ?? 0);
+        if ($name !== '' && $gid > 0) {
+            $pdo->prepare("INSERT INTO activities (groupID,activityName,sortOrder) VALUES (?,?,?)")
+                ->execute([$gid, $name, $order]);
+            flash('success', "Activity '$name' added.");
+        } else {
+            flash('error', 'Please fill all fields.');
+        }
+    }
+
+    if ($action === 'edit') {
+        $id    = (int)$_POST['activityID'];
+        $name  = trim($_POST['activityName'] ?? '');
+        $gid   = (int)$_POST['groupID'];
+        $order = (int)($_POST['sortOrder'] ?? 0);
+        if ($name !== '') {
+            $pdo->prepare("UPDATE activities SET activityName=?,groupID=?,sortOrder=? WHERE activityID=?")
+                ->execute([$name, $gid, $order, $id]);
+            flash('success', 'Activity updated.');
+        } else {
+            flash('error', 'Name cannot be empty.');
+        }
+    }
+
+    if ($action === 'delete') {
+        $id = (int)$_POST['activityID'];
+        $pdo->prepare("DELETE FROM activities WHERE activityID=?")->execute([$id]);
+        flash('success', 'Activity deleted.');
+    }
+
+    header('Location: activities.php');
+    exit;
+}
+
+$groups     = $pdo->query("SELECT * FROM activity_groups ORDER BY sortOrder,groupID")->fetchAll();
+$activities = $pdo->query("
+    SELECT a.*, ag.groupName
+    FROM activities a
+    JOIN activity_groups ag ON ag.groupID = a.groupID
+    ORDER BY ag.sortOrder, a.sortOrder, a.activityID
+")->fetchAll();
+
+$editing = null;
+if (isset($_GET['edit'])) {
+    $stmt = $pdo->prepare("SELECT * FROM activities WHERE activityID=?");
+    $stmt->execute([(int)$_GET['edit']]);
+    $editing = $stmt->fetch();
+}
+
+admin_head('Activities', 'activities.php');
+show_alerts();
+?>
+<h1>Activities</h1>
+
+<div class="card">
+  <h2><?= $editing ? 'Edit Activity' : 'Add New Activity' ?></h2>
+  <form method="POST">
+    <input type="hidden" name="action" value="<?= $editing ? 'edit' : 'add' ?>">
+    <?php if ($editing): ?>
+    <input type="hidden" name="activityID" value="<?= $editing['activityID'] ?>">
+    <?php endif; ?>
+    <div class="form-row">
+      <div>
+        <label>Activity Name</label>
+        <input type="text" name="activityName"
+               value="<?= htmlspecialchars($editing['activityName'] ?? '') ?>"
+               placeholder="e.g. Kayaking" required>
+      </div>
+      <div>
+        <label>Group</label>
+        <select name="groupID" required>
+          <option value="">– select –</option>
+          <?php foreach ($groups as $g): ?>
+          <option value="<?= $g['groupID'] ?>"
+            <?= ($editing && $editing['groupID'] == $g['groupID']) ? 'selected' : '' ?>>
+            <?= htmlspecialchars($g['groupName']) ?>
+          </option>
+          <?php endforeach; ?>
+        </select>
+      </div>
+    </div>
+    <div>
+      <label>Sort Order</label>
+      <input type="number" name="sortOrder"
+             value="<?= (int)($editing['sortOrder'] ?? 0) ?>" min="0" style="max-width:120px;">
+    </div>
+    <div style="display:flex;gap:.5rem;margin-top:.25rem;">
+      <button type="submit" class="btn btn-primary"><?= $editing ? '💾 Save' : '➕ Add' ?></button>
+      <?php if ($editing): ?>
+      <a href="activities.php" class="btn btn-secondary">Cancel</a>
+      <?php endif; ?>
+    </div>
+  </form>
+</div>
+
+<table class="tbl">
+  <thead><tr><th>#</th><th>Name</th><th>Group</th><th>Sort</th><th></th></tr></thead>
+  <tbody>
+  <?php foreach ($activities as $a): ?>
+  <tr>
+    <td style="color:var(--muted)"><?= $a['activityID'] ?></td>
+    <td><?= htmlspecialchars($a['activityName']) ?></td>
+    <td><span class="badge badge-group"><?= htmlspecialchars($a['groupName']) ?></span></td>
+    <td><?= $a['sortOrder'] ?></td>
+    <td style="display:flex;gap:.4rem;justify-content:flex-end;">
+      <a href="?edit=<?= $a['activityID'] ?>" class="btn btn-sm btn-teal">Edit</a>
+      <form method="POST" onsubmit="return confirm('Delete this activity?')">
+        <input type="hidden" name="action" value="delete">
+        <input type="hidden" name="activityID" value="<?= $a['activityID'] ?>">
+        <button class="btn btn-sm btn-danger">Delete</button>
+      </form>
+    </td>
+  </tr>
+  <?php endforeach; ?>
+  </tbody>
+</table>
+<?php admin_foot(); ?>

+ 117 - 0
admin/activity_groups.php

@@ -0,0 +1,117 @@
+<?php
+require_once __DIR__ . '/auth.php';
+require_once __DIR__ . '/layout.php';
+require_admin();
+
+$pdo = get_pdo();
+
+// Handle POST actions
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    $action = $_POST['action'] ?? '';
+
+    if ($action === 'add') {
+        $name  = trim($_POST['groupName'] ?? '');
+        $order = (int)($_POST['sortOrder'] ?? 0);
+        if ($name !== '') {
+            $pdo->prepare("INSERT INTO activity_groups (groupName, sortOrder) VALUES (?,?)")
+                ->execute([$name, $order]);
+            flash('success', "Group '$name' added.");
+        } else {
+            flash('error', 'Group name cannot be empty.');
+        }
+    }
+
+    if ($action === 'edit') {
+        $id    = (int)$_POST['groupID'];
+        $name  = trim($_POST['groupName'] ?? '');
+        $order = (int)($_POST['sortOrder'] ?? 0);
+        if ($name !== '') {
+            $pdo->prepare("UPDATE activity_groups SET groupName=?, sortOrder=? WHERE groupID=?")
+                ->execute([$name, $order, $id]);
+            flash('success', 'Group updated.');
+        } else {
+            flash('error', 'Group name cannot be empty.');
+        }
+    }
+
+    if ($action === 'delete') {
+        $id = (int)$_POST['groupID'];
+        $pdo->prepare("DELETE FROM activity_groups WHERE groupID=?")->execute([$id]);
+        flash('success', 'Group deleted.');
+    }
+
+    header('Location: activity_groups.php');
+    exit;
+}
+
+$groups = $pdo->query("SELECT * FROM activity_groups ORDER BY sortOrder, groupID")->fetchAll();
+
+// Edit mode?
+$editing = null;
+if (isset($_GET['edit'])) {
+    $stmt = $pdo->prepare("SELECT * FROM activity_groups WHERE groupID=?");
+    $stmt->execute([(int)$_GET['edit']]);
+    $editing = $stmt->fetch();
+}
+
+admin_head('Activity Groups', 'activity_groups.php');
+show_alerts();
+?>
+<h1>Activity Groups</h1>
+
+<div class="card">
+  <h2><?= $editing ? 'Edit Group' : 'Add New Group' ?></h2>
+  <form method="POST">
+    <input type="hidden" name="action" value="<?= $editing ? 'edit' : 'add' ?>">
+    <?php if ($editing): ?>
+    <input type="hidden" name="groupID" value="<?= $editing['groupID'] ?>">
+    <?php endif; ?>
+    <div class="form-row">
+      <div>
+        <label>Group Name</label>
+        <input type="text" name="groupName"
+               value="<?= htmlspecialchars($editing['groupName'] ?? '') ?>"
+               placeholder="e.g. Overnight" required>
+      </div>
+      <div>
+        <label>Sort Order</label>
+        <input type="number" name="sortOrder"
+               value="<?= (int)($editing['sortOrder'] ?? 0) ?>" min="0">
+      </div>
+    </div>
+    <div style="display:flex;gap:.5rem;">
+      <button type="submit" class="btn btn-primary"><?= $editing ? '💾 Save' : '➕ Add Group' ?></button>
+      <?php if ($editing): ?>
+      <a href="activity_groups.php" class="btn btn-secondary">Cancel</a>
+      <?php endif; ?>
+    </div>
+  </form>
+</div>
+
+<table class="tbl">
+  <thead><tr><th>#</th><th>Name</th><th>Sort</th><th>Activities</th><th></th></tr></thead>
+  <tbody>
+  <?php foreach ($groups as $g):
+    $cnt = $pdo->prepare("SELECT COUNT(*) FROM activities WHERE groupID=?");
+    $cnt->execute([$g['groupID']]);
+    $actCount = $cnt->fetchColumn();
+  ?>
+  <tr>
+    <td style="color:var(--muted)"><?= $g['groupID'] ?></td>
+    <td><?= htmlspecialchars($g['groupName']) ?></td>
+    <td><?= $g['sortOrder'] ?></td>
+    <td><span class="badge badge-group"><?= $actCount ?></span></td>
+    <td style="text-align:right;display:flex;gap:.4rem;justify-content:flex-end;">
+      <a href="?edit=<?= $g['groupID'] ?>" class="btn btn-sm btn-teal">Edit</a>
+      <form method="POST" onsubmit="return confirm('Delete this group and ALL its activities?')">
+        <input type="hidden" name="action" value="delete">
+        <input type="hidden" name="groupID" value="<?= $g['groupID'] ?>">
+        <button type="submit" class="btn btn-sm btn-danger">Delete</button>
+      </form>
+    </td>
+  </tr>
+  <?php endforeach; ?>
+  </tbody>
+</table>
+
+<?php admin_foot(); ?>

+ 28 - 0
admin/auth.php

@@ -0,0 +1,28 @@
+<?php
+// auth.php – shared admin authentication helper
+// Include at the top of every admin page.
+
+require_once __DIR__ . '/../db.php';
+
+session_name('packit_admin');
+session_start();
+
+define('ADMIN_LOGGED_IN', isset($_SESSION['admin_logged_in']) && $_SESSION['admin_logged_in'] === true);
+
+function require_admin(): void {
+    if (!ADMIN_LOGGED_IN) {
+        header('Location: ' . admin_url('login.php'));
+        exit;
+    }
+}
+
+function admin_url(string $page): string {
+    return $page;
+}
+
+function admin_logout(): void {
+    $_SESSION = [];
+    session_destroy();
+    header('Location: login.php');
+    exit;
+}

+ 44 - 0
admin/index.php

@@ -0,0 +1,44 @@
+<?php
+require_once __DIR__ . '/auth.php';
+require_once __DIR__ . '/layout.php';
+require_admin();
+
+$pdo = get_pdo();
+
+$counts = [];
+foreach (['activity_groups','activities','item_groups','items','activity_item_map','selection_hashes'] as $t) {
+    $counts[$t] = $pdo->query("SELECT COUNT(*) FROM `$t`")->fetchColumn();
+}
+
+admin_head('Dashboard', 'index.php');
+?>
+<h1>Dashboard</h1>
+
+<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:1rem;margin-bottom:2rem;">
+<?php
+$tiles = [
+    ['Activity Groups', $counts['activity_groups'], 'activity_groups.php', '#4fd1c5'],
+    ['Activities',      $counts['activities'],      'activities.php',      '#e8c547'],
+    ['Item Groups',     $counts['item_groups'],     'item_groups.php',     '#4fd1c5'],
+    ['Items',           $counts['items'],           'items.php',           '#e8c547'],
+    ['Mappings',        $counts['activity_item_map'],'mappings.php',       '#a78bfa'],
+    ['Saved Lists',     $counts['selection_hashes'],'#',                   '#7a8099'],
+];
+foreach ($tiles as [$label, $count, $href, $color]):
+?>
+<a href="<?= $href ?>" style="background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:1.25rem 1.5rem;text-decoration:none;display:block;transition:border-color .15s;" onmouseover="this.style.borderColor='<?= $color ?>'" onmouseout="this.style.borderColor='var(--border)'">
+  <div style="font-size:2rem;font-family:'Bebas Neue',sans-serif;color:<?= $color ?>;line-height:1;"><?= $count ?></div>
+  <div style="font-size:.8rem;color:var(--muted);margin-top:.25rem;text-transform:uppercase;letter-spacing:.06em;"><?= $label ?></div>
+</a>
+<?php endforeach; ?>
+</div>
+
+<div class="card" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:.75rem;">
+  <a href="activities.php"      class="btn btn-secondary">✏️ Activities</a>
+  <a href="activity_groups.php" class="btn btn-secondary">📂 Act. Groups</a>
+  <a href="items.php"           class="btn btn-secondary">🎒 Items</a>
+  <a href="item_groups.php"     class="btn btn-secondary">📂 Item Groups</a>
+  <a href="mappings.php"        class="btn btn-secondary">🔗 Mappings</a>
+</div>
+
+<?php admin_foot(); ?>

+ 100 - 0
admin/item_groups.php

@@ -0,0 +1,100 @@
+<?php
+require_once __DIR__ . '/auth.php';
+require_once __DIR__ . '/layout.php';
+require_admin();
+
+$pdo = get_pdo();
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    $action = $_POST['action'] ?? '';
+    if ($action === 'add') {
+        $name  = trim($_POST['groupName'] ?? '');
+        $order = (int)($_POST['sortOrder'] ?? 0);
+        if ($name !== '') {
+            $pdo->prepare("INSERT INTO item_groups (groupName, sortOrder) VALUES (?,?)")->execute([$name, $order]);
+            flash('success', "Group '$name' added.");
+        } else { flash('error', 'Name required.'); }
+    }
+    if ($action === 'edit') {
+        $id = (int)$_POST['groupID'];
+        $name  = trim($_POST['groupName'] ?? '');
+        $order = (int)($_POST['sortOrder'] ?? 0);
+        if ($name !== '') {
+            $pdo->prepare("UPDATE item_groups SET groupName=?,sortOrder=? WHERE groupID=?")->execute([$name,$order,$id]);
+            flash('success', 'Group updated.');
+        } else { flash('error', 'Name required.'); }
+    }
+    if ($action === 'delete') {
+        $id = (int)$_POST['groupID'];
+        $pdo->prepare("DELETE FROM item_groups WHERE groupID=?")->execute([$id]);
+        flash('success', 'Group deleted.');
+    }
+    header('Location: item_groups.php'); exit;
+}
+
+$groups = $pdo->query("SELECT * FROM item_groups ORDER BY sortOrder, groupID")->fetchAll();
+
+$editing = null;
+if (isset($_GET['edit'])) {
+    $stmt = $pdo->prepare("SELECT * FROM item_groups WHERE groupID=?");
+    $stmt->execute([(int)$_GET['edit']]);
+    $editing = $stmt->fetch();
+}
+
+admin_head('Item Groups', 'item_groups.php');
+show_alerts();
+?>
+<h1>Item Groups</h1>
+<div class="card">
+  <h2><?= $editing ? 'Edit Group' : 'Add New Group' ?></h2>
+  <form method="POST">
+    <input type="hidden" name="action" value="<?= $editing ? 'edit' : 'add' ?>">
+    <?php if ($editing): ?>
+    <input type="hidden" name="groupID" value="<?= $editing['groupID'] ?>">
+    <?php endif; ?>
+    <div class="form-row">
+      <div>
+        <label>Group Name</label>
+        <input type="text" name="groupName"
+               value="<?= htmlspecialchars($editing['groupName'] ?? '') ?>"
+               placeholder="e.g. Clothing" required>
+      </div>
+      <div>
+        <label>Sort Order</label>
+        <input type="number" name="sortOrder"
+               value="<?= (int)($editing['sortOrder'] ?? 0) ?>" min="0">
+      </div>
+    </div>
+    <div style="display:flex;gap:.5rem;">
+      <button type="submit" class="btn btn-primary"><?= $editing ? '💾 Save' : '➕ Add Group' ?></button>
+      <?php if ($editing): ?><a href="item_groups.php" class="btn btn-secondary">Cancel</a><?php endif; ?>
+    </div>
+  </form>
+</div>
+
+<table class="tbl">
+  <thead><tr><th>#</th><th>Name</th><th>Sort</th><th>Items</th><th></th></tr></thead>
+  <tbody>
+  <?php foreach ($groups as $g):
+    $stmt = $pdo->prepare("SELECT COUNT(*) FROM items WHERE groupID=?");
+    $stmt->execute([$g['groupID']]);
+    $cnt = $stmt->fetchColumn();
+  ?>
+  <tr>
+    <td style="color:var(--muted)"><?= $g['groupID'] ?></td>
+    <td><?= htmlspecialchars($g['groupName']) ?></td>
+    <td><?= $g['sortOrder'] ?></td>
+    <td><span class="badge badge-group"><?= $cnt ?></span></td>
+    <td style="display:flex;gap:.4rem;justify-content:flex-end;">
+      <a href="?edit=<?= $g['groupID'] ?>" class="btn btn-sm btn-teal">Edit</a>
+      <form method="POST" onsubmit="return confirm('Delete group and ALL its items?')">
+        <input type="hidden" name="action" value="delete">
+        <input type="hidden" name="groupID" value="<?= $g['groupID'] ?>">
+        <button class="btn btn-sm btn-danger">Delete</button>
+      </form>
+    </td>
+  </tr>
+  <?php endforeach; ?>
+  </tbody>
+</table>
+<?php admin_foot(); ?>

+ 115 - 0
admin/items.php

@@ -0,0 +1,115 @@
+<?php
+require_once __DIR__ . '/auth.php';
+require_once __DIR__ . '/layout.php';
+require_admin();
+
+$pdo = get_pdo();
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    $action = $_POST['action'] ?? '';
+    if ($action === 'add') {
+        $name  = trim($_POST['itemName'] ?? '');
+        $gid   = (int)$_POST['groupID'];
+        $order = (int)($_POST['sortOrder'] ?? 0);
+        if ($name !== '' && $gid > 0) {
+            $pdo->prepare("INSERT INTO items (groupID,itemName,sortOrder) VALUES (?,?,?)")->execute([$gid,$name,$order]);
+            flash('success', "Item '$name' added.");
+        } else { flash('error', 'Please fill all fields.'); }
+    }
+    if ($action === 'edit') {
+        $id    = (int)$_POST['itemID'];
+        $name  = trim($_POST['itemName'] ?? '');
+        $gid   = (int)$_POST['groupID'];
+        $order = (int)($_POST['sortOrder'] ?? 0);
+        if ($name !== '') {
+            $pdo->prepare("UPDATE items SET itemName=?,groupID=?,sortOrder=? WHERE itemID=?")->execute([$name,$gid,$order,$id]);
+            flash('success', 'Item updated.');
+        } else { flash('error', 'Name required.'); }
+    }
+    if ($action === 'delete') {
+        $id = (int)$_POST['itemID'];
+        $pdo->prepare("DELETE FROM items WHERE itemID=?")->execute([$id]);
+        flash('success', 'Item deleted.');
+    }
+    header('Location: items.php'); exit;
+}
+
+$igroups = $pdo->query("SELECT * FROM item_groups ORDER BY sortOrder,groupID")->fetchAll();
+$items   = $pdo->query("
+    SELECT i.*, ig.groupName
+    FROM items i
+    JOIN item_groups ig ON ig.groupID = i.groupID
+    ORDER BY ig.sortOrder, i.sortOrder, i.itemID
+")->fetchAll();
+
+$editing = null;
+if (isset($_GET['edit'])) {
+    $stmt = $pdo->prepare("SELECT * FROM items WHERE itemID=?");
+    $stmt->execute([(int)$_GET['edit']]);
+    $editing = $stmt->fetch();
+}
+
+admin_head('Items', 'items.php');
+show_alerts();
+?>
+<h1>Items</h1>
+<div class="card">
+  <h2><?= $editing ? 'Edit Item' : 'Add New Item' ?></h2>
+  <form method="POST">
+    <input type="hidden" name="action" value="<?= $editing ? 'edit' : 'add' ?>">
+    <?php if ($editing): ?>
+    <input type="hidden" name="itemID" value="<?= $editing['itemID'] ?>">
+    <?php endif; ?>
+    <div class="form-row">
+      <div>
+        <label>Item Name</label>
+        <input type="text" name="itemName"
+               value="<?= htmlspecialchars($editing['itemName'] ?? '') ?>"
+               placeholder="e.g. Rain jacket" required>
+      </div>
+      <div>
+        <label>Group</label>
+        <select name="groupID" required>
+          <option value="">– select –</option>
+          <?php foreach ($igroups as $g): ?>
+          <option value="<?= $g['groupID'] ?>"
+            <?= ($editing && $editing['groupID'] == $g['groupID']) ? 'selected' : '' ?>>
+            <?= htmlspecialchars($g['groupName']) ?>
+          </option>
+          <?php endforeach; ?>
+        </select>
+      </div>
+    </div>
+    <div>
+      <label>Sort Order</label>
+      <input type="number" name="sortOrder" value="<?= (int)($editing['sortOrder'] ?? 0) ?>" min="0" style="max-width:120px;">
+    </div>
+    <div style="display:flex;gap:.5rem;margin-top:.25rem;">
+      <button type="submit" class="btn btn-primary"><?= $editing ? '💾 Save' : '➕ Add' ?></button>
+      <?php if ($editing): ?><a href="items.php" class="btn btn-secondary">Cancel</a><?php endif; ?>
+    </div>
+  </form>
+</div>
+
+<table class="tbl">
+  <thead><tr><th>#</th><th>Name</th><th>Group</th><th>Sort</th><th></th></tr></thead>
+  <tbody>
+  <?php foreach ($items as $item): ?>
+  <tr>
+    <td style="color:var(--muted)"><?= $item['itemID'] ?></td>
+    <td><?= htmlspecialchars($item['itemName']) ?></td>
+    <td><span class="badge badge-group"><?= htmlspecialchars($item['groupName']) ?></span></td>
+    <td><?= $item['sortOrder'] ?></td>
+    <td style="display:flex;gap:.4rem;justify-content:flex-end;">
+      <a href="?edit=<?= $item['itemID'] ?>" class="btn btn-sm btn-teal">Edit</a>
+      <form method="POST" onsubmit="return confirm('Delete this item?')">
+        <input type="hidden" name="action" value="delete">
+        <input type="hidden" name="itemID" value="<?= $item['itemID'] ?>">
+        <button class="btn btn-sm btn-danger">Delete</button>
+      </form>
+    </td>
+  </tr>
+  <?php endforeach; ?>
+  </tbody>
+</table>
+<?php admin_foot(); ?>

+ 116 - 0
admin/layout.php

@@ -0,0 +1,116 @@
+<?php
+// layout.php – shared admin HTML chrome
+// Call admin_head($title) and admin_foot() around content.
+
+function admin_head(string $title, string $active = ''): void {
+    $nav = [
+        'index.php'          => 'Dashboard',
+        'activities.php'     => 'Activities',
+        'activity_groups.php'=> 'Activity Groups',
+        'items.php'          => 'Items',
+        'item_groups.php'    => 'Item Groups',
+        'mappings.php'       => 'Mappings',
+    ];
+    ?>
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>PackIt Admin – <?= htmlspecialchars($title) ?></title>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>
+:root{--bg:#0f1117;--surface:#181c27;--border:#2a2f3f;--accent:#e8c547;--accent2:#4fd1c5;--text:#e8eaf0;--muted:#7a8099;--danger:#e05555;--green:#5cb85c;--radius:8px;}
+*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
+body{background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif;font-size:14px;display:flex;min-height:100vh;}
+/* sidebar */
+.sidebar{width:200px;flex-shrink:0;background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;padding:1.5rem 0;}
+.sidebar-logo{font-family:'Bebas Neue',sans-serif;font-size:1.8rem;color:var(--accent);letter-spacing:.05em;padding:0 1.25rem .5rem;}
+.sidebar-sub{font-size:.7rem;color:var(--muted);letter-spacing:.1em;text-transform:uppercase;padding:0 1.25rem 1.5rem;}
+.nav-link{display:block;padding:.55rem 1.25rem;color:var(--muted);text-decoration:none;font-size:.85rem;font-weight:500;border-left:3px solid transparent;transition:color .15s,border-color .15s,background .15s;}
+.nav-link:hover{color:var(--text);background:rgba(255,255,255,.03);}
+.nav-link.active{color:var(--accent);border-left-color:var(--accent);background:rgba(232,197,71,.05);}
+.nav-sep{height:1px;background:var(--border);margin:.5rem 1.25rem;}
+.sidebar-footer{margin-top:auto;padding:1rem 1.25rem;}
+.btn-logout{display:block;text-align:center;padding:.5rem;background:transparent;border:1px solid var(--border);border-radius:var(--radius);color:var(--muted);font-size:.8rem;cursor:pointer;text-decoration:none;transition:all .15s;width:100%;}
+.btn-logout:hover{border-color:var(--danger);color:var(--danger);}
+/* main */
+.admin-main{flex:1;display:flex;flex-direction:column;min-width:0;}
+.admin-topbar{padding:.9rem 1.75rem;border-bottom:1px solid var(--border);font-size:.85rem;color:var(--muted);display:flex;align-items:center;justify-content:space-between;}
+.admin-content{padding:1.75rem;flex:1;}
+h1{font-family:'Bebas Neue',sans-serif;font-size:1.8rem;letter-spacing:.05em;color:var(--accent);margin-bottom:1.25rem;}
+h2{font-size:1rem;font-weight:600;margin-bottom:.75rem;color:var(--text);}
+/* table */
+.tbl{width:100%;border-collapse:collapse;margin-bottom:1.5rem;}
+.tbl th{text-align:left;font-size:.75rem;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);padding:.6rem .9rem;border-bottom:1px solid var(--border);}
+.tbl td{padding:.6rem .9rem;border-bottom:1px solid rgba(42,47,63,.6);vertical-align:middle;}
+.tbl tr:hover td{background:rgba(255,255,255,.02);}
+/* form card */
+.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:1.25rem 1.5rem;margin-bottom:1.5rem;}
+label{display:block;font-size:.8rem;font-weight:500;color:var(--muted);margin-bottom:.3rem;letter-spacing:.04em;text-transform:uppercase;}
+input[type=text],input[type=password],input[type=number],select,textarea{width:100%;background:var(--bg);border:1.5px solid var(--border);color:var(--text);padding:.55rem .8rem;border-radius:var(--radius);font-family:'DM Sans',sans-serif;font-size:.9rem;outline:none;transition:border-color .15s;margin-bottom:.9rem;}
+input:focus,select:focus,textarea:focus{border-color:var(--accent2);}
+.form-row{display:grid;grid-template-columns:1fr 1fr;gap:1rem;}
+/* buttons */
+.btn{display:inline-flex;align-items:center;gap:.4rem;padding:.5rem 1rem;border-radius:var(--radius);font-family:'DM Sans',sans-serif;font-size:.83rem;font-weight:500;cursor:pointer;border:1.5px solid transparent;text-decoration:none;transition:all .15s;}
+.btn-sm{padding:.3rem .7rem;font-size:.78rem;}
+.btn-primary{background:var(--accent);color:#0f1117;border-color:var(--accent);}
+.btn-primary:hover{background:#f0d260;}
+.btn-danger{background:transparent;color:var(--danger);border-color:var(--danger);}
+.btn-danger:hover{background:rgba(224,85,85,.08);}
+.btn-secondary{background:transparent;color:var(--text);border-color:var(--border);}
+.btn-secondary:hover{border-color:var(--muted);}
+.btn-teal{background:transparent;color:var(--accent2);border-color:var(--accent2);}
+.btn-teal:hover{background:rgba(79,209,197,.08);}
+/* alerts */
+.alert{padding:.75rem 1rem;border-radius:var(--radius);margin-bottom:1rem;font-size:.85rem;}
+.alert-success{background:rgba(92,184,92,.1);border:1px solid rgba(92,184,92,.3);color:var(--green);}
+.alert-error{background:rgba(224,85,85,.1);border:1px solid rgba(224,85,85,.3);color:var(--danger);}
+/* badges */
+.badge{display:inline-block;padding:.15rem .55rem;border-radius:999px;font-size:.72rem;font-weight:600;letter-spacing:.04em;}
+.badge-group{background:rgba(79,209,197,.1);color:var(--accent2);border:1px solid rgba(79,209,197,.3);}
+</style>
+</head>
+<body>
+<aside class="sidebar">
+  <div class="sidebar-logo">PackIt</div>
+  <div class="sidebar-sub">Admin Panel</div>
+  <?php foreach ($nav as $file => $label): ?>
+  <a href="<?= $file ?>" class="nav-link <?= $active === $file ? 'active' : '' ?>"><?= $label ?></a>
+  <?php endforeach; ?>
+  <div class="nav-sep"></div>
+  <a href="../index.php" class="nav-link" style="font-size:.78rem">← Back to site</a>
+  <div class="sidebar-footer">
+    <a href="logout.php" class="btn-logout">Log out</a>
+  </div>
+</aside>
+<div class="admin-main">
+<div class="admin-topbar">
+  <span>PackIt Admin</span>
+  <span style="color:var(--accent);font-weight:500"><?= htmlspecialchars($_SESSION['admin_user'] ?? '') ?></span>
+</div>
+<div class="admin-content">
+<?php } // end admin_head
+
+function admin_foot(): void { ?>
+</div></div></body></html>
+<?php }
+
+function flash(string $key, string $msg): void {
+    $_SESSION["flash_$key"] = $msg;
+}
+
+function get_flash(string $key): string {
+    $msg = $_SESSION["flash_$key"] ?? '';
+    unset($_SESSION["flash_$key"]);
+    return $msg;
+}
+
+function show_alerts(): void {
+    $s = get_flash('success');
+    $e = get_flash('error');
+    if ($s) echo '<div class="alert alert-success">' . htmlspecialchars($s) . '</div>';
+    if ($e) echo '<div class="alert alert-error">'   . htmlspecialchars($e) . '</div>';
+}

+ 81 - 0
admin/login.php

@@ -0,0 +1,81 @@
+<?php
+require_once __DIR__ . '/auth.php';
+
+if (ADMIN_LOGGED_IN) {
+    header('Location: index.php');
+    exit;
+}
+
+$error = '';
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    $user = trim($_POST['username'] ?? '');
+    $pass = $_POST['password'] ?? '';
+
+    if ($user === '' || $pass === '') {
+        $error = 'Please enter username and password.';
+    } else {
+        $pdo = get_pdo();
+        $stmt = $pdo->prepare("SELECT password_hash FROM admin_users WHERE username = ?");
+        $stmt->execute([$user]);
+        $row = $stmt->fetch();
+
+        if ($row && password_verify($pass, $row['password_hash'])) {
+            session_regenerate_id(true);
+            $_SESSION['admin_logged_in'] = true;
+            $_SESSION['admin_user']      = $user;
+            header('Location: index.php');
+            exit;
+        } else {
+            $error = 'Invalid username or password.';
+            // Slow down brute-force attempts
+            sleep(1);
+        }
+    }
+}
+?>
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>PackIt Admin – Login</title>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>
+:root{--bg:#0f1117;--surface:#181c27;--border:#2a2f3f;--accent:#e8c547;--accent2:#4fd1c5;--text:#e8eaf0;--muted:#7a8099;--danger:#e05555;--radius:10px;}
+*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
+body{background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center;}
+body::before{content:'';position:fixed;inset:0;background:radial-gradient(ellipse 60% 60% at 50% 40%, rgba(232,197,71,.05) 0%, transparent 70%);pointer-events:none;}
+.login-box{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:2.5rem 2rem;width:100%;max-width:360px;position:relative;}
+.login-logo{font-family:'Bebas Neue',sans-serif;font-size:2.8rem;color:var(--accent);letter-spacing:.05em;margin-bottom:.25rem;}
+.login-sub{font-size:.78rem;color:var(--muted);text-transform:uppercase;letter-spacing:.1em;margin-bottom:2rem;}
+label{display:block;font-size:.75rem;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;margin-bottom:.3rem;}
+input{display:block;width:100%;background:var(--bg);border:1.5px solid var(--border);color:var(--text);padding:.6rem .85rem;border-radius:var(--radius);font-family:'DM Sans',sans-serif;font-size:.9rem;outline:none;margin-bottom:1rem;transition:border-color .15s;}
+input:focus{border-color:var(--accent2);}
+.btn-login{width:100%;background:var(--accent);color:#0f1117;border:none;padding:.75rem;border-radius:var(--radius);font-family:'DM Sans',sans-serif;font-size:.95rem;font-weight:600;cursor:pointer;margin-top:.5rem;transition:background .15s;}
+.btn-login:hover{background:#f0d260;}
+.error-msg{background:rgba(224,85,85,.1);border:1px solid rgba(224,85,85,.3);color:var(--danger);padding:.65rem .9rem;border-radius:var(--radius);font-size:.85rem;margin-bottom:1rem;}
+</style>
+</head>
+<body>
+<div class="login-box">
+  <div class="login-logo">PackIt</div>
+  <div class="login-sub">Admin Access</div>
+  <?php if ($error): ?>
+  <div class="error-msg"><?= htmlspecialchars($error) ?></div>
+  <?php endif; ?>
+  <form method="POST">
+    <label for="username">Username</label>
+    <input type="text" id="username" name="username"
+           value="<?= htmlspecialchars($_POST['username'] ?? '') ?>"
+           autocomplete="username" required>
+    <label for="password">Password</label>
+    <input type="password" id="password" name="password"
+           autocomplete="current-password" required>
+    <button class="btn-login" type="submit">Sign in</button>
+  </form>
+</div>
+</body>
+</html>

+ 3 - 0
admin/logout.php

@@ -0,0 +1,3 @@
+<?php
+require_once __DIR__ . '/auth.php';
+admin_logout();

+ 161 - 0
admin/mappings.php

@@ -0,0 +1,161 @@
+<?php
+require_once __DIR__ . '/auth.php';
+require_once __DIR__ . '/layout.php';
+require_admin();
+
+$pdo = get_pdo();
+
+// Handle add/remove mapping
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    $action = $_POST['action'] ?? '';
+    $aid    = (int)($_POST['activityID'] ?? 0);
+    $iid    = (int)($_POST['itemID']     ?? 0);
+
+    if ($action === 'add' && $aid > 0 && $iid > 0) {
+        try {
+            $pdo->prepare("INSERT IGNORE INTO activity_item_map (activityID,itemID) VALUES (?,?)")
+                ->execute([$aid, $iid]);
+            flash('success', 'Mapping added.');
+        } catch (Exception $e) {
+            flash('error', 'Could not add mapping.');
+        }
+    }
+    if ($action === 'remove' && $aid > 0 && $iid > 0) {
+        $pdo->prepare("DELETE FROM activity_item_map WHERE activityID=? AND itemID=?")
+            ->execute([$aid, $iid]);
+        flash('success', 'Mapping removed.');
+    }
+
+    header('Location: mappings.php' . (isset($_GET['aid']) ? '?aid=' . (int)$_GET['aid'] : ''));
+    exit;
+}
+
+// Load all activities and items for selects
+$activities = $pdo->query("
+    SELECT a.activityID, a.activityName, ag.groupName
+    FROM activities a
+    JOIN activity_groups ag ON ag.groupID = a.groupID
+    ORDER BY ag.sortOrder, a.sortOrder, a.activityID
+")->fetchAll();
+
+$allItems = $pdo->query("
+    SELECT i.itemID, i.itemName, ig.groupName
+    FROM items i
+    JOIN item_groups ig ON ig.groupID = i.groupID
+    ORDER BY ig.sortOrder, i.sortOrder, i.itemID
+")->fetchAll();
+
+// Selected activity filter
+$selectedAid = isset($_GET['aid']) ? (int)$_GET['aid'] : 0;
+if (!$selectedAid && !empty($activities)) {
+    $selectedAid = $activities[0]['activityID'];
+}
+
+// Get current mappings for selected activity
+$mappedItemIds = [];
+$mappedItems   = [];
+if ($selectedAid) {
+    $stmt = $pdo->prepare("
+        SELECT i.itemID, i.itemName, ig.groupName
+        FROM activity_item_map aim
+        JOIN items i          ON i.itemID   = aim.itemID
+        JOIN item_groups ig   ON ig.groupID = i.groupID
+        WHERE aim.activityID = ?
+        ORDER BY ig.sortOrder, i.sortOrder
+    ");
+    $stmt->execute([$selectedAid]);
+    $mappedItems   = $stmt->fetchAll();
+    $mappedItemIds = array_column($mappedItems, 'itemID');
+}
+
+admin_head('Mappings', 'mappings.php');
+show_alerts();
+?>
+<h1>Activity ↔ Item Mappings</h1>
+
+<div style="display:flex;gap:1.5rem;flex-wrap:wrap;align-items:flex-start;">
+
+  <!-- Activity selector -->
+  <div class="card" style="min-width:220px;flex:0 0 220px;">
+    <h2>Select Activity</h2>
+    <?php foreach ($activities as $a): ?>
+    <a href="?aid=<?= $a['activityID'] ?>"
+       style="display:block;padding:.45rem .6rem;border-radius:6px;margin-bottom:.25rem;font-size:.85rem;text-decoration:none;
+              background:<?= $selectedAid==$a['activityID'] ? 'rgba(232,197,71,.1)' : 'transparent' ?>;
+              color:<?= $selectedAid==$a['activityID'] ? 'var(--accent)' : 'var(--muted)' ?>;
+              border:1px solid <?= $selectedAid==$a['activityID'] ? 'rgba(232,197,71,.3)' : 'transparent' ?>;">
+      <?= htmlspecialchars($a['activityName']) ?>
+      <span style="float:right;font-size:.72rem;color:var(--muted)"><?= htmlspecialchars($a['groupName']) ?></span>
+    </a>
+    <?php endforeach; ?>
+  </div>
+
+  <!-- Mapping manager -->
+  <div style="flex:1;min-width:300px;">
+    <?php if ($selectedAid):
+      // Get the activity name
+      $actName = '';
+      foreach ($activities as $a) { if ($a['activityID'] == $selectedAid) $actName = $a['activityName']; }
+    ?>
+    <div class="card">
+      <h2 style="margin-bottom:1rem">Items for: <span style="color:var(--accent)"><?= htmlspecialchars($actName) ?></span></h2>
+
+      <!-- Add new mapping -->
+      <form method="POST" style="display:flex;gap:.5rem;align-items:flex-end;margin-bottom:1.25rem;flex-wrap:wrap;">
+        <input type="hidden" name="action" value="add">
+        <input type="hidden" name="activityID" value="<?= $selectedAid ?>">
+        <div style="flex:1;min-width:200px;">
+          <label>Add Item</label>
+          <select name="itemID" required style="margin-bottom:0;">
+            <option value="">– choose item –</option>
+            <?php
+            // Group items in optgroups
+            $optGroups = [];
+            foreach ($allItems as $it) {
+                if (in_array($it['itemID'], $mappedItemIds)) continue; // already mapped
+                $optGroups[$it['groupName']][] = $it;
+            }
+            foreach ($optGroups as $grpName => $grpItems):
+            ?>
+            <optgroup label="<?= htmlspecialchars($grpName) ?>">
+              <?php foreach ($grpItems as $it): ?>
+              <option value="<?= $it['itemID'] ?>"><?= htmlspecialchars($it['itemName']) ?></option>
+              <?php endforeach; ?>
+            </optgroup>
+            <?php endforeach; ?>
+          </select>
+        </div>
+        <button type="submit" class="btn btn-primary">➕ Add</button>
+      </form>
+
+      <!-- Current mapped items -->
+      <?php if (empty($mappedItems)): ?>
+      <p style="color:var(--muted);font-size:.85rem">No items mapped yet.</p>
+      <?php else: ?>
+      <table class="tbl">
+        <thead><tr><th>#</th><th>Item</th><th>Group</th><th></th></tr></thead>
+        <tbody>
+        <?php foreach ($mappedItems as $it): ?>
+        <tr>
+          <td style="color:var(--muted)"><?= $it['itemID'] ?></td>
+          <td><?= htmlspecialchars($it['itemName']) ?></td>
+          <td><span class="badge badge-group"><?= htmlspecialchars($it['groupName']) ?></span></td>
+          <td style="text-align:right">
+            <form method="POST" onsubmit="return confirm('Remove this mapping?')">
+              <input type="hidden" name="action"     value="remove">
+              <input type="hidden" name="activityID" value="<?= $selectedAid ?>">
+              <input type="hidden" name="itemID"     value="<?= $it['itemID'] ?>">
+              <button class="btn btn-sm btn-danger">Remove</button>
+            </form>
+          </td>
+        </tr>
+        <?php endforeach; ?>
+        </tbody>
+      </table>
+      <?php endif; ?>
+    </div>
+    <?php endif; ?>
+  </div>
+
+</div>
+<?php admin_foot(); ?>

+ 13 - 0
config.php

@@ -0,0 +1,13 @@
+<?php
+// PackIt – database configuration
+// Place this file ONE directory ABOVE the web-root (e.g. /var/www/config.php)
+// so it is never served directly by the web server.
+
+$host     = 'localhost';
+$dbname   = 'packit';
+$username = 'packer';
+$password = 'DEbWX9AHkzAgaNdLC0Qj605grG';
+$charset  = 'utf8mb4';
+
+// Admin session secret – change to any random string
+define('ADMIN_SESSION_SECRET', 'fdgdffrsgjsjsrttdc4rtjuzi8edetjh');

+ 26 - 0
db.php

@@ -0,0 +1,26 @@
+<?php
+// db.php – shared PDO helper
+// Included by every script that needs a database connection.
+ini_set('display_errors', 1); ini_set('display_startup_errors', 1); error_reporting(E_ALL);
+require_once __DIR__ . '/config.php';
+
+function get_pdo(): PDO {
+    global $host, $dbname, $username, $password, $charset;
+    static $pdo = null;
+    if ($pdo === null) {
+        $dsn = "mysql:host=$host;dbname=$dbname;charset=$charset";
+        $options = [
+            PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
+            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+            PDO::ATTR_EMULATE_PREPARES   => false,
+        ];
+        try {
+            $pdo = new PDO($dsn, $username, $password, $options);
+        } catch (PDOException $e) {
+            http_response_code(500);
+            die('<p style="font-family:monospace;color:#c00">Database connection failed: '
+                . htmlspecialchars($e->getMessage()) . '</p>');
+        }
+    }
+    return $pdo;
+}

+ 306 - 56
index.php

@@ -1,74 +1,324 @@
 <?php
+ini_set('display_errors', 1); ini_set('display_startup_errors', 1); error_reporting(E_ALL);
+// index.php – Activity selection page
+require_once __DIR__ . '/db.php';
 
-// error reporting
-error_reporting(E_ALL);
+$pdo = get_pdo();
 
-// Include the external configuration file
-require_once '../config.php';
-
-
-try {
-    // Use the settings from config.php for the PDO connection
-    $pdo = new PDO("mysql:host=$host;dbname=$dbname", $username, $password);
-    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
-} catch (PDOException $e) {
-    echo 'Connection failed: ' . $e->getMessage();
-    exit;
-}
-
-// Query to fetch activities and their corresponding names
-$query = "
-    SELECT a.activityID, an.activityName
-    FROM activities a
-    JOIN activity_names an ON a.activityID = an.activityID
+// Fetch all groups with their activities
+$sql = "
+    SELECT
+        ag.groupID,
+        ag.groupName   AS groupName,
+        ag.sortOrder   AS groupSort,
+        a.activityID,
+        a.activityName,
+        a.sortOrder    AS actSort
+    FROM activity_groups ag
+    JOIN activities a ON a.groupID = ag.groupID
+    ORDER BY ag.sortOrder, ag.groupID, a.sortOrder, a.activityID
 ";
+$rows = $pdo->query($sql)->fetchAll();
 
-// Execute the query and fetch the results
-$stmt = $pdo->query($query);
-$activities = $stmt->fetchAll(PDO::FETCH_ASSOC);
-
-// If no activities found
-if (!$activities) {
-    echo "No activities found.";
-    exit;
+// Group by category
+$groups = [];
+foreach ($rows as $row) {
+    $gid = $row['groupID'];
+    if (!isset($groups[$gid])) {
+        $groups[$gid] = ['name' => $row['groupName'], 'activities' => []];
+    }
+    $groups[$gid]['activities'][] = [
+        'id'   => $row['activityID'],
+        'name' => $row['activityName'],
+    ];
 }
 ?>
-
 <!DOCTYPE html>
 <html lang="en">
 <head>
-    <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0">
-    <title>Activity Selection</title>
-    <style>
-        body {
-            font-family: Arial, sans-serif;
-            padding: 20px;
-        }
-        .activity-item {
-            margin: 5px 0;
-        }
-        .activity-item input {
-            margin-right: 10px;
-        }
-    </style>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>PackIt – Choose Your Activities</title>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>
+  :root {
+    --bg:       #0f1117;
+    --surface:  #181c27;
+    --border:   #2a2f3f;
+    --accent:   #e8c547;
+    --accent2:  #4fd1c5;
+    --text:     #e8eaf0;
+    --muted:    #7a8099;
+    --danger:   #e05555;
+    --radius:   10px;
+    --gap:      1.5rem;
+  }
+
+  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+  body {
+    background: var(--bg);
+    color: var(--text);
+    font-family: 'DM Sans', sans-serif;
+    font-size: 15px;
+    min-height: 100vh;
+    display: flex;
+    flex-direction: column;
+  }
+
+  /* ---------- header ---------- */
+  header {
+    padding: 2.5rem var(--gap) 1.5rem;
+    border-bottom: 1px solid var(--border);
+    display: flex;
+    align-items: flex-end;
+    gap: 1rem;
+    position: relative;
+    overflow: hidden;
+  }
+  header::before {
+    content: '';
+    position: absolute;
+    inset: 0;
+    background: radial-gradient(ellipse 60% 80% at 10% 50%, rgba(232,197,71,.08) 0%, transparent 70%);
+    pointer-events: none;
+  }
+  .logo {
+    font-family: 'Bebas Neue', sans-serif;
+    font-size: clamp(2.8rem, 6vw, 4.5rem);
+    letter-spacing: .04em;
+    color: var(--accent);
+    line-height: 1;
+    position: relative;
+  }
+  .tagline {
+    font-size: .85rem;
+    color: var(--muted);
+    letter-spacing: .08em;
+    text-transform: uppercase;
+    padding-bottom: .25rem;
+    position: relative;
+  }
+
+  /* ---------- main layout ---------- */
+  main {
+    flex: 1;
+    max-width: 760px;
+    width: 100%;
+    margin: 0 auto;
+    padding: 2.5rem var(--gap) 6rem;
+  }
+
+  .section-title {
+    font-family: 'Bebas Neue', sans-serif;
+    font-size: 1.15rem;
+    letter-spacing: .12em;
+    color: var(--accent2);
+    text-transform: uppercase;
+    margin-bottom: .75rem;
+    display: flex;
+    align-items: center;
+    gap: .6rem;
+  }
+  .section-title::after {
+    content: '';
+    flex: 1;
+    height: 1px;
+    background: var(--border);
+  }
+
+  .activity-group {
+    margin-bottom: 2rem;
+  }
+
+  .activity-grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+    gap: .6rem;
+  }
+
+  /* Custom checkbox card */
+  .activity-card {
+    position: relative;
+  }
+  .activity-card input[type="checkbox"] {
+    position: absolute;
+    opacity: 0;
+    width: 0; height: 0;
+  }
+  .activity-card label {
+    display: flex;
+    align-items: center;
+    gap: .7rem;
+    padding: .7rem 1rem;
+    background: var(--surface);
+    border: 1.5px solid var(--border);
+    border-radius: var(--radius);
+    cursor: pointer;
+    transition: border-color .15s, background .15s, transform .1s;
+    user-select: none;
+  }
+  .activity-card label:hover {
+    border-color: var(--accent);
+    background: #1e2333;
+  }
+  .check-icon {
+    width: 18px; height: 18px;
+    border: 1.5px solid var(--border);
+    border-radius: 5px;
+    flex-shrink: 0;
+    display: flex; align-items: center; justify-content: center;
+    transition: background .15s, border-color .15s;
+    background: var(--bg);
+  }
+  .check-icon svg { display: none; }
+
+  .activity-card input:checked + label {
+    border-color: var(--accent);
+    background: rgba(232,197,71,.07);
+  }
+  .activity-card input:checked + label .check-icon {
+    background: var(--accent);
+    border-color: var(--accent);
+  }
+  .activity-card input:checked + label .check-icon svg { display: block; }
+  .activity-card input:checked + label { transform: scale(1.01); }
+
+  /* ---------- sticky footer bar ---------- */
+  .action-bar {
+    position: fixed;
+    bottom: 0; left: 0; right: 0;
+    background: rgba(15,17,23,.92);
+    backdrop-filter: blur(12px);
+    border-top: 1px solid var(--border);
+    padding: 1rem var(--gap);
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 1rem;
+    z-index: 100;
+  }
+  .selection-count {
+    font-size: .85rem;
+    color: var(--muted);
+  }
+  .selection-count strong {
+    color: var(--accent);
+    font-variant-numeric: tabular-nums;
+  }
+
+  .btn-primary {
+    background: var(--accent);
+    color: #0f1117;
+    font-family: 'DM Sans', sans-serif;
+    font-weight: 600;
+    font-size: .95rem;
+    letter-spacing: .03em;
+    padding: .7rem 1.8rem;
+    border: none;
+    border-radius: var(--radius);
+    cursor: pointer;
+    transition: background .15s, transform .1s, opacity .15s;
+  }
+  .btn-primary:hover { background: #f0d260; transform: translateY(-1px); }
+  .btn-primary:active { transform: translateY(0); }
+  .btn-primary:disabled { opacity: .35; cursor: not-allowed; transform: none; }
+
+  /* select-all row */
+  .controls {
+    display: flex;
+    gap: .75rem;
+    margin-bottom: 2rem;
+    flex-wrap: wrap;
+  }
+  .btn-ghost {
+    background: transparent;
+    color: var(--muted);
+    border: 1.5px solid var(--border);
+    border-radius: var(--radius);
+    padding: .45rem 1rem;
+    font-family: 'DM Sans', sans-serif;
+    font-size: .8rem;
+    font-weight: 500;
+    letter-spacing: .04em;
+    text-transform: uppercase;
+    cursor: pointer;
+    transition: color .15s, border-color .15s;
+  }
+  .btn-ghost:hover { color: var(--text); border-color: var(--muted); }
+
+  footer {
+    text-align: center;
+    padding: 1rem;
+    font-size: .75rem;
+    color: var(--border);
+  }
+  footer a { color: var(--muted); text-decoration: none; }
+  footer a:hover { color: var(--text); }
+</style>
 </head>
 <body>
 
-    <h1>Select Activities</h1>
-    <form action="submit_activities.php" method="GET">
-        <?php foreach ($activities as $activity): ?>
-            <div class="activity-item">
-                <input type="checkbox" name="activity_ids" value="<?php echo htmlspecialchars($activity['activityID']); ?>">
-                <label for="activity_<?php echo htmlspecialchars($activity['activityID']); ?>">
-                    <?php echo htmlspecialchars($activity['activityName']); ?>
-                </label>
-            </div>
+<header>
+  <div class="logo">PackIt</div>
+  <div class="tagline">Your smart packing companion</div>
+</header>
+
+<main>
+  <div class="controls">
+    <button class="btn-ghost" type="button" onclick="toggleAll(true)">Select all</button>
+    <button class="btn-ghost" type="button" onclick="toggleAll(false)">Clear all</button>
+  </div>
+
+  <form id="actForm" action="list.php" method="GET">
+    <?php foreach ($groups as $gid => $group): ?>
+    <div class="activity-group">
+      <div class="section-title"><?= htmlspecialchars($group['name']) ?></div>
+      <div class="activity-grid">
+        <?php foreach ($group['activities'] as $act): ?>
+        <div class="activity-card">
+          <input type="checkbox" name="aID[]"
+                 id="act_<?= $act['id'] ?>"
+                 value="<?= (int)$act['id'] ?>"
+                 onchange="updateCount()">
+          <label for="act_<?= $act['id'] ?>">
+            <span class="check-icon">
+              <svg width="10" height="8" viewBox="0 0 10 8" fill="none">
+                <path d="M1 4l3 3 5-6" stroke="#0f1117" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+              </svg>
+            </span>
+            <?= htmlspecialchars($act['name']) ?>
+          </label>
+        </div>
         <?php endforeach; ?>
+      </div>
+    </div>
+    <?php endforeach; ?>
+  </form>
+</main>
 
-        <br>
-        <button type="submit">Pack it in!</button>
-    </form>
+<div class="action-bar">
+  <div class="selection-count"><strong id="cnt">0</strong> activities selected</div>
+  <button class="btn-primary" id="packBtn" disabled
+          onclick="document.getElementById('actForm').submit()">
+    Pack it! →
+  </button>
+</div>
 
+<footer><a href="admin/">Admin</a></footer>
+
+<script>
+function updateCount() {
+  const n = document.querySelectorAll('input[name="aID[]"]:checked').length;
+  document.getElementById('cnt').textContent = n;
+  document.getElementById('packBtn').disabled = n === 0;
+}
+function toggleAll(on) {
+  document.querySelectorAll('input[name="aID[]"]').forEach(cb => cb.checked = on);
+  updateCount();
+}
+</script>
 </body>
 </html>

+ 53 - 0
list.php

@@ -0,0 +1,53 @@
+<?php
+// list.php – receives selected activity IDs, creates/reuses a hash, redirects to packing.php
+require_once __DIR__ . '/db.php';
+
+$pdo = get_pdo();
+
+// Sanitize input
+$raw = isset($_GET['aID']) && is_array($_GET['aID']) ? $_GET['aID'] : [];
+$ids = [];
+foreach ($raw as $v) {
+    $v = (int)$v;
+    if ($v > 0) $ids[] = $v;
+}
+$ids = array_unique($ids);
+sort($ids);
+
+if (empty($ids)) {
+    header('Location: index.php?error=no_selection');
+    exit;
+}
+
+// Validate IDs exist in DB
+$placeholders = implode(',', array_fill(0, count($ids), '?'));
+$stmt = $pdo->prepare("SELECT activityID FROM activities WHERE activityID IN ($placeholders)");
+$stmt->execute($ids);
+$validIds = $stmt->fetchAll(PDO::FETCH_COLUMN);
+
+if (empty($validIds)) {
+    header('Location: index.php?error=invalid_selection');
+    exit;
+}
+sort($validIds);
+$idsStr = implode(',', $validIds);
+
+// Generate deterministic hash from the sorted activity IDs
+$hash = hash('sha256', $idsStr);
+
+// Check if this exact selection already has a hash stored
+$stmt = $pdo->prepare("SELECT hash FROM selection_hashes WHERE activity_ids = ?");
+$stmt->execute([$idsStr]);
+$existing = $stmt->fetchColumn();
+
+if ($existing) {
+    $hash = $existing;
+} else {
+    // Store new hash
+    $stmt = $pdo->prepare("INSERT INTO selection_hashes (hash, activity_ids) VALUES (?, ?)");
+    $stmt->execute([$hash, $idsStr]);
+}
+
+// Redirect to the shareable packing list URL
+header("Location: packing.php?h=" . urlencode($hash));
+exit;

+ 422 - 0
packing.php

@@ -0,0 +1,422 @@
+<?php
+ini_set('display_errors', 1); ini_set('display_startup_errors', 1); error_reporting(E_ALL);
+// packing.php – displays the packing list for a given hash
+require_once __DIR__ . '/db.php';
+
+$pdo = get_pdo();
+
+$hash = isset($_GET['h']) ? trim($_GET['h']) : '';
+if (!preg_match('/^[a-f0-9]{64}$/', $hash)) {
+    http_response_code(400);
+    die('<p>Invalid or missing list hash.</p>');
+}
+
+// Resolve hash → activity IDs
+$stmt = $pdo->prepare("SELECT activity_ids FROM selection_hashes WHERE hash = ?");
+$stmt->execute([$hash]);
+$row = $stmt->fetch();
+if (!$row) {
+    http_response_code(404);
+    die('<p>List not found. It may have expired or the link is wrong.</p>');
+}
+
+$activityIds = array_map('intval', explode(',', $row['activity_ids']));
+if (empty($activityIds)) {
+    die('<p>No activities stored for this hash.</p>');
+}
+
+// Fetch the selected activity names for display
+$ph = implode(',', array_fill(0, count($activityIds), '?'));
+$stmt = $pdo->prepare("
+    SELECT a.activityName
+    FROM activities a
+    WHERE a.activityID IN ($ph)
+    ORDER BY a.activityName
+");
+$stmt->execute($activityIds);
+$activityNames = $stmt->fetchAll(PDO::FETCH_COLUMN);
+
+// Get all items for these activities, deduplicated, grouped by item_group
+$stmt = $pdo->prepare("
+    SELECT DISTINCT
+        ig.groupID,
+        ig.groupName,
+        ig.sortOrder  AS groupSort,
+        i.itemID,
+        i.itemName,
+        i.sortOrder   AS itemSort
+    FROM activity_item_map aim
+    JOIN items i          ON i.itemID   = aim.itemID
+    JOIN item_groups ig   ON ig.groupID = i.groupID
+    WHERE aim.activityID IN ($ph)
+    ORDER BY ig.sortOrder, ig.groupID, i.sortOrder, i.itemID
+");
+$stmt->execute($activityIds);
+$allItems = $stmt->fetchAll();
+
+// Group items by item_group
+$grouped = [];
+foreach ($allItems as $item) {
+    $gid = $item['groupID'];
+    if (!isset($grouped[$gid])) {
+        $grouped[$gid] = ['name' => $item['groupName'], 'items' => []];
+    }
+    $grouped[$gid]['items'][] = ['id' => $item['itemID'], 'name' => $item['itemName']];
+}
+
+$pageUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
+           . '://' . $_SERVER['HTTP_HOST']
+           . $_SERVER['REQUEST_URI'];
+$totalItems = count($allItems);
+?>
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>PackIt – Your Packing List</title>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>
+  :root {
+    --bg:       #0f1117;
+    --surface:  #181c27;
+    --border:   #2a2f3f;
+    --accent:   #e8c547;
+    --accent2:  #4fd1c5;
+    --text:     #e8eaf0;
+    --muted:    #7a8099;
+    --green:    #5cb85c;
+    --radius:   10px;
+    --gap:      1.5rem;
+  }
+  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+  body {
+    background: var(--bg);
+    color: var(--text);
+    font-family: 'DM Sans', sans-serif;
+    font-size: 15px;
+    min-height: 100vh;
+    display: flex;
+    flex-direction: column;
+  }
+  header {
+    padding: 2rem var(--gap) 1.5rem;
+    border-bottom: 1px solid var(--border);
+    display: flex;
+    align-items: flex-end;
+    justify-content: space-between;
+    gap: 1rem;
+    flex-wrap: wrap;
+    position: relative;
+    overflow: hidden;
+  }
+  header::before {
+    content: '';
+    position: absolute;
+    inset: 0;
+    background: radial-gradient(ellipse 60% 80% at 90% 50%, rgba(79,209,197,.06) 0%, transparent 70%);
+    pointer-events: none;
+  }
+  .header-left { display: flex; align-items: flex-end; gap: 1rem; position: relative; }
+  .logo {
+    font-family: 'Bebas Neue', sans-serif;
+    font-size: clamp(2.2rem, 5vw, 3.5rem);
+    letter-spacing: .04em;
+    color: var(--accent);
+    line-height: 1;
+  }
+  .header-meta {
+    font-size: .8rem;
+    color: var(--muted);
+    padding-bottom: .2rem;
+  }
+  .header-meta strong { color: var(--accent2); }
+
+  .btn-row {
+    display: flex;
+    gap: .6rem;
+    flex-wrap: wrap;
+    position: relative;
+  }
+  .btn {
+    display: inline-flex; align-items: center; gap: .5rem;
+    padding: .55rem 1.1rem;
+    border-radius: var(--radius);
+    font-family: 'DM Sans', sans-serif;
+    font-size: .82rem;
+    font-weight: 500;
+    letter-spacing: .03em;
+    cursor: pointer;
+    border: 1.5px solid transparent;
+    transition: all .15s;
+    text-decoration: none;
+  }
+  .btn-accent {
+    background: var(--accent); color: #0f1117; border-color: var(--accent);
+  }
+  .btn-accent:hover { background: #f0d260; }
+  .btn-outline {
+    background: transparent; color: var(--text); border-color: var(--border);
+  }
+  .btn-outline:hover { border-color: var(--muted); }
+  .btn-teal {
+    background: transparent; color: var(--accent2); border-color: var(--accent2);
+  }
+  .btn-teal:hover { background: rgba(79,209,197,.08); }
+
+  main {
+    flex: 1;
+    max-width: 760px;
+    width: 100%;
+    margin: 0 auto;
+    padding: 2.5rem var(--gap) 3rem;
+  }
+
+  /* Activities pills */
+  .activity-pills {
+    display: flex;
+    flex-wrap: wrap;
+    gap: .4rem;
+    margin-bottom: 2.5rem;
+  }
+  .pill {
+    background: rgba(232,197,71,.1);
+    border: 1px solid rgba(232,197,71,.3);
+    color: var(--accent);
+    font-size: .75rem;
+    font-weight: 500;
+    padding: .3rem .75rem;
+    border-radius: 999px;
+    letter-spacing: .03em;
+  }
+
+  /* Progress bar */
+  .progress-bar-wrap {
+    background: var(--surface);
+    border: 1px solid var(--border);
+    border-radius: var(--radius);
+    padding: 1rem 1.25rem;
+    margin-bottom: 2.5rem;
+    display: flex;
+    align-items: center;
+    gap: 1.5rem;
+  }
+  .progress-track {
+    flex: 1;
+    height: 6px;
+    background: var(--border);
+    border-radius: 999px;
+    overflow: hidden;
+  }
+  .progress-fill {
+    height: 100%;
+    background: linear-gradient(90deg, var(--accent2), var(--accent));
+    border-radius: 999px;
+    width: 0%;
+    transition: width .3s ease;
+  }
+  .progress-label {
+    font-size: .82rem;
+    color: var(--muted);
+    white-space: nowrap;
+  }
+  .progress-label strong { color: var(--accent); font-variant-numeric: tabular-nums; }
+
+  .section-title {
+    font-family: 'Bebas Neue', sans-serif;
+    font-size: 1.1rem;
+    letter-spacing: .12em;
+    color: var(--accent2);
+    text-transform: uppercase;
+    margin-bottom: .75rem;
+    display: flex;
+    align-items: center;
+    gap: .6rem;
+  }
+  .section-title::after {
+    content: '';
+    flex: 1;
+    height: 1px;
+    background: var(--border);
+  }
+
+  .item-group {
+    margin-bottom: 2rem;
+  }
+
+  .item-list {
+    display: flex;
+    flex-direction: column;
+    gap: .4rem;
+  }
+
+  .item-row {
+    display: flex;
+    align-items: center;
+    gap: .75rem;
+    padding: .6rem 1rem;
+    background: var(--surface);
+    border: 1.5px solid var(--border);
+    border-radius: var(--radius);
+    cursor: pointer;
+    transition: border-color .15s, background .15s;
+    user-select: none;
+  }
+  .item-row:hover { background: #1e2333; }
+  .item-row.checked {
+    border-color: rgba(92,184,92,.5);
+    background: rgba(92,184,92,.05);
+  }
+  .item-row.checked .item-name {
+    text-decoration: line-through;
+    color: var(--muted);
+  }
+  .item-check {
+    width: 20px; height: 20px;
+    border: 1.5px solid var(--border);
+    border-radius: 5px;
+    flex-shrink: 0;
+    display: flex; align-items: center; justify-content: center;
+    background: var(--bg);
+    transition: all .15s;
+  }
+  .item-row.checked .item-check {
+    background: var(--green);
+    border-color: var(--green);
+  }
+  .item-check svg { display: none; }
+  .item-row.checked .item-check svg { display: block; }
+  .item-name { flex: 1; font-size: .95rem; }
+
+  /* Share box */
+  .share-box {
+    background: var(--surface);
+    border: 1.5px solid var(--border);
+    border-radius: var(--radius);
+    padding: 1.25rem 1.5rem;
+    margin-bottom: 2.5rem;
+    display: flex;
+    gap: .75rem;
+    align-items: center;
+    flex-wrap: wrap;
+  }
+  .share-url {
+    flex: 1;
+    font-size: .8rem;
+    color: var(--muted);
+    font-family: monospace;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    min-width: 0;
+  }
+  .copy-feedback {
+    font-size: .8rem;
+    color: var(--green);
+    opacity: 0;
+    transition: opacity .3s;
+  }
+  .copy-feedback.show { opacity: 1; }
+
+  @media print {
+    header .btn-row, .share-box, .progress-bar-wrap { display: none !important; }
+    body { background: #fff; color: #000; }
+    .item-row { border-color: #ccc; background: none; }
+    .section-title { color: #333; }
+    .pill { background: #f5f5f5; color: #333; border-color: #ccc; }
+  }
+</style>
+</head>
+<body>
+
+<header>
+  <div class="header-left">
+    <div class="logo">PackIt</div>
+    <div class="header-meta">
+      <strong><?= $totalItems ?></strong> items &nbsp;·&nbsp;
+      <strong><?= count($activityNames) ?></strong> activities
+    </div>
+  </div>
+  <div class="btn-row">
+    <a href="index.php" class="btn btn-outline">← New list</a>
+    <button class="btn btn-teal" onclick="window.print()">🖨 Print</button>
+    <button class="btn btn-accent" onclick="copyLink()">🔗 Copy link</button>
+  </div>
+</header>
+
+<main>
+  <!-- Activity pills -->
+  <div class="activity-pills">
+    <?php foreach ($activityNames as $n): ?>
+    <span class="pill"><?= htmlspecialchars($n) ?></span>
+    <?php endforeach; ?>
+  </div>
+
+  <!-- Share box -->
+  <div class="share-box">
+    <span class="share-url" id="shareUrl"><?= htmlspecialchars($pageUrl) ?></span>
+    <span class="copy-feedback" id="copyFb">Copied!</span>
+    <button class="btn btn-teal" onclick="copyLink()">Copy link</button>
+  </div>
+
+  <!-- Progress -->
+  <div class="progress-bar-wrap">
+    <div class="progress-track"><div class="progress-fill" id="progressFill"></div></div>
+    <div class="progress-label"><strong id="checkedCnt">0</strong> / <?= $totalItems ?> packed</div>
+  </div>
+
+  <!-- Grouped item list -->
+  <?php foreach ($grouped as $gid => $group): ?>
+  <div class="item-group">
+    <div class="section-title"><?= htmlspecialchars($group['name']) ?></div>
+    <div class="item-list">
+      <?php foreach ($group['items'] as $item): ?>
+      <div class="item-row" onclick="toggleItem(this)">
+        <div class="item-check">
+          <svg width="10" height="8" viewBox="0 0 10 8" fill="none">
+            <path d="M1 4l3 3 5-6" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+          </svg>
+        </div>
+        <span class="item-name"><?= htmlspecialchars($item['name']) ?></span>
+      </div>
+      <?php endforeach; ?>
+    </div>
+  </div>
+  <?php endforeach; ?>
+</main>
+
+<script>
+const total = <?= $totalItems ?>;
+
+function toggleItem(el) {
+  el.classList.toggle('checked');
+  updateProgress();
+}
+
+function updateProgress() {
+  const checked = document.querySelectorAll('.item-row.checked').length;
+  document.getElementById('checkedCnt').textContent = checked;
+  document.getElementById('progressFill').style.width = (checked / total * 100) + '%';
+}
+
+function copyLink() {
+  const url = document.getElementById('shareUrl').textContent;
+  navigator.clipboard.writeText(url).then(() => {
+    const fb = document.getElementById('copyFb');
+    fb.classList.add('show');
+    setTimeout(() => fb.classList.remove('show'), 2000);
+  }).catch(() => {
+    // Fallback
+    const ta = document.createElement('textarea');
+    ta.value = url;
+    document.body.appendChild(ta);
+    ta.select();
+    document.execCommand('copy');
+    document.body.removeChild(ta);
+  });
+}
+</script>
+</body>
+</html>

+ 248 - 0
schema.sql

@@ -0,0 +1,248 @@
+-- PackIt Database Schema
+-- Run this once to set up all required tables.
+
+CREATE DATABASE IF NOT EXISTS packit CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+USE packit;
+
+-- Activity groups (categories)
+CREATE TABLE IF NOT EXISTS activity_groups (
+    groupID   INT AUTO_INCREMENT PRIMARY KEY,
+    groupName VARCHAR(100) NOT NULL,
+    sortOrder INT DEFAULT 0
+);
+
+-- Activities
+CREATE TABLE IF NOT EXISTS activities (
+    activityID   INT AUTO_INCREMENT PRIMARY KEY,
+    groupID      INT NOT NULL,
+    activityName VARCHAR(150) NOT NULL,
+    sortOrder    INT DEFAULT 0,
+    FOREIGN KEY (groupID) REFERENCES activity_groups(groupID) ON DELETE CASCADE
+);
+
+-- Item groups
+CREATE TABLE IF NOT EXISTS item_groups (
+    groupID   INT AUTO_INCREMENT PRIMARY KEY,
+    groupName VARCHAR(100) NOT NULL,
+    sortOrder INT DEFAULT 0
+);
+
+-- Items
+CREATE TABLE IF NOT EXISTS items (
+    itemID    INT AUTO_INCREMENT PRIMARY KEY,
+    groupID   INT NOT NULL,
+    itemName  VARCHAR(150) NOT NULL,
+    sortOrder INT DEFAULT 0,
+    FOREIGN KEY (groupID) REFERENCES item_groups(groupID) ON DELETE CASCADE
+);
+
+-- Relation between activities and items
+CREATE TABLE IF NOT EXISTS activity_item_map (
+    activityID INT NOT NULL,
+    itemID     INT NOT NULL,
+    PRIMARY KEY (activityID, itemID),
+    FOREIGN KEY (activityID) REFERENCES activities(activityID) ON DELETE CASCADE,
+    FOREIGN KEY (itemID)     REFERENCES items(itemID)          ON DELETE CASCADE
+);
+
+-- Stored selection hashes (for shareable URLs)
+CREATE TABLE IF NOT EXISTS selection_hashes (
+    hashID       INT AUTO_INCREMENT PRIMARY KEY,
+    hash         CHAR(64) NOT NULL UNIQUE,
+    activity_ids TEXT NOT NULL,
+    created_at   TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Admin users
+CREATE TABLE IF NOT EXISTS admin_users (
+    adminID       INT AUTO_INCREMENT PRIMARY KEY,
+    username      VARCHAR(50)  NOT NULL UNIQUE,
+    password_hash VARCHAR(255) NOT NULL
+);
+
+-- -----------------------------------------------
+-- Sample data
+-- -----------------------------------------------
+
+INSERT INTO activity_groups (groupName, sortOrder) VALUES
+('Overnight',        1),   -- groupID 1
+('Rescue & Safety',  2),   -- groupID 2
+('Water Activities', 3),   -- groupID 3
+('Winter Sports',    4);   -- groupID 4
+
+-- activityID 1=Camping 2=Hiking 3=Bivouac 4=First Aid 5=Mountain Rescue
+-- activityID 6=Kayaking 7=Swimming 8=Skiing 9=Snowshoeing
+INSERT INTO activities (groupID, activityName, sortOrder) VALUES
+(1, 'Camping',         1),
+(1, 'Hiking',          2),
+(1, 'Bivouac',         3),
+(2, 'First Aid',       1),
+(2, 'Mountain Rescue', 2),
+(3, 'Kayaking',        1),
+(3, 'Swimming',        2),
+(4, 'Skiing',          1),
+(4, 'Snowshoeing',     2);
+
+INSERT INTO item_groups (groupName, sortOrder) VALUES
+('Clothing',     1),   -- groupID 1
+('Shelter',      2),   -- groupID 2
+('Navigation',   3),   -- groupID 3
+('Medical',      4),   -- groupID 4
+('Water & Food', 5),   -- groupID 5
+('Safety Gear',  6);   -- groupID 6
+
+-- Items with their resulting auto_increment IDs:
+--  1  Base layer top        (Clothing)
+--  2  Base layer bottom     (Clothing)
+--  3  Fleece jacket         (Clothing)
+--  4  Rain jacket           (Clothing)
+--  5  Gloves                (Clothing)
+--  6  Warm hat              (Clothing)
+--  7  Hiking boots          (Clothing)
+--  8  Wetsuit               (Clothing)
+--  9  Ski jacket            (Clothing)
+-- 10  Ski pants             (Clothing)
+-- 11  Ski boots             (Clothing)
+-- 12  Tent                  (Shelter)
+-- 13  Sleeping bag          (Shelter)
+-- 14  Sleeping mat          (Shelter)
+-- 15  Bivouac bag           (Shelter)
+-- 16  Topographic map       (Navigation)
+-- 17  Compass               (Navigation)
+-- 18  GPS device            (Navigation)
+-- 19  First aid kit         (Medical)
+-- 20  Emergency blanket     (Medical)
+-- 21  Tourniquet            (Medical)
+-- 22  SAM splint            (Medical)
+-- 23  Water bottle          (Water & Food)
+-- 24  Water filter          (Water & Food)
+-- 25  Trail snacks          (Water & Food)
+-- 26  Stove & fuel          (Water & Food)
+-- 27  Cooking pot           (Water & Food)
+-- 28  Headlamp + batteries  (Safety Gear)
+-- 29  Whistle               (Safety Gear)
+-- 30  Rope (30m)            (Safety Gear)
+-- 31  Avalanche probe       (Safety Gear)
+-- 32  Avalanche transceiver (Safety Gear)
+-- 33  Shovel                (Safety Gear)
+-- 34  Life jacket           (Safety Gear)
+-- 35  Paddle float          (Safety Gear)
+
+INSERT INTO items (groupID, itemName, sortOrder) VALUES
+(1, 'Base layer top',             1),
+(1, 'Base layer bottom',          2),
+(1, 'Fleece jacket',              3),
+(1, 'Rain jacket',                4),
+(1, 'Gloves',                     5),
+(1, 'Warm hat',                   6),
+(1, 'Hiking boots',               7),
+(1, 'Wetsuit',                    8),
+(1, 'Ski jacket',                 9),
+(1, 'Ski pants',                 10),
+(1, 'Ski boots',                 11),
+(2, 'Tent',                       1),
+(2, 'Sleeping bag',               2),
+(2, 'Sleeping mat',               3),
+(2, 'Bivouac bag',                4),
+(3, 'Topographic map',            1),
+(3, 'Compass',                    2),
+(3, 'GPS device',                 3),
+(4, 'First aid kit',              1),
+(4, 'Emergency blanket',          2),
+(4, 'Tourniquet',                 3),
+(4, 'SAM splint',                 4),
+(5, 'Water bottle',               1),
+(5, 'Water filter',               2),
+(5, 'Trail snacks',               3),
+(5, 'Stove & fuel',               4),
+(5, 'Cooking pot',                5),
+(6, 'Headlamp + spare batteries', 1),
+(6, 'Whistle',                    2),
+(6, 'Rope (30m)',                 3),
+(6, 'Avalanche probe',            4),
+(6, 'Avalanche transceiver',      5),
+(6, 'Shovel',                     6),
+(6, 'Life jacket',                7),
+(6, 'Paddle float',               8);
+
+-- -----------------------------------------------
+-- Activity -> Item mappings (all IDs verified)
+-- -----------------------------------------------
+
+-- activityID 1: Camping
+INSERT INTO activity_item_map (activityID, itemID) VALUES
+(1, 1),(1, 2),(1, 3),(1, 4),(1, 7),
+(1,12),(1,13),(1,14),
+(1,16),(1,17),
+(1,19),(1,20),
+(1,23),(1,24),(1,25),(1,26),(1,27),
+(1,28),(1,29);
+
+-- activityID 2: Hiking
+INSERT INTO activity_item_map (activityID, itemID) VALUES
+(2, 1),(2, 2),(2, 3),(2, 4),(2, 7),
+(2,16),(2,17),(2,18),
+(2,19),(2,20),
+(2,23),(2,24),(2,25),
+(2,28),(2,29);
+
+-- activityID 3: Bivouac
+INSERT INTO activity_item_map (activityID, itemID) VALUES
+(3, 1),(3, 2),(3, 3),(3, 4),(3, 5),(3, 6),(3, 7),
+(3,14),(3,15),
+(3,16),(3,17),
+(3,19),(3,20),
+(3,23),(3,25),
+(3,28),(3,29);
+
+-- activityID 4: First Aid
+INSERT INTO activity_item_map (activityID, itemID) VALUES
+(4,19),(4,20),(4,21),(4,22),
+(4,28),(4,29);
+
+-- activityID 5: Mountain Rescue
+INSERT INTO activity_item_map (activityID, itemID) VALUES
+(5, 1),(5, 3),(5, 4),(5, 5),(5, 6),
+(5,16),(5,17),(5,18),
+(5,19),(5,20),(5,21),(5,22),
+(5,28),(5,29),(5,30);
+
+-- activityID 6: Kayaking
+INSERT INTO activity_item_map (activityID, itemID) VALUES
+(6, 1),(6, 4),(6, 8),
+(6,19),(6,20),
+(6,23),(6,24),
+(6,28),(6,29),
+(6,34),(6,35);
+
+-- activityID 7: Swimming
+INSERT INTO activity_item_map (activityID, itemID) VALUES
+(7, 8),
+(7,19),(7,20),
+(7,34);
+
+-- activityID 8: Skiing
+INSERT INTO activity_item_map (activityID, itemID) VALUES
+(8, 1),(8, 2),(8, 5),(8, 6),(8, 9),(8,10),(8,11),
+(8,16),(8,17),(8,18),
+(8,19),(8,20),
+(8,23),(8,25),
+(8,28),(8,29),
+(8,31),(8,32),(8,33);
+
+-- activityID 9: Snowshoeing
+INSERT INTO activity_item_map (activityID, itemID) VALUES
+(9, 1),(9, 2),(9, 3),(9, 4),(9, 5),(9, 6),(9, 7),
+(9,16),(9,17),
+(9,19),(9,20),
+(9,23),(9,24),(9,25),
+(9,28),(9,29),
+(9,31);
+
+-- -----------------------------------------------
+-- Default admin user
+-- Username: admin   Password: packit-admin
+-- Change the password after first login!
+-- -----------------------------------------------
+INSERT INTO admin_users (username, password_hash) VALUES
+('admin', '$2y$12$eTfGi/R5IxHH5NQxrVe9.OijNfyFfpvT/X1i9aYgqZPRF6.Gu72hC');