OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

index.php

41 KB
<?php
$this->layout('layouts/main', [
    'page_title' => 'IT-Services',
    'breadcrumbs' => [],
    'full_width' => true,
    'current_route' => 'services',
]);

$groupedServices = $grouped_services ?? [];
$pinnedIds = $pinned_ids ?? [];
$pinnedServices = $pinned_services ?? [];
$categories = $categories ?? [];
$serviceCount = $service_count ?? 0;
$availableIcons = $available_icons ?? [];
$canManage = $can_manage ?? false;
$csrfToken = $this->csrf();
$view = $this;
$iconBasePath = '/uploads/service-icons/';
$docPages = $doc_pages ?? [];
?>

<div class="services-fullwidth">
    <!-- Content Header -->
    <div class="content-header">
        <div>
            <h1><?= $this->te('IT-Services') ?></h1>
            <ol class="breadcrumb">
                <li class="breadcrumb-item"><a href="/"><?= $this->te('Home') ?></a></li>
                <li class="breadcrumb-item active"><?= $this->te('IT-Services') ?></li>
            </ol>
        </div>
        <div class="content-header-actions">
            <?php if ($canManage): ?>
                <button type="button" class="btn btn-primary" onclick="openModal('newServiceModal')">
                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
                    <?= $this->te('Neuer Service') ?>
                </button>
                <a href="/services/categories" class="btn btn-default"><?= $this->te('Kategorien') ?></a>
            <?php endif; ?>
        </div>
    </div>

    <div class="services-content">

    <!-- Filter Card with Search -->
    <div class="card card-outline card-primary mb-3">
        <div class="filter-card-body">
            <div style="position:relative;max-width:320px;flex:1;min-width:200px;">
                                <input type="search" id="serviceSearch" class="form-control form-control-sm" placeholder="<?= $this->te('Service suchen…') ?>" aria-label="<?= $this->te('Service suchen') ?>" oninput="filterServices()" autocomplete="off" style="padding-left:2rem;">
            </div>
        </div>
    </div>

        <!-- Angepinnte Services -->
        <section class="pinned-section" id="pinnedSection"<?= empty($pinnedServices) ? ' style="display:none;"' : '' ?>>
            <div class="section-header" style="display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;">
                <h2><span class="section-icon">&#128204;</span> <?= $this->te('Meine Pins') ?></h2>
                <div style="display:inline-flex;align-items:center;gap:1rem;flex-wrap:wrap;">
                    <label class="navbar-fav-toggle" style="display:inline-flex;align-items:center;gap:0.4rem;font-size:0.85rem;color:var(--gray-500);cursor:pointer;font-weight:500;" title="<?= $this->te('Zeigt deine ersten Pins als Schnellzugriff oben im Header an (nur am PC sichtbar).') ?>">
                        <input type="checkbox" id="navbarFavToggle"<?= !empty($navbar_favorites_enabled) ? ' checked' : '' ?> onchange="toggleNavbarFavorites(this.checked)" style="width:15px;height:15px;accent-color:var(--color-primary);cursor:pointer;">
                        <span><?= $this->te('Im Header anzeigen') ?></span>
                    </label>
                    <label style="display:inline-flex;align-items:center;gap:0.4rem;font-size:0.85rem;color:var(--gray-500);font-weight:500;" title="<?= $this->te('Wie viele Pins oben im Header angezeigt werden (1–15).') ?>">
                        <span><?= $this->te('Anzahl') ?></span>
                        <input type="number" id="navbarFavCount" min="1" max="15" value="<?= (int)($navbar_favorites_count ?? 5) ?>" onchange="saveNavbarFavCount(this.value)" style="width:58px;padding:0.2rem 0.4rem;border:1px solid var(--gray-300);border-radius:0.375rem;">
                    </label>
                </div>
            </div>
            <script>
            function toggleNavbarFavorites(enabled) {
                var fd = new FormData();
                fd.append('key', 'navbar_favorites_enabled');
                fd.append('value', enabled ? '1' : '0');
                fetch('/api/user-settings', {
                    method: 'POST',
                    headers: {
                        'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
                        'X-Requested-With': 'XMLHttpRequest'
                    },
                    body: fd
                }).catch(function () {});
            }
            function saveNavbarFavCount(v) {
                v = parseInt(v, 10);
                if (isNaN(v) || v < 1) { v = 1; }
                if (v > 15) { v = 15; }
                var el = document.getElementById('navbarFavCount');
                if (el) { el.value = v; }
                var fd = new FormData();
                fd.append('key', 'navbar_favorites_count');
                fd.append('value', String(v));
                fetch('/api/user-settings', {
                    method: 'POST',
                    headers: {
                        'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
                        'X-Requested-With': 'XMLHttpRequest'
                    },
                    body: fd
                }).catch(function () {});
            }
            </script>
            <div class="service-grid pinned-grid" id="pinnedGrid">
                <?php foreach ($pinnedServices as $s):
                    $reachLabel = $s['reachability'] === 'internal' ? 'Intern erreichbar' : 'Extern erreichbar';
                ?>
                    <a href="<?= $view->e($s['url']) ?>" target="_blank" rel="noopener" draggable="false" class="service-card pinned" data-name="<?= $view->e($s['name']) ?>" data-url="<?= $view->e($s['url']) ?>" data-id="<?= $s['id'] ?>"<?php if (!empty($s['description'])): ?> title="<?= $view->e($s['description']) ?>"<?php endif; ?>>
                        <span class="drag-handle" title="<?= $this->te('Ziehen zum Sortieren') ?>" aria-label="<?= $this->te('Ziehen zum Sortieren') ?>">&#8942;&#8942;</span>
                        <div class="service-icon-wrap">
                            <?php if (!empty($s['icon'])): ?>
                                <img src="<?= $iconBasePath . $view->e($s['icon']) ?>" alt="" class="service-icon-img">
                            <?php else: ?>
                                <span class="service-icon-letter" <?php if (!empty($s['category_color'])): ?>style="background:<?= $view->e($s['category_color']) ?>"<?php endif; ?>>
                                    <?= strtoupper(mb_substr($s['name'], 0, 1)) ?>
                                </span>
                            <?php endif; ?>
                        </div>
                        <div class="service-info">
                            <div class="service-name"><?= $view->e($s['name']) ?></div>
                            <div class="service-url"><?= $view->e(parse_url($s['url'], PHP_URL_HOST) ?? $s['url']) ?></div>
                        </div>
                        <?php if ($s['reachability']): ?>
                            <span class="reach-badge reach-<?= $view->e($s['reachability']) ?>" title="<?= $reachLabel ?>" aria-label="<?= $reachLabel ?>">
                                <?= $s['reachability'] === 'internal' ? '&#127970;' : '&#127760;' ?>
                            </span>
                        <?php endif; ?>
                        <button type="button" class="pin-btn pinned" onclick="event.preventDefault(); event.stopPropagation(); togglePin(<?= $s['id'] ?>, this);" title="<?= $this->te('Pin entfernen') ?>" aria-label="<?= $this->te('Pin entfernen') ?>">
                            &#9733;
                        </button>
                    </a>
                <?php endforeach; ?>
            </div>
        </section>

        <!-- Services nach Kategorien -->
        <?php if (empty($groupedServices)): ?>
            <div class="empty-state">
                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="48" height="48"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
                <p><?= $this->te('Noch keine Services vorhanden') ?></p>
                <?php if ($canManage): ?>
                    <button type="button" class="btn btn-primary" onclick="openModal('newServiceModal')"><?= $this->te('Ersten Service anlegen') ?></button>
                <?php endif; ?>
            </div>
        <?php else: ?>
            <?php foreach ($groupedServices as $group): ?>
            <section class="category-section">
                <div class="section-header">
                    <h2>
                        <?php if (!empty($group['color'])): ?>
                            <span class="cat-dot" style="background:<?= $view->e($group['color']) ?>"></span>
                        <?php endif; ?>
                        <?= $view->e($group['name']) ?>
                        <span class="section-count"><?= count($group['services']) ?></span>
                    </h2>
                </div>
                <div class="service-grid" data-service-grid>
                    <?php foreach ($group['services'] as $s):
                        $isPinned = in_array((int)$s['id'], $pinnedIds, true);
                        $reachLabel = $s['reachability'] === 'internal' ? 'Intern erreichbar' : 'Extern erreichbar';
                    ?>
                        <div class="service-card <?= $isPinned ? 'is-pinned' : '' ?>" data-name="<?= $view->e($s['name']) ?>" data-url="<?= $view->e($s['url']) ?>" data-id="<?= $s['id'] ?>"<?php if (!empty($s['description'])): ?> title="<?= $view->e($s['description']) ?>"<?php endif; ?>>
                            <?php if ($canManage): ?>
                                <span class="drag-handle" title="<?= $this->te('Ziehen zum Sortieren') ?>" aria-label="<?= $this->te('Ziehen zum Sortieren') ?>">&#8942;&#8942;</span>
                            <?php endif; ?>
                            <a href="<?= $view->e($s['url']) ?>" target="_blank" rel="noopener" class="service-link">
                                <div class="service-icon-wrap">
                                    <?php if (!empty($s['icon'])): ?>
                                        <img src="<?= $iconBasePath . $view->e($s['icon']) ?>" alt="" class="service-icon-img">
                                    <?php else: ?>
                                        <span class="service-icon-letter" <?php if (!empty($group['color'])): ?>style="background:<?= $view->e($group['color']) ?>"<?php endif; ?>>
                                            <?= strtoupper(mb_substr($s['name'], 0, 1)) ?>
                                        </span>
                                    <?php endif; ?>
                                </div>
                                <div class="service-info">
                                    <div class="service-name"><?= $view->e($s['name']) ?></div>
                                    <div class="service-url"><?= $view->e(parse_url($s['url'], PHP_URL_HOST) ?? $s['url']) ?></div>
                                </div>
                                <?php if ($s['reachability']): ?>
                                    <span class="reach-badge reach-<?= $view->e($s['reachability']) ?>" title="<?= $reachLabel ?>" aria-label="<?= $reachLabel ?>">
                                        <?= $s['reachability'] === 'internal' ? '&#127970;' : '&#127760;' ?>
                                    </span>
                                <?php endif; ?>
                            </a>
                            <?php $svcDocs = $docPages[(int)$s['id']] ?? []; ?>
                            <?php if (!empty($svcDocs)): ?>
                            <div class="service-docs">
                                <?php foreach ($svcDocs as $doc): ?>
                                <a href="/pages/<?= (int)$doc['id'] ?>" class="service-doc-chip"
                                   title="Dokumentation: <?= $view->e($doc['title']) ?>">
                                    <i class="fas fa-book-open" aria-hidden="true"></i><?= $view->e($doc['title']) ?>
                                </a>
                                <?php endforeach; ?>
                            </div>
                            <?php endif; ?>
                            <div class="service-actions">
                                <button type="button" class="pin-btn <?= $isPinned ? 'pinned' : '' ?>" onclick="togglePin(<?= $s['id'] ?>, this)" title="<?= $isPinned ? 'Pin entfernen' : 'Anpinnen' ?>" aria-label="<?= $isPinned ? 'Pin entfernen' : 'Anpinnen' ?>">
                                    <?= $isPinned ? '&#9733;' : '&#9734;' ?>
                                </button>
                                <?php if ($canManage): ?>
                                    <button type="button" class="edit-btn" onclick="openEditModal(<?= htmlspecialchars(json_encode($s), ENT_QUOTES) ?>)" title="<?= $this->te('Bearbeiten') ?>" aria-label="<?= $this->te('Service bearbeiten') ?>">
                                        &#9998;
                                    </button>
                                    <form method="POST" action="/services/<?= $s['id'] ?>" onsubmit="return confirm('Service loeschen?');" style="display:inline;">
                                        <input type="hidden" name="_csrf" value="<?= $csrfToken ?>">
                                        <input type="hidden" name="_method" value="DELETE">
                                        <button type="submit" class="delete-btn" title="<?= $this->te('Loeschen') ?>" aria-label="<?= $this->te('Service loeschen') ?>">&times;</button>
                                    </form>
                                <?php endif; ?>
                            </div>
                        </div>
                    <?php endforeach; ?>
                </div>
            </section>
            <?php endforeach; ?>
        <?php endif; ?>
    </div>
