Quelltext des aktuellen Standes
<?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">📌</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') ?>">⋮⋮</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' ? '🏢' : '🌐' ?>
</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') ?>">
★
</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') ?>">⋮⋮</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' ? '🏢' : '🌐' ?>
</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 ? '★' : '☆' ?>
</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') ?>">
✎
</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') ?>">×</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')">×</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('🏢 Intern') ?></option>
<option value="external"><?= $this->te('🌐 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')">×</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('🏢 Intern') ?></option>
<option value="external"><?= $this->te('🌐 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, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
}
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>