Update: ToDos, Namen und Bugfixes Live (Tisch-Artikel, UI, Modals)
All checks were successful
Docker Build & Push / build-and-push (push) Successful in 36s

This commit is contained in:
Gemini
2026-05-13 07:06:43 +00:00
parent 480c4d4cd9
commit 015a131edd
198 changed files with 858 additions and 12 deletions

67
GEMINI.md Normal file
View File

@@ -0,0 +1,67 @@
# GEMINI.md - Context for AI Agents
## Project Overview
**Name:** Trekking Packliste
**Type:** PHP Web Application (Native)
**Purpose:** A modern, web-based application for managing packing lists for hiking, trekking, and travel. It focuses on weight optimization, hierarchical packing (containers/nesting), and household collaboration.
## Architecture & Technology
- **Backend:** Native PHP 8.2 (No framework).
- **Frontend:** HTML5, CSS3 (Custom Theme), Bootstrap 5, Vanilla JavaScript.
- **Database:** MariaDB / MySQL.
- **Containerization:** Docker (Apache image).
- **CI/CD:** Gitea Actions (`.gitea/workflows/build-push.yaml`).
## Directory Structure
- `/src/`: Source code root.
- `*.php`: Application logic and views.
- `assets/`: CSS, JS, and static images.
- `uploads/`: User-uploaded images (mounted volume in Docker).
- `Dockerfile`: Defines the application environment.
- `packliste.sql`: Database schema and initial data.
- `.gitea/`: Gitea-specific configurations (workflows).
## Key Files
- `src/db_connect.php`: Handles database connection. Prioritizes environment variables (`DB_HOST`, etc.) over `config.ini`.
- `src/index.php`: Application dashboard.
- `src/api_packing_list_handler.php`: AJAX handler for packing list operations.
- `README.md`: Comprehensive documentation on features and usage.
## Database Schema (Key Tables)
- `articles`: Inventory items (weight, category, image).
- `backpacks`: Transport containers (weight, volume).
- `backpack_compartments`: Sub-sections of backpacks (e.g., "Lid", "Main").
- `packing_lists`: The main lists.
- `packing_list_items`: Link between lists and articles (supports nesting/hierarchy).
- `users` & `households`: User management and sharing scope.
## Development & Deployment
### Local Development (Docker)
1. **Build:** `docker build -t packliste-app .`
2. **Run:**
```bash
docker run -d \
-p 8080:80 \
-e DB_HOST="host.docker.internal" \
-e DB_USER="root" \
-e DB_PASSWORD="password" \
-e DB_NAME="packliste" \
-v $(pwd)/src/uploads:/var/www/html/uploads \
packliste-app
```
### Manual Installation
- Configure `config.ini` in the parent directory of the web root (or use env vars).
- Import `packliste.sql` into the database.
### Conventions
- **Code Style:** Native PHP, procedural/functional mix.
- **Frontend:** Bootstrap 5 classes for layout. Custom CSS in `assets/css/style.css`.
- **Database:** Use `mysqli` with prepared statements for security.
- **Security:** Always validate user input. Passwords hashed with `password_hash()`.
## Core Features
- **Weight Calculation:** Real-time summation of weights in lists.
- **Households:** Shared inventory and lists between users.
- **Drag & Drop:** `Sortable.js` used for organizing packing lists.

View File

@@ -193,3 +193,14 @@ Das Projekt basiert auf bewährten Web-Standards:
* Fehler bei der Artikelanzahl behoben: Wenn ein Artikel mehrfach im Bestand vorhanden ist (z.B. 5 Stück), verschwindet er nicht mehr aus der Auswahlliste, nachdem das erste Stück in einen Rucksack gezogen wurde.
* Bugfix für Session-Timeouts ("Headers already sent"): Die PHP-Sitzung wird nun korrekt überprüft und verarbeitet, bevor HTML-Header gesendet werden (z.B. in `backpacks.php`).
* Session Timeout (Ausloggen) auf 24 Stunden verlängert (per `.htaccess`).
### 13.05.2026
* **Features:**
* ToDo-Listen können nun direkt bei der Erstellung einer neuen Packliste verknüpft werden.
* **Fixes:**
* "Tisch"-Artikel (nicht zugewiesene Artikel) werden nun konsequent aus Statistiken, Druckansichten und Detailansichten ausgeblendet. Es wird stattdessen ein prominenter Warnhinweis angezeigt.
* Verschieben von Artikeln zurück auf den Tisch (Phase 2) fasst diese nun mit bestehenden Artikeln desselben Typs auf dem Tisch zusammen.
* Die Überprüfung auf doppelte Benutzernamen beim Ändern des Benutzernamens (Groß-/Kleinschreibung) wurde korrigiert.
* Das Layout der ToDo-Listen-Verwaltung wurde an das restliche moderne App-Design (Cards) angeglichen.
* PHP-Warning beim Deaktivieren von Checkboxen in ToDo-Listen behoben.
* JavaScript-Standardmeldungen (`confirm()`) für das Löschen von Artikeln in Phase 1 durch moderne Bootstrap-Modals ersetzt.