</div>

<?php if ($canManage): ?>
<!-- Modal: Neuer Service -->
<div id="newServiceModal" class="modal-overlay" style="display:none;">
    <div class="modal-card">
        <div class="modal-header">
            <h2><?= $this->te('Neuer Service') ?></h2>
            <button aria-label="<?= $this->te('Schließen') ?>" type="button" class="modal-close" onclick="closeModal('newServiceModal')">&times;</button>
        </div>
        <form method="POST" action="/services" enctype="multipart/form-data">
            <input type="hidden" name="_csrf" value="<?= $csrfToken ?>">
            <div class="modal-body">
                <div class="form-group">
                    <label for="new-name"><?= $this->te('Name *') ?></label>
                    <input type="text" id="new-name" name="name" class="form-control" placeholder="<?= $this->te('z.B. WLAN-Controller') ?>" required>
                </div>
                <div class="form-group">
                    <label for="new-url"><?= $this->t('URL *') ?></label>
                    <input type="url" id="new-url" name="url" class="form-control" placeholder="https://..." required>
                </div>
                <div class="form-group">
                    <label for="new-description"><?= $this->te('Beschreibung') ?></label>
                    <input type="text" id="new-description" name="description" class="form-control" maxlength="1000" placeholder="<?= $this->te('Optionaler Hinweis (wird als Tooltip angezeigt)') ?>">
                </div>
                <div class="form-row">
                    <div class="form-group">
                        <label for="new-category"><?= $this->te('Kategorie') ?></label>
                        <select id="new-category" name="category_id" class="form-control">
                            <option value=""><?= $this->te('Ohne Kategorie') ?></option>
                            <?php foreach ($categories as $cat): ?>
                                <option value="<?= $cat['id'] ?>"><?= $view->e($cat['name']) ?></option>
                            <?php endforeach; ?>
                        </select>
                    </div>
                    <div class="form-group">
                        <label for="new-reach"><?= $this->te('Erreichbarkeit') ?></label>
                        <select id="new-reach" name="reachability" class="form-control">
                            <option value=""><?= $this->te('Keine Angabe') ?></option>
                            <option value="internal"><?= $this->te('&#127970; Intern') ?></option>
                            <option value="external"><?= $this->te('&#127760; Extern') ?></option>
                        </select>
                    </div>
                </div>
                <div class="form-group">
                    <label for="new-icon"><?= $this->te('Icon hochladen') ?></label>
                    <input type="file" id="new-icon" name="icon" class="form-control" accept=".png,.jpg,.jpeg,.svg,.ico,.webp,.gif" onchange="previewIcon(this, 'new-icon-preview')">
                    <div id="new-icon-preview" class="icon-preview"></div>
                </div>
                <?php if (!empty($availableIcons)): ?>
                <div class="form-group">
                    <label for="new-existing-icon"><?= $this->te('Oder vorhandenes Icon') ?></label>
                    <select id="new-existing-icon" name="existing_icon" class="form-control" onchange="previewExistingIcon(this.value, 'new-existing-preview')">
                        <option value=""><?= $this->te('Kein Icon') ?></option>
                        <?php foreach ($availableIcons as $ico): ?>
                            <option value="<?= $view->e($ico) ?>"><?= $view->e($ico) ?></option>
                        <?php endforeach; ?>
                    </select>
                    <div id="new-existing-preview" class="icon-preview"></div>
                </div>
                <?php endif; ?>
            </div>
            <div class="modal-footer">
                <button type="button" class="btn btn-secondary" onclick="closeModal('newServiceModal')"><?= $this->te('Abbrechen') ?></button>
                <button type="submit" class="btn btn-primary"><?= $this->te('Erstellen') ?></button>
            </div>
        </form>
    </div>
</div>

<!-- Modal: Service bearbeiten -->
<div id="editServiceModal" class="modal-overlay" style="display:none;">
    <div class="modal-card">
        <div class="modal-header">
            <h2><?= $this->te('Service bearbeiten') ?></h2>
            <button aria-label="<?= $this->te('Schließen') ?>" type="button" class="modal-close" onclick="closeModal('editServiceModal')">&times;</button>
        </div>
        <form method="POST" id="editServiceForm" enctype="multipart/form-data">
            <input type="hidden" name="_csrf" value="<?= $csrfToken ?>">
            <input type="hidden" name="_method" value="PUT">
            <div class="modal-body">
                <div class="form-group">
                    <label for="edit-name"><?= $this->te('Name *') ?></label>
                    <input type="text" id="edit-name" name="name" class="form-control" required>
                </div>
                <div class="form-group">
                    <label for="edit-url"><?= $this->t('URL *') ?></label>
                    <input type="url" id="edit-url" name="url" class="form-control" required>
                </div>
                <div class="form-group">
                    <label for="edit-description"><?= $this->te('Beschreibung') ?></label>
                    <input type="text" id="edit-description" name="description" class="form-control" maxlength="1000" placeholder="<?= $this->te('Optionaler Hinweis (wird als Tooltip angezeigt)') ?>">
                </div>
                <div class="form-row">
                    <div class="form-group">
                        <label for="edit-category"><?= $this->te('Kategorie') ?></label>
                        <select id="edit-category" name="category_id" class="form-control">
                            <option value=""><?= $this->te('Ohne Kategorie') ?></option>
                            <?php foreach ($categories as $cat): ?>
                                <option value="<?= $cat['id'] ?>"><?= $view->e($cat['name']) ?></option>
                            <?php endforeach; ?>
                        </select>
                    </div>
                    <div class="form-group">
                        <label for="edit-reach"><?= $this->te('Erreichbarkeit') ?></label>
                        <select id="edit-reach" name="reachability" class="form-control">
                            <option value=""><?= $this->te('Keine Angabe') ?></option>
                            <option value="internal"><?= $this->te('&#127970; Intern') ?></option>
                            <option value="external"><?= $this->te('&#127760; Extern') ?></option>
                        </select>
                    </div>
                </div>
                <div class="form-group">
                    <label><?= $this->te('Aktuelles Icon') ?></label>
                    <div id="edit-current-icon" class="icon-preview"></div>
                    <label class="checkbox-label" style="margin-top:0.5rem;">
                        <input type="checkbox" name="remove_icon" value="1" id="edit-remove-icon"> <?= $this->te('Icon entfernen') ?>
                    </label>
                </div>
                <div class="form-group">
                    <label for="edit-icon"><?= $this->te('Neues Icon hochladen') ?></label>
                    <input type="file" id="edit-icon" name="icon" class="form-control" accept=".png,.jpg,.jpeg,.svg,.ico,.webp,.gif" onchange="previewIcon(this, 'edit-icon-preview')">
                    <div id="edit-icon-preview" class="icon-preview"></div>
                </div>
            </div>
            <div class="modal-footer">
                <button type="button" class="btn btn-secondary" onclick="closeModal('editServiceModal')"><?= $this->te('Abbrechen') ?></button>
                <button type="submit" class="btn btn-primary"><?= $this->te('Speichern') ?></button>
            </div>
        </form>
    </div>