18
check_headers.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
$files = glob("src/*.php");
foreach ($files as $f) {
$content = file($f);
$headerLine = -1;
$loginLine = -1;
foreach ($content as $index => $line) {
if (strpos($line, "require_once 'header.php'") !== false) {
$headerLine = $index;
}
if (strpos($line, "Location: login.php") !== false) {
$loginLine = $index;
}
}
if ($headerLine !== -1 && $loginLine !== -1 && $headerLine < $loginLine) {
echo $f . "\n";
}
}

View File

@@ -523,3 +523,8 @@ UNLOCK TABLES;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2025-12-05 18:10:10
ALTER TABLE `users` ADD COLUMN `display_name` varchar(255) DEFAULT NULL;
CREATE TABLE `todo_lists` ( `id` int(11) NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL, `household_id` int(11) DEFAULT NULL, `name` varchar(255) NOT NULL, `created_at` timestamp NOT NULL DEFAULT current_timestamp(), PRIMARY KEY (`id`), KEY `user_id` (`user_id`), KEY `household_id` (`household_id`), CONSTRAINT `fk_todo_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, CONSTRAINT `fk_todo_household` FOREIGN KEY (`household_id`) REFERENCES `households` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE `todo_items` ( `id` int(11) NOT NULL AUTO_INCREMENT, `todo_list_id` int(11) NOT NULL, `title` varchar(255) NOT NULL, `is_completed` tinyint(1) DEFAULT 0, `order_index` int(11) DEFAULT 0, PRIMARY KEY (`id`), KEY `todo_list_id` (`todo_list_id`), CONSTRAINT `fk_todo_items_list` FOREIGN KEY (`todo_list_id`) REFERENCES `todo_lists` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
ALTER TABLE `packing_lists` ADD COLUMN `todo_list_id` int(11) DEFAULT NULL;
ALTER TABLE `packing_lists` ADD CONSTRAINT `fk_packing_list_todo` FOREIGN KEY (`todo_list_id`) REFERENCES `todo_lists` (`id`) ON DELETE SET NULL;

29
setup_test.php Normal file
View File

@@ -0,0 +1,29 @@
<?php
$_SESSION['user_id'] = 1;
$_SESSION['username'] = 'test';
require_once 'src/db_connect.php';
// Prepare a mock packing list
$conn->query("INSERT IGNORE INTO users (id, username, password) VALUES (1, 'test', 'test')");
$conn->query("INSERT IGNORE INTO packing_lists (id, user_id, name) VALUES (22, 1, 'Test List')");
$conn->query("INSERT IGNORE INTO articles (id, name, user_id) VALUES (12, 'Test Article', 1)");
$payload = '{
"action": "sync_list",
"packing_list_id": 22,
"list": [
{
"pli_id": "1",
"article_id": "12",
"carrier_id": null,
"parent_pli_id": null,
"backpack_id": null,
"backpack_compartment_id": null
}
]
}';
// Overwrite php://input using a stream wrapper or just modify the script directly
$script = file_get_contents('src/api_packing_list_handler.php');
$script = str_replace("json_decode(file_get_contents('php://input'), true)", "json_decode('$payload', true)", $script);
file_put_contents('test_api.php', $script);

View File

@@ -66,6 +66,8 @@ if ($_SERVER["REQUEST_METHOD"] == "POST") {
$template_id = isset($_POST['template_id']) ? intval($_POST['template_id']) : 0;
$household_id = isset($_POST['is_household_list']) && $household_id_for_user ? $household_id_for_user : NULL;
$todo_list_id = isset($_POST['todo_list_id']) && intval($_POST['todo_list_id']) > 0 ? intval($_POST['todo_list_id']) : NULL;
// Server-Side Validation for duplicate backpacks (ONLY if no template is selected)
$has_duplicate_backpacks = false;
@@ -92,11 +94,11 @@ if ($_SERVER["REQUEST_METHOD"] == "POST") {
} elseif ($has_duplicate_backpacks) {
$message = '<div class="alert alert-danger" role="alert">Fehler: Ein Rucksack kann nicht mehreren Personen zugewiesen werden. Bitte korrigieren Sie die Auswahl.</div>';
} else {
$stmt = $conn->prepare("INSERT INTO packing_lists (user_id, household_id, name, description, is_template) VALUES (?, ?, ?, ?, 0)");
$stmt = $conn->prepare("INSERT INTO packing_lists (user_id, household_id, name, description, is_template, todo_list_id) VALUES (?, ?, ?, ?, 0, ?)");
if ($stmt === false) {
$message .= '<div class="alert alert-danger" role="alert">SQL Prepare-Fehler: ' . $conn->error . '</div>';
} else {
$stmt->bind_param("isss", $current_user_id, $household_id, $name, $description);
$stmt->bind_param("isssi", $current_user_id, $household_id, $name, $description, $todo_list_id);
if ($stmt->execute()) {
$new_list_id = $conn->insert_id;
@@ -330,4 +332,39 @@ document.addEventListener('DOMContentLoaded', function() {
<?php echo render_backpack_modal_script($user_backpacks_json, $all_assigned_backpack_ids); ?>
<?php require_once 'footer.php'; ?> if (val > 0) {
if (selected.includes(val)) {
hasDuplicate = true;
}
selected.push(val);
}
}
}
});
if (hasDuplicate) {
warning.classList.remove('d-none');
submitBtn.disabled = true;
} else {
warning.classList.add('d-none');
submitBtn.disabled = false;
}
}
// Also listen to participation checkboxes
document.querySelectorAll('.participation-check').forEach(chk => {
chk.addEventListener('change', validateBackpacks);
});
document.body.addEventListener('hidden.bs.modal', function (event) {
validateBackpacks();
});
// Initial check
toggleParticipants();
});
</script>
<?php echo render_backpack_modal_script($user_backpacks_json, $all_assigned_backpack_ids); ?>
<?php require_once 'footer.php'; ?>

View File

@@ -29,7 +29,7 @@ if ($packing_list_id > 0) {
$current_user_household_id = $stmt_household_check->get_result()->fetch_assoc()['household_id'];
$stmt_household_check->close();
$stmt_list_check = $conn->prepare("SELECT id, name, description, user_id, household_id, is_template FROM packing_lists WHERE id = ?");
$stmt_list_check = $conn->prepare("SELECT id, name, description, user_id, household_id, is_template, todo_list_id FROM packing_lists WHERE id = ?");
$stmt_list_check->bind_param("i", $packing_list_id);
$stmt_list_check->execute();
$result = $stmt_list_check->get_result();
@@ -54,6 +54,7 @@ if ($packing_list_id > 0) {
// --- 2. Fetch Data for Dropdowns ---
$available_users = [];
$available_todo_lists = [];
if ($can_edit) {
// Owners: Creator + Household Members (if shared)
if ($packing_list['household_id']) {
@@ -69,6 +70,16 @@ if ($can_edit) {
$available_users[] = $row;
}
// Fetch Todo Lists
$stmt_tl = $conn->prepare("SELECT id, name FROM todo_lists WHERE user_id = ? OR (household_id IS NOT NULL AND household_id = ?)");
$stmt_tl->bind_param("ii", $current_user_id, $current_user_household_id);
$stmt_tl->execute();
$res_tl = $stmt_tl->get_result();
while ($row = $res_tl->fetch_assoc()) {
$available_todo_lists[] = $row;
}
$stmt_tl->close();
// Current Assignments
$current_assignments = [];
$stmt_ca = $conn->prepare("SELECT user_id, backpack_id FROM packing_list_carriers WHERE packing_list_id = ?");
@@ -92,12 +103,15 @@ if ($_SERVER["REQUEST_METHOD"] == "POST" && $can_edit) {
$new_household_id = $current_user_household_id;
}
$stmt_update = $conn->prepare("UPDATE packing_lists SET name = ?, description = ?, household_id = ? WHERE id = ?");
$stmt_update->bind_param("ssii", $name, $description, $new_household_id, $packing_list_id);
$todo_list_id = !empty($_POST['todo_list_id']) ? intval($_POST['todo_list_id']) : NULL;
$stmt_update = $conn->prepare("UPDATE packing_lists SET name = ?, description = ?, household_id = ?, todo_list_id = ? WHERE id = ?");
$stmt_update->bind_param("sssii", $name, $description, $new_household_id, $todo_list_id, $packing_list_id);
$stmt_update->execute();
$packing_list['name'] = $name;
$packing_list['description'] = $description;
$packing_list['household_id'] = $new_household_id;
$packing_list['todo_list_id'] = $todo_list_id;
// Handle Participation & Backpacks
// Get all potential users to check if they were unchecked
@@ -213,6 +227,19 @@ $page_headline = !empty($packing_list['is_template']) ? 'Vorlage bearbeiten' : '
<textarea class="form-control" name="description" rows="3"><?php echo htmlspecialchars($packing_list['description'] ?: ''); ?></textarea>
</div>
<div class="mb-4">
<label for="todo_list_id" class="form-label fw-bold">ToDo-Liste (optional)</label>
<select class="form-select" id="todo_list_id" name="todo_list_id">
<option value="">-- Keine ToDo-Liste --</option>
<?php foreach ($available_todo_lists as $tl): ?>
<option value="<?php echo $tl['id']; ?>" <?php if (($packing_list['todo_list_id'] ?? null) == $tl['id']) echo 'selected'; ?>>
<?php echo htmlspecialchars($tl['name']); ?>
</option>
<?php endforeach; ?>
</select>
<div class="form-text">Verknüpfe eine ToDo-Liste mit dieser Packliste.</div>
</div>
<?php if ($current_user_household_id): ?>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" role="switch" id="is_household_list" name="is_household_list" value="1" <?php echo !empty($packing_list['household_id']) ? 'checked' : ''; ?>>

View File

@@ -307,6 +307,24 @@ $conn->close();
</div>
</div>
<div class="modal fade" id="deleteItemModal" tabindex="-1" aria-labelledby="deleteItemModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteItemModalLabel">Artikel löschen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Diesen Artikel wirklich aus der Liste entfernen?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="button" class="btn btn-danger" id="btn-delete-confirm">Löschen</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="removeItemModal" tabindex="-1" aria-labelledby="removeItemModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
@@ -1035,4 +1053,14 @@ $conn->close();
}
</script>
<?php require_once 'footer.php'; ?>city = '0'; }, 1500);
}
return json;
} catch(error) {
console.error(error);
return Promise.reject(error);
}
}
</script>
<?php require_once 'footer.php'; ?>

View File

@@ -0,0 +1,613 @@
<?php
// manage_packing_list_items.php - Interaktive Verwaltung der Packlisten-Artikel
// FINALE, REKURSIVE NESTED-SORTABLE VERSION
if (session_status() == PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user_id'])) {
header("Location: login.php");
exit;
}
require_once 'db_connect.php';
require_once 'header.php';
$current_user_id = $_SESSION['user_id'];
$packing_list_id = isset($_GET['id']) ? intval($_GET['id']) : 0;
$can_edit = false;
// Lade Packliste und prüfe Berechtigung
$stmt_list = $conn->prepare("SELECT * FROM packing_lists WHERE id = ?");
$stmt_list->bind_param("i", $packing_list_id);
$stmt_list->execute();
$result = $stmt_list->get_result();
if ($result->num_rows === 0) die("Packliste nicht gefunden.");
$packing_list = $result->fetch_assoc();
$stmt_list->close();
$stmt_household = $conn->prepare("SELECT household_id FROM users WHERE id = ?");
$stmt_household->bind_param("i", $current_user_id);
$stmt_household->execute();
$current_user_household_id = $stmt_household->get_result()->fetch_assoc()['household_id'];
$stmt_household->close();
$is_owner = ($packing_list['user_id'] == $current_user_id);
$is_household_list = !empty($packing_list['household_id']);
$is_in_same_household = ($is_household_list && $packing_list['household_id'] == $current_user_household_id);
if ($is_owner || $is_in_same_household) { $can_edit = true; }
if (!$can_edit) { die("Zugriff verweigert."); }
$page_title = "Packliste bearbeiten: " . htmlspecialchars($packing_list['name']);
$household_member_ids = [$current_user_id];
if ($current_user_household_id) {
$stmt_members = $conn->prepare("SELECT id FROM users WHERE household_id = ?");
$stmt_members->bind_param("i", $current_user_household_id);
$stmt_members->execute();
$result_members = $stmt_members->get_result();
while ($row = $result_members->fetch_assoc()) { if (!in_array($row['id'], $household_member_ids)) { $household_member_ids[] = $row['id']; } }
$stmt_members->close();
}
$placeholders = implode(',', array_fill(0, count($household_member_ids), '?'));
$types = str_repeat('i', count($household_member_ids));
$sql_all_articles = "SELECT a.id, a.name, a.weight_grams, a.quantity_owned, a.product_designation, a.consumable, a.parent_article_id, a.image_url, c.name as category_name, m.name as manufacturer_name
FROM articles a
LEFT JOIN categories c ON a.category_id = c.id
LEFT JOIN manufacturers m ON a.manufacturer_id = m.id
WHERE a.user_id IN ($placeholders)";
$all_params = $household_member_ids;
$all_types = $types;
if ($current_user_household_id) {
$sql_all_articles .= " OR a.household_id = ?";
$all_params[] = $current_user_household_id;
$all_types .= 'i';
}
$stmt_all_articles = $conn->prepare($sql_all_articles);
$stmt_all_articles->bind_param($all_types, ...$all_params);
$stmt_all_articles->execute();
$all_articles_raw = $stmt_all_articles->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt_all_articles->close();
$sql_items = "SELECT pli.id, pli.article_id, pli.quantity, pli.parent_packing_list_item_id, pli.carrier_user_id, pli.backpack_id, pli.backpack_compartment_id,
COALESCE(
a.name,
bc.name,
CASE WHEN b.name IS NOT NULL THEN CONCAT('Rucksack: ', b.name) ELSE NULL END,
pli.name
) as name,
a.weight_grams, a.product_designation, a.consumable, m.name as manufacturer_name
FROM packing_list_items pli
LEFT JOIN articles a ON pli.article_id = a.id
LEFT JOIN manufacturers m ON a.manufacturer_id = m.id
LEFT JOIN backpacks b ON pli.backpack_id = b.id
LEFT JOIN backpack_compartments bc ON pli.backpack_compartment_id = bc.id
WHERE pli.packing_list_id = ?
ORDER BY pli.order_index ASC";
$stmt_items = $conn->prepare($sql_items);
$stmt_items->bind_param("i", $packing_list_id);
$stmt_items->execute();
$packed_items_raw = $stmt_items->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt_items->close();
$carriers_data = [];
// KORREKTUR: Nur Träger anzeigen, die dieser Liste zugewiesen sind
$stmt_carriers = $conn->prepare("SELECT u.id, u.username FROM users u JOIN packing_list_carriers plc ON u.id = plc.user_id WHERE plc.packing_list_id = ? ORDER BY u.username");
$stmt_carriers->bind_param("i", $packing_list_id);
$stmt_carriers->execute();
$carriers_result = $stmt_carriers->get_result();
while ($row = $carriers_result->fetch_assoc()) { $carriers_data[] = $row; }
$stmt_carriers->close();
$carriers_data[] = ['id' => null, 'username' => 'Sonstiges'];
$categories = array_unique(array_filter(array_column($all_articles_raw, 'category_name')));
sort($categories);
$manufacturers = array_unique(array_filter(array_column($all_articles_raw, 'manufacturer_name')));
sort($manufacturers);
$conn->close();
?>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
<!-- Tom Select CSS/JS -->
<link href="https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/css/tom-select.bootstrap5.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/js/tom-select.complete.min.js"></script>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h2 class="h4 mb-0"><i class="fas fa-edit me-2"></i>Packliste bearbeiten: <?php echo htmlspecialchars($packing_list['name']); ?></h2>
<a href="packing_list_detail.php?id=<?php echo $packing_list_id; ?>" class="btn btn-secondary"><i class="fas fa-eye me-2"></i>Zur Ansicht</a>
</div>
<div class="card-body">
<div class="editor-container">
<div class="editor-pane">
<div class="pane-header">
<h5 class="mb-0">Verfügbare Artikel</h5>
<div class="row g-2 mt-2">
<div class="col-12"><input type="text" id="filter-text" class="form-control form-control-sm" placeholder="Artikel, Hersteller, Modell suchen..."></div>
<div class="col-md-6"><select id="filter-category" class="form-select form-select-sm"><option value="">-- Alle Kategorien --</option><?php foreach($categories as $c) echo '<option>'.htmlspecialchars($c).'</option>'; ?></select></div>
<div class="col-md-6"><select id="filter-manufacturer" class="form-select form-select-sm"><option value="">-- Alle Hersteller --</option><?php foreach($manufacturers as $m) echo '<option>'.htmlspecialchars($m).'</option>'; ?></select></div>
</div>
</div>
<div class="pane-content" id="available-items-list"></div>
</div>
<div class="editor-pane">
<div class="pane-header"><h5 class="mb-0">Gepackte Artikel</h5></div>
<div class="pane-content" id="carriers-container"></div>
</div>
</div>
</div>
</div>
<!-- Modals and Feedback remain unchanged -->
<div class="modal fade" id="includeChildrenModal" tabindex="-1" aria-labelledby="includeChildrenModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="includeChildrenModalLabel">Komponenten hinzufügen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Dieser Artikel hat zugehörige Komponenten. Sollen diese ebenfalls zur Packliste hinzugefügt werden?
<ul id="children-list" class="list-group mt-2"></ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="add-without-children">Nein, nur diesen Artikel</button>
<button type="button" class="btn btn-primary" id="add-with-children">Ja, alle hinzufügen</button>
</div>
</div>
</div>
</div>
<div id="save-feedback" class="save-feedback">Änderungen gespeichert!</div>
<div id="image-preview-tooltip" class="image-preview-tooltip"></div>
<style>
/* Nested Sortable Specific Styles */
.nested-sortable {
min-height: 10px;
padding-left: 20px; /* Indent child items */
border-left: 2px solid rgba(0,0,0,0.05);
margin-top: 5px;
}
.packed-item-container {
margin-bottom: 5px;
}
.packed-item-content {
display: flex;
align-items: center;
padding: 8px;
background-color: #fff;
border: 1px solid rgba(0,0,0,0.1);
border-radius: 6px;
transition: box-shadow 0.2s;
}
.packed-item-content:hover {
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
}
/* Highlight drop targets */
.nested-sortable.sortable-ghost {
background-color: rgba(59, 74, 35, 0.1);
border: 1px dashed var(--color-primary);
}
/* Empty nested containers should have a hint of height to be droppable */
.nested-sortable:empty {
min-height: 30px;
background-color: rgba(0,0,0,0.02);
border: 1px dashed rgba(0,0,0,0.1);
border-radius: 4px;
margin-top: 5px;
}
.nested-sortable:empty::after {
content: 'Hier ablegen';
display: block;
text-align: center;
color: #aaa;
font-size: 0.8em;
padding-top: 5px;
}
/* Backpack & Compartment Styles */
.backpack-root-item {
background-color: #e8f5e9;
border: 1px solid #2e7d32;
}
.compartment-item {
background-color: #f1f8e9;
border-left: 3px solid #7cb342;
}
</style>
<script>
let packedItems = <?php echo json_encode($packed_items_raw); ?>;
const allArticles = <?php echo json_encode($all_articles_raw); ?>;
const carriers = <?php echo json_encode($carriers_data); ?>;
const packingListId = <?php echo $packing_list_id; ?>;
let sortableInstances = [];
let childrenModal = null;
document.addEventListener('DOMContentLoaded', () => {
// Initialize Tom Select for filters with allowEmptyOption
const tsOptions = {
create: false,
sortField: { field: "text", direction: "asc" },
allowEmptyOption: true,
onChange: function(value) {
// Propagate change
this.input.dispatchEvent(new Event('change'));
}
};
new TomSelect('#filter-category', tsOptions);
new TomSelect('#filter-manufacturer', tsOptions);
// Tooltip Logic
const tooltip = document.getElementById('image-preview-tooltip');
if (tooltip) {
// Delegation for dynamically added items
document.body.addEventListener('mouseover', e => {
if (e.target.classList.contains('article-image-trigger')) {
const url = e.target.getAttribute('data-preview-url');
if (url && !url.endsWith('assets/images/keinbild.png')) {
tooltip.style.backgroundImage = `url('${url}')`;
tooltip.style.display = 'block';
}
}
});
document.body.addEventListener('mousemove', e => {
if (tooltip.style.display === 'block') {
tooltip.style.left = e.pageX + 15 + 'px';
tooltip.style.top = e.pageY + 15 + 'px';
}
});
document.body.addEventListener('mouseout', e => {
if (e.target.classList.contains('article-image-trigger')) {
tooltip.style.display = 'none';
}
});
}
['input', 'change'].forEach(evt => {
document.getElementById('filter-text').addEventListener(evt, renderAvailableItems);
document.getElementById('filter-category').addEventListener(evt, renderAvailableItems);
document.getElementById('filter-manufacturer').addEventListener(evt, renderAvailableItems);
});
document.getElementById('carriers-container').addEventListener('click', handlePackedItemActions);
document.getElementById('carriers-container').addEventListener('change', handleQuantityChange);
fullRender();
});
function fullRender() {
renderAvailableItems();
renderCarriersAndPackedItems();
}
function renderAvailableItems() {
const filterText = document.getElementById('filter-text').value.toLowerCase();
const filterCategory = document.getElementById('filter-category').value;
const filterManufacturer = document.getElementById('filter-manufacturer').value;
const availableListEl = document.getElementById('available-items-list');
const packedQuantities = {};
packedItems.forEach(item => {
const aid = String(item.article_id);
packedQuantities[aid] = (packedQuantities[aid] || 0) + parseInt(item.quantity || 1, 10);
});
let html = '';
allArticles.forEach(article => {
if (article.parent_article_id) return;
const aid = String(article.id);
const packedQty = packedQuantities[aid] || 0;
const ownedQty = parseInt(article.quantity_owned || 1, 10);
const isConsumable = article.consumable == 1;
if (isConsumable || packedQty < ownedQty) {
const matchesText = article.name.toLowerCase().includes(filterText) || (article.manufacturer_name && article.manufacturer_name.toLowerCase().includes(filterText)) || (article.product_designation && article.product_designation.toLowerCase().includes(filterText));
const matchesCategory = !filterCategory || article.category_name === filterCategory;
const matchesManufacturer = !filterManufacturer || article.manufacturer_name === filterManufacturer;
if (matchesText && matchesCategory && matchesManufacturer) {
const details = [article.manufacturer_name, article.product_designation].filter(Boolean).join(' - ');
const imgUrl = article.image_url ? article.image_url : 'assets/images/keinbild.png';
html += `
<div class="available-item-card d-flex align-items-center" data-article-id="${article.id}">
<img src="${imgUrl}" class="article-image-trigger me-2 rounded" style="width: 30px; height: 30px; object-fit: cover; cursor: pointer;" data-preview-url="${imgUrl}">
<div class="flex-grow-1">
${article.name}
<small class="text-muted d-block" style="line-height: 1;">(${details || '---'} | ${article.weight_grams}g)</small>
</div>
</div>`;
}
}
});
availableListEl.innerHTML = html;
new Sortable(availableListEl, {
group: { name: 'nested', pull: 'clone', put: false },
animation: 150,
sort: false
});
}
function renderCarriersAndPackedItems() {
const container = document.getElementById('carriers-container');
container.innerHTML = '';
sortableInstances.forEach(s => s.destroy());
sortableInstances = [];
carriers.forEach(carrier => {
const carrierId = carrier.id === null ? 'null' : carrier.id;
const carrierDiv = document.createElement('div');
carrierDiv.className = 'carrier-box';
carrierDiv.innerHTML = `<div class="carrier-header"><h6>${carrier.username}</h6></div>`;
const carrierRootList = document.createElement('div');
carrierRootList.className = 'carrier-list nested-sortable';
carrierRootList.dataset.carrierId = carrierId;
carrierDiv.appendChild(carrierRootList);
container.appendChild(carrierDiv);
const itemsForCarrier = packedItems.filter(item => (item.carrier_user_id === null ? 'null' : String(item.carrier_user_id)) == String(carrierId));
const itemsById = itemsForCarrier.reduce((acc, item) => ({...acc, [item.id]: {...item, children: []}}), {});
const rootItems = [];
Object.values(itemsById).forEach(item => {
if (item.parent_packing_list_item_id && itemsById[item.parent_packing_list_item_id]) {
itemsById[item.parent_packing_list_item_id].children.push(item);
} else {
rootItems.push(item);
}
});
renderRecursive(rootItems, carrierRootList);
initNestedSortable(carrierRootList);
});
}
function renderRecursive(items, container) {
items.forEach(item => {
const itemEl = createPackedItemDOM(item);
container.appendChild(itemEl);
const nestedContainer = itemEl.querySelector('.nested-sortable');
if (item.children && item.children.length > 0) {
renderRecursive(item.children, nestedContainer);
}
initNestedSortable(nestedContainer);
});
}
function createPackedItemDOM(item) {
const div = document.createElement('div');
div.className = 'packed-item-container';
div.dataset.itemId = item.id;
div.dataset.articleId = item.article_id;
if(item.backpack_id) div.dataset.backpackId = item.backpack_id;
if(item.backpack_compartment_id) div.dataset.backpackCompartmentId = item.backpack_compartment_id;
let contentClass = 'packed-item-content';
let iconClass = 'fas fa-grip-vertical handle';
let nameDisplay = item.name;
let metaDisplay = item.manufacturer_name ? `<small class="text-muted">(${item.manufacturer_name})</small>` : '';
let controls = `
<input type="number" class="form-control form-control-sm quantity-input text-center mx-2" value="${item.quantity}" min="1">
<button class="btn btn-sm btn-outline-danger remove-item-btn"><i class="fas fa-trash"></i></button>
`;
// Special styling for Containers
if (item.backpack_id) {
contentClass += ' backpack-root-item';
iconClass = 'fas fa-hiking handle me-2';
nameDisplay = `<strong>${item.name}</strong>`;
metaDisplay = '';
// Disable controls for structural items
controls = '';
} else if (item.backpack_compartment_id) {
contentClass += ' compartment-item';
iconClass = 'fas fa-folder-open handle me-2 text-success';
nameDisplay = `<span class="fw-bold text-dark">${item.name}</span>`;
metaDisplay = '';
// Disable controls for structural items
controls = '';
}
div.innerHTML = `
<div class="${contentClass}">
<i class="${iconClass}"></i>
<span class="item-name ms-2">${nameDisplay} ${metaDisplay}</span>
<div class="item-controls">
${controls}
</div>
</div>
<div class="nested-sortable"></div>
`;
return div;
}
function initNestedSortable(element) {
const s = new Sortable(element, {
group: 'nested',
animation: 150,
handle: '.handle',
fallbackOnBody: true,
swapThreshold: 0.65,
ghostClass: 'sortable-ghost',
onAdd: function (evt) {
if (evt.from.id === 'available-items-list') {
const articleId = evt.item.dataset.articleId;
const originalArticleData = allArticles.find(a => String(a.id) === articleId);
const newItemData = {
id: 'new-' + Date.now(),
article_id: articleId,
name: originalArticleData.name,
manufacturer_name: originalArticleData.manufacturer_name,
quantity: 1,
consumable: originalArticleData.consumable,
children: []
};
const newDOM = createPackedItemDOM(newItemData);
evt.item.replaceWith(newDOM);
initNestedSortable(newDOM.querySelector('.nested-sortable'));
const childArticles = allArticles.filter(a => String(a.parent_article_id) === articleId);
if (childArticles.length > 0) {
handleChildArticlesPrompt(childArticles, newDOM);
} else {
syncListState();
}
} else {
syncListState();
}
},
onEnd: function (evt) {
if (evt.from.id !== 'available-items-list') {
syncListState();
}
}
});
sortableInstances.push(s);
}
function handleChildArticlesPrompt(childArticles, parentElement) {
if (!childrenModal) childrenModal = new bootstrap.Modal(document.getElementById('includeChildrenModal'));
document.getElementById('children-list').innerHTML = childArticles.map(c => `<li class="list-group-item">${c.name}</li>`).join('');
document.getElementById('add-with-children').onclick = () => {
childrenModal.hide();
const nestedContainer = parentElement.querySelector('.nested-sortable');
childArticles.forEach(child => {
const childItemData = {
id: 'new-child-' + Date.now() + Math.random(),
article_id: child.id,
name: child.name,
manufacturer_name: child.manufacturer_name,
quantity: 1,
consumable: child.consumable,
children: []
};
const childDOM = createPackedItemDOM(childItemData);
nestedContainer.appendChild(childDOM);
initNestedSortable(childDOM.querySelector('.nested-sortable'));
});
syncListState();
};
document.getElementById('add-without-children').onclick = () => {
childrenModal.hide();
syncListState();
};
childrenModal.show();
}
function syncListState() {
const payload = { action: 'sync_list', list: [] };
document.querySelectorAll('.carrier-list').forEach(rootList => {
const carrierId = rootList.dataset.carrierId;
function traverse(container, parentId) {
Array.from(container.children).forEach(child => {
if (!child.classList.contains('packed-item-container')) return;
const pliId = child.dataset.itemId;
const articleId = child.dataset.articleId;
payload.list.push({
pli_id: pliId,
article_id: articleId,
carrier_id: carrierId,
parent_pli_id: parentId,
backpack_id: child.dataset.backpackId || null,
backpack_compartment_id: child.dataset.backpackCompartmentId || null
});
const nestedContainer = child.querySelector('.nested-sortable');
if (nestedContainer) {
traverse(nestedContainer, pliId);
}
});
}
traverse(rootList, null);
});
sendApiRequest(payload).then(newItems => {
packedItems = newItems;
// Save scroll positions
const scrollPos = {
avail: document.getElementById('available-items-list').scrollTop,
carrier: document.getElementById('carriers-container').scrollTop
};
const panes = document.querySelectorAll('.pane-content');
const scrollMap = Array.from(panes).map(p => p.scrollTop);
fullRender();
// Restore scroll positions
Array.from(panes).forEach((p, i) => {
if (scrollMap[i] !== undefined) p.scrollTop = scrollMap[i];
});
}).catch(err => {
console.error("Sync failed:", err);
alert("Fehler beim Speichern! Bitte Seite neu laden.");
});
}
function handlePackedItemActions(e) {
const button = e.target.closest('button');
if (!button) return;
const itemEl = button.closest('.packed-item-container');
if (!itemEl) return;
const itemId = itemEl.dataset.itemId;
if (button.classList.contains('remove-item-btn')) {
if (confirm('Diesen Artikel wirklich aus der Packliste entfernen?')) {
itemEl.remove();
sendApiRequest({ action: 'delete_item', item_id: itemId });
}
}
}
function handleQuantityChange(e) {
const input = e.target;
if (input.classList.contains('quantity-input')) {
const itemEl = input.closest('.packed-item-container');
const itemId = itemEl.dataset.itemId;
const newQuantity = input.value;
clearTimeout(input.dataset.timeout);
input.dataset.timeout = setTimeout(() => {
sendApiRequest({ action: 'update_quantity', item_id: itemId, quantity: newQuantity });
}, 500);
}
}
async function sendApiRequest(data) {
data.packing_list_id = packingListId;
try {
const response = await fetch('api_packing_list_handler.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({error: 'Serverfehler'}));
console.error(errorData);
return Promise.reject(errorData);
}
const json = await response.json();
if (data.action !== 'update_quantity') {
const fb = document.getElementById('save-feedback');
fb.style.opacity = '1';
setTimeout(() => { fb.style.opacity = '0'; }, 1500);
}
return json;
} catch(error) {
console.error(error);
return Promise.reject(error);
}
}
</script>
<?php require_once 'footer.php'; ?>

View File

@@ -302,6 +302,16 @@ function render_item_row($item, $level, $items_by_parent) {
</div>
</div>
</div>
<?php if ($table_items_count > 0): ?>
<div class="alert alert-warning print-hide">
<i class="fas fa-exclamation-triangle me-2"></i><strong>Achtung:</strong> Auf dem Tisch liegen noch Artikel, die keinem Rucksack und keinem Träger zugewiesen sind! Diese fließen nicht in die Statistik oder den Druck ein.
<?php if ($packing_list['user_id'] == $current_user_id || (!empty($packing_list['household_id']) && $packing_list['household_id'] == $current_user_household_id)): ?>
<a href="manage_packing_list_items.php?id=<?php echo $packing_list_id; ?>&phase=2" class="alert-link ms-2">Jetzt zuweisen <i class="fas fa-arrow-right"></i></a>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="row">
<div class="col-lg-8 mb-4 mb-lg-0">
<div class="card h-100">

View File

@@ -50,7 +50,7 @@ if ($packing_list) {
a.weight_grams, c.name AS category_name, a.consumable, a.image_url,
a.product_designation, m.name AS manufacturer_name,
bp.manufacturer AS bp_manufacturer, bp.model AS bp_model,
u.username AS carrier_name
COALESCE(u.display_name, u.username) AS carrier_name
FROM packing_list_items pli
LEFT JOIN articles a ON pli.article_id = a.id
LEFT JOIN manufacturers m ON a.manufacturer_id = m.id
@@ -59,6 +59,7 @@ if ($packing_list) {
LEFT JOIN backpack_compartments bpc ON pli.backpack_compartment_id = bpc.id
LEFT JOIN users u ON pli.carrier_user_id = u.id
WHERE pli.packing_list_id = ?
AND NOT (pli.carrier_user_id IS NULL AND pli.backpack_id IS NULL AND pli.backpack_compartment_id IS NULL AND pli.parent_packing_list_item_id IS NULL)
ORDER BY pli.order_index ASC";
$stmt = $conn->prepare($sql);

View File

@@ -60,7 +60,7 @@ if ($_SERVER["REQUEST_METHOD"] == "POST") {
$stmt->close();
} elseif (isset($_POST['toggle_item'])) {
$item_id = intval($_POST['item_id']);
$status = intval($_POST['status']);
$status = isset($_POST['status']) ? intval($_POST['status']) : 0;
$stmt = $conn->prepare("UPDATE todo_items SET is_completed = ? WHERE id = ?");
$stmt->bind_param("ii", $status, $item_id);
$stmt->execute();
@@ -130,9 +130,9 @@ $active_list_id = isset($_GET['list_id']) ? intval($_GET['list_id']) : (!empty($
$active_list_name = '';
foreach ($todo_lists as $l) if ($l['id'] == $active_list_id) $active_list_name = $l['name'];
?>
<div class="card shadow-sm">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-primary"><?php echo htmlspecialchars($active_list_name); ?></h6>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fas fa-tasks me-2"></i><?php echo htmlspecialchars($active_list_name); ?></h5>
</div>
<div class="card-body">
<ul class="list-group list-group-flush mb-3">

BIN
src/uploads/6852fc1190bc2.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
src/uploads/685306028b828.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

BIN
src/uploads/685306a5be4af.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

BIN
src/uploads/68530b5956db3.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

BIN
src/uploads/685314e648206.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

BIN
src/uploads/6853157b136ce.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 902 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Some files were not shown because too many files have changed in this diff Show More