</div>
<?php endif; ?>

<script>
// Suche
function filterServices() {
    var q = (document.getElementById('serviceSearch').value || '').toLowerCase();
    document.querySelectorAll('.service-card').forEach(function(card) {
        var name = (card.getAttribute('data-name') || '').toLowerCase();
        var url = (card.getAttribute('data-url') || '').toLowerCase();
        card.style.display = (!q || name.includes(q) || url.includes(q)) ? '' : 'none';
    });
    // Kategorie-Sektionen ausblenden wenn leer
    document.querySelectorAll('.category-section').forEach(function(sec) {
        var visible = sec.querySelectorAll('.service-card:not([style*="display: none"])').length;
        sec.style.display = visible === 0 && q ? 'none' : '';
    });
}

var SERVICES_CSRF = '<?= $csrfToken ?>';

function escHtml(str) {
    return String(str == null ? '' : str)
        .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}

function hostFromUrl(url) {
    try { return new URL(url).host || url; } catch (e) { return url; }
}

// Pin toggle (AJAX) – aktualisiert die Ansicht in-place, ohne Neuladen
function togglePin(serviceId, btn) {
    fetch('/services/' + serviceId + '/pin', {
        method: 'POST',
        headers: {
            'X-Requested-With': 'XMLHttpRequest',
            'Accept': 'application/json',
            'Content-Type': 'application/x-www-form-urlencoded'
        },
        body: '_csrf=' + encodeURIComponent(SERVICES_CSRF)
    })
    .then(function(r) { return r.json(); })
    .then(function(data) {
        if (data.error) { alert(data.error); return; }
        applyPinState(serviceId, data.pinned);
    })
    .catch(function(err) {
        console.error('Pin-Fehler:', err);
    });
}

// Pin-Status in-place anwenden
function applyPinState(serviceId, pinned) {
    // Kategorie-Karte (Quelle) finden und Buttons/Status aktualisieren
    var catCard = document.querySelector('.category-section .service-card[data-id="' + serviceId + '"]');
    if (catCard) {
        catCard.classList.toggle('is-pinned', pinned);
        var pinBtn = catCard.querySelector('.pin-btn');
        if (pinBtn) {
            pinBtn.classList.toggle('pinned', pinned);
            pinBtn.innerHTML = pinned ? '★' : '☆';
            pinBtn.title = pinned ? 'Pin entfernen' : 'Anpinnen';
            pinBtn.setAttribute('aria-label', pinBtn.title);
        }
    }

    var grid = document.getElementById('pinnedGrid');
    var section = document.getElementById('pinnedSection');
    var existing = grid ? grid.querySelector('.service-card[data-id="' + serviceId + '"]') : null;

    if (pinned) {
        if (grid && !existing && catCard) {
            grid.appendChild(buildPinnedCard(catCard));
            initPinnedSortable();
        }
    } else if (existing) {
        existing.remove();
    }

    // Pins-Sektion ein-/ausblenden
    if (grid && section) {
        section.style.display = grid.querySelector('.service-card') ? '' : 'none';
    }
}

// Pinned-Karte aus einer Kategorie-Karte erzeugen
function buildPinnedCard(srcCard) {
    var id = srcCard.getAttribute('data-id');
    var name = srcCard.getAttribute('data-name') || '';
    var url = srcCard.getAttribute('data-url') || '';
    var desc = srcCard.getAttribute('title') || '';
    var iconWrap = srcCard.querySelector('.service-icon-wrap');
    var reach = srcCard.querySelector('.reach-badge');

    var a = document.createElement('a');
    a.href = url;
    a.target = '_blank';
    a.rel = 'noopener';
    a.draggable = false;
    a.className = 'service-card pinned';
    a.setAttribute('data-name', name);
    a.setAttribute('data-url', url);
    a.setAttribute('data-id', id);
    if (desc) a.setAttribute('title', desc);

    a.innerHTML =
        '<span class="drag-handle" title="Ziehen zum Sortieren" aria-label="Ziehen zum Sortieren">⋮⋮</span>' +
        (iconWrap ? iconWrap.outerHTML : '') +
        '<div class="service-info"><div class="service-name">' + escHtml(name) +
        '</div><div class="service-url">' + escHtml(hostFromUrl(url)) + '</div></div>' +
        (reach ? reach.outerHTML : '') +
        '<button type="button" class="pin-btn pinned" title="Pin entfernen" aria-label="Pin entfernen" ' +
        'onclick="event.preventDefault(); event.stopPropagation(); togglePin(' + id + ', this);">★</button>';

    return a;
}

// ============================================================
// Drag & Drop Sortierung
// ============================================================
function attachSortable(grid, handleSelector, onReorder) {
    if (!grid) return;

    grid.querySelectorAll('.service-card').forEach(function(item) {
        bindDragItem(grid, item, handleSelector);
    });

    if (!grid._sortableBound) {
        grid._sortableBound = true;
        grid._raf = null;
        grid._ptr = null;
        // dragover wird sehr haeufig ausgeloest – wir merken uns nur die
        // Zeigerposition und verschieben das Element gebuendelt pro Frame
        // (requestAnimationFrame). Das verhindert Layout-Thrashing/Flackern.
        grid.addEventListener('dragover', function(e) {
            if (!grid._dragEl) return;
            e.preventDefault();
            if (e.dataTransfer) { e.dataTransfer.dropEffect = 'move'; }
            grid._ptr = { x: e.clientX, y: e.clientY };
            if (grid._raf) return;
            grid._raf = requestAnimationFrame(function() {
                grid._raf = null;
                if (!grid._dragEl || !grid._ptr) return;
                placeDragEl(grid, grid._ptr.x, grid._ptr.y);
            });
        });
        // Standard-Drop-Verhalten unterbinden (verhindert z. B. Navigation,
        // wenn eine Pin-Karte – ein <a> – losgelassen wird).
        grid.addEventListener('drop', function(e) {
            if (grid._dragEl) e.preventDefault();
        });
        grid._onReorder = onReorder;
    }
}

// Verschiebt das gezogene Element an die vom Zeiger bestimmte Stelle –
// aber nur, wenn sich die Position tatsaechlich aendert (kein Flackern).
function placeDragEl(grid, x, y) {
    var dragEl = grid._dragEl;
    var ref = getDragAfterElement(grid, x, y); // Element, VOR das eingefuegt wird (null = ans Ende)
    if (ref === dragEl) return;
    if (ref === null) {
        if (grid.lastElementChild === dragEl) return; // schon am Ende
        grid.appendChild(dragEl);
    } else {
        if (dragEl.nextElementSibling === ref) return; // bereits korrekt platziert
        grid.insertBefore(dragEl, ref);
    }
}

function bindDragItem(grid, item, handleSelector) {
    if (item.dataset.dragBound === '1') return;
    item.dataset.dragBound = '1';

    var handle = handleSelector ? item.querySelector(handleSelector) : item;
    if (!handle) return;

    handle.style.cursor = 'grab';
    handle.addEventListener('mousedown', function() { item.draggable = true; });
    handle.addEventListener('mouseup', function() { item.draggable = false; });
    handle.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); });

    item.addEventListener('dragstart', function(e) {
        grid._dragEl = item;
        grid.classList.add('dragging-active');
        item.classList.add('dragging');
        if (e.dataTransfer) {
            e.dataTransfer.effectAllowed = 'move';
            // Firefox startet den Drag nur, wenn Daten gesetzt werden.
            try { e.dataTransfer.setData('text/plain', item.getAttribute('data-id') || ''); } catch (err) {}
        }
    });
    item.addEventListener('dragend', function() {
        item.classList.remove('dragging');
        item.draggable = false;
        grid.classList.remove('dragging-active');
        grid._dragEl = null;
        grid._ptr = null;
        if (grid._raf) { cancelAnimationFrame(grid._raf); grid._raf = null; }
        if (typeof grid._onReorder === 'function') {
            grid._onReorder();
        }
    });
}

// Bestimmt – monoton und damit flackerfrei – das Element, VOR das das gezogene
// Element eingefuegt werden soll (null = ans Ende). Die Regel ist in Lese-
// reihenfolge stabil: verschiebt sich das gezogene Element nach hinten, rutschen
// vordere Karten nach vorne (Zeiger bleibt rechts von ihnen) und hintere nach
// hinten (Zeiger bleibt links von ihnen) – es entsteht kein Hin-und-Her-Springen.
function getDragAfterElement(grid, x, y) {
    var els = grid.querySelectorAll('.service-card:not(.dragging)');
    for (var i = 0; i < els.length; i++) {
        var b = els[i].getBoundingClientRect();
        if (y < b.top) {
            return els[i];                                  // Zeiger in einer hoeheren Zeile
        }
        if (y <= b.bottom && x < b.left + b.width / 2) {
            return els[i];                                  // gleiche Zeile, links der Mitte
        }
    }
    return null;                                            // hinter allen Elementen
}

function sendOrder(endpoint, ids) {
    fetch(endpoint, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'Accept': 'application/json',
            'X-Requested-With': 'XMLHttpRequest',
            'X-CSRF-Token': SERVICES_CSRF
        },
        body: JSON.stringify({ service_ids: ids })
    })
    .then(function(r) { return r.json(); })
    .then(function(data) { if (data.error) { console.error('Sortier-Fehler:', data.error); } })
    .catch(function(err) { console.error('Sortier-Fehler:', err); });
}

function gridIds(grid) {
    return Array.prototype.slice.call(grid.querySelectorAll('.service-card[data-id]'))
        .map(function(el) { return parseInt(el.getAttribute('data-id'), 10); });
}

// Pins sortieren (alle Nutzer)
function initPinnedSortable() {
    var grid = document.getElementById('pinnedGrid');
    if (!grid) return;
    attachSortable(grid, '.drag-handle', function() {
        sendOrder('/services/pin-order', gridIds(grid));
    });
}

// Services innerhalb Kategorien sortieren (nur Verwaltung)
function initServiceSortable() {
    var grids = document.querySelectorAll('[data-service-grid]');
    if (!grids.length) return;
    grids.forEach(function(grid) {
        attachSortable(grid, '.drag-handle', function() {
            // Gesamte Reihenfolge aller Services (DOM-Reihenfolge) senden
            var allIds = [];
            document.querySelectorAll('[data-service-grid] .service-card[data-id]').forEach(function(el) {
                allIds.push(parseInt(el.getAttribute('data-id'), 10));
            });
            sendOrder('/services/order', allIds);
        });
    });
}

document.addEventListener('DOMContentLoaded', function() {
    initPinnedSortable();
    <?php if ($canManage): ?>initServiceSortable();<?php endif; ?>
});

// Modals
function openModal(id) {
    var modal = document.getElementById(id);
    modal.style.display = 'flex';
    document.body.style.overflow = 'hidden';
    var firstInput = modal.querySelector('input[type="text"], input[type="url"]');
    if (firstInput) firstInput.focus();
}

function closeModal(id) {
    document.getElementById(id).style.display = 'none';
    document.body.style.overflow = '';
}

// Modal schliessen bei Klick auf Overlay oder Escape
document.querySelectorAll('.modal-overlay').forEach(function(modal) {
    modal.addEventListener('click', function(e) {
        if (e.target === this) closeModal(this.id);
    });
});
document.addEventListener('keydown', function(e) {
    if (e.key === 'Escape') {
        document.querySelectorAll('.modal-overlay').forEach(function(m) {
            if (m.style.display !== 'none') closeModal(m.id);
        });
    }
});

<?php if ($canManage): ?>
// Edit-Modal befuellen
function openEditModal(service) {
    document.getElementById('editServiceForm').action = '/services/' + service.id;
    document.getElementById('edit-name').value = service.name || '';
    document.getElementById('edit-url').value = service.url || '';
    document.getElementById('edit-description').value = service.description || '';
    document.getElementById('edit-category').value = service.category_id || '';
    document.getElementById('edit-reach').value = service.reachability || '';
    document.getElementById('edit-remove-icon').checked = false;

    var iconPreview = document.getElementById('edit-current-icon');
    if (service.icon) {
        iconPreview.innerHTML = '<img src="<?= $iconBasePath ?>' + service.icon + '" alt="Icon" style="max-width:48px;max-height:48px;border-radius:0.375rem;">';
    } else {
        iconPreview.innerHTML = '<span style="color:#9ca3af;">Kein Icon</span>';
    }

    openModal('editServiceModal');
}
<?php endif; ?>

// Icon-Vorschau
function previewIcon(input, previewId) {
    var preview = document.getElementById(previewId);
    if (input.files && input.files[0]) {
        var reader = new FileReader();
        reader.onload = function(e) {
            preview.innerHTML = '<img src="' + e.target.result + '" style="max-width:48px;max-height:48px;border-radius:0.375rem;">';
        };
        reader.readAsDataURL(input.files[0]);
    } else {
        preview.innerHTML = '';
    }
}

function previewExistingIcon(filename, previewId) {
    var preview = document.getElementById(previewId);
    if (filename) {
        preview.innerHTML = '<img src="<?= $iconBasePath ?>' + filename + '" style="max-width:48px;max-height:48px;border-radius:0.375rem;">';
    } else {
        preview.innerHTML = '';
    }
}
</script>

<style>
/* ============================================ */
/* IT-Services Styles                            */
/* ============================================ */
.services-fullwidth {
}


/* Content */
.services-content {
    padding: 1.25rem 1rem 3rem;
}

/* Sections */
.pinned-section,
.category-section {
    margin-bottom: 2rem;
}

.section-header {
    margin-bottom: 1rem;
}

.section-header h2 {
    font-size: 0.85rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--gray-700);
    display: flex;
    align-items: center;
    gap: 0.5rem;
    margin: 0;
    padding-bottom: 0.5rem;
    border-bottom: 1px solid var(--gray-200);
}

.section-icon {
    font-size: 1rem;
}

.cat-dot {
    width: 10px;
    height: 10px;
    border-radius: 50%;
    display: inline-block;
    flex-shrink: 0;
}

.section-count {
    font-size: 0.7rem;
    font-weight: 600;
    background: var(--gray-100);
    color: var(--gray-500);
    padding: 0.1rem 0.5rem;
    border-radius: 9999px;
    font-style: normal;
    text-transform: none;
    letter-spacing: 0;
}

/* Service Grid */
.service-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
    gap: 0.75rem;
}

/* Service Card */
.service-card {
    background: var(--color-bg);
    border: 1px solid var(--color-gray-300);
    border-radius: 0.25rem;
    padding: 1rem 1.25rem;
    display: flex;
    align-items: center;
    gap: 0.75rem;
    transition: border-color 0.15s, box-shadow 0.15s;
    position: relative;
}

.service-card:hover {
    border-color: var(--color-primary);
    box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}

.service-card.pinned {
    text-decoration: none;
    color: inherit;
    cursor: pointer;
}

.service-card.is-pinned {
    border-color: var(--color-primary);
    background: var(--color-primary-pale);
}

.service-link,
.service-link:hover,
.service-link:focus,
.service-card.pinned,
.service-card.pinned:hover {
    text-decoration: none !important;
}

.service-link {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    color: inherit;
    flex: 1;
    min-width: 0;
}

/* Service Icon */
.service-icon-wrap {
    width: 42px;
    height: 42px;
    flex-shrink: 0;
    display: flex;
    align-items: center;
    justify-content: center;
}

.service-icon-img {
    max-width: 42px;
    max-height: 42px;
    border-radius: 0.25rem;
    object-fit: contain;
}

.service-icon-letter {
    width: 42px;
    height: 42px;
    border-radius: 0.25rem;
    background: var(--color-primary);
    color: var(--color-on-accent);
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 1.1rem;
    font-weight: 700;
}

/* Service Info */
.service-info {
    flex: 1;
    min-width: 0;
}

.service-name {
    font-weight: 600;
    font-size: 0.9rem;
    color: var(--gray-900);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.service-url {
    font-size: 0.75rem;
    color: var(--gray-400);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* Reachability Badge */
.reach-badge {
    font-size: 0.9rem;
    flex-shrink: 0;
}

/* Drag-Handle */
.drag-handle {
    flex-shrink: 0;
    color: var(--gray-300);
    font-size: 0.85rem;
    line-height: 1;
    letter-spacing: -3px;
    cursor: grab;
    opacity: 0;
    transition: opacity 0.15s, color 0.15s;
    user-select: none;
    padding: 0 0.1rem;
    margin-left: -0.35rem;
}

.service-card:hover .drag-handle {
    opacity: 1;
}

.drag-handle:hover {
    color: var(--gray-500);
}

.service-card.dragging {
    opacity: 0.5;
    border-color: var(--color-primary);
    border-style: dashed;
}

/* Waehrend eines Drags: andere Karten reagieren nicht auf den Zeiger.
   Das verhindert Hover-Effekte/Flackern an der Stelle, wo abgelegt wird,
   und sorgt fuer ein ruhiges Einsortieren. */
.service-grid.dragging-active .service-card:not(.dragging) {
    pointer-events: none;
}

.service-grid.dragging-active .service-card {
    transition: none;
}

/* Actions */
.service-actions {
    display: flex;
    align-items: center;
    gap: 0.25rem;
    flex-shrink: 0;
}

/* Verknuepfte Dokuseiten: eigene Zeile unterhalb von Name und Aktionen. */
.service-card { flex-wrap: wrap; }

.service-docs {
    flex-basis: 100%;
    order: 9;
    display: flex;
    flex-wrap: wrap;
    gap: 0.3rem;
    margin-top: 0.15rem;
}

.service-doc-chip {
    display: inline-flex;
    align-items: center;
    gap: 0.3rem;
    max-width: 100%;
    padding: 0.1rem 0.45rem;
    border-radius: 9999px;
    border: 1px solid var(--gray-200);
    background: var(--gray-50);
    color: var(--gray-600);
    font-size: 0.7rem;
    text-decoration: none;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.service-doc-chip:hover {
    border-color: var(--color-primary);
    color: var(--color-primary);
    text-decoration: none;
}

.pin-btn {
    background: none;
    border: none;
    cursor: pointer;
    font-size: 1.1rem;
    color: var(--gray-300);
    padding: 0.25rem;
    transition: all 0.15s;
    line-height: 1;
}

.pin-btn:hover {
    color: var(--color-warning);
}

.pin-btn.pinned {
    color: var(--color-warning);
}

.edit-btn {
    background: none;
    border: none;
    cursor: pointer;
    font-size: 0.9rem;
    color: var(--gray-400);
    padding: 0.25rem;
    transition: color 0.15s;
}

.edit-btn:hover {
    color: var(--color-primary);
}

.delete-btn {
    background: none;
    border: none;
    cursor: pointer;
    font-size: 1.1rem;
    color: var(--gray-300);
    padding: 0.25rem;
    transition: color 0.15s;
}

.delete-btn:hover {
    color: var(--red-500);
}

.empty-state svg {
    margin-bottom: 1rem;
    opacity: 0.4;
}

.form-group:last-child {
    margin-bottom: 0;
}

.form-row {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 1rem;
}

.icon-preview {
    margin-top: 0.5rem;
    min-height: 20px;
}

/* Buttons */
.btn-secondary {
    background: var(--color-gray-100);
    color: var(--color-gray-700);
    border: 1px solid var(--color-gray-300);
}

.btn-secondary:hover { background: var(--color-gray-200); }

/* Responsive */
@media (max-width: 768px) {
    .services-content {
        padding: 1rem;
    }

    .service-grid {
        grid-template-columns: 1fr;
    }

    .form-row {
        grid-template-columns: 1fr;
    }
}
</style>