Quelltext des aktuellen Standes
<?php
$this->layout('layouts/main', [
'page_title' => $page_title,
'current_route' => $current_route,
'full_width' => true,
]);
?>
<style>
a, a:hover { text-decoration: none; }
/* ── Overall layout ── */
.fe-page { display:flex; flex-direction:column; height:calc(100vh - 57px); }
.fe-wrap { flex:1; min-height:0; overflow:hidden; display:flex; flex-direction:column; }
.fe-body { flex:1; display:flex; overflow:hidden; position:relative; }
/* ── Sidebar ── */
.fe-sidebar {
width:230px; flex-shrink:0;
background:var(--slate-50); border-right:1px solid var(--slate-200);
overflow-y:auto; display:flex; flex-direction:column;
}
.fe-sidebar-sec {
padding:0.65rem 0.875rem 0.2rem;
font-size:0.67rem; font-weight:700; text-transform:uppercase;
letter-spacing:0.07em; color:var(--slate-400);
}
.fe-tree-root {
display:flex; align-items:center; gap:0.45rem;
padding:0.42rem 0.875rem; font-size:0.83rem; color:var(--gray-700);
text-decoration:none; border-left:3px solid transparent;
transition:background 0.1s;
}
.fe-tree-root:hover { background:var(--color-gray-200); }
.fe-tree-root.active { background:var(--blue-100); color:var(--blue-700); font-weight:600; border-left-color:var(--blue-500); }
.fe-tree-node {}
.fe-tree-item {
display:flex; align-items:center;
padding:0.38rem 0.875rem 0.38rem 0;
font-size:0.82rem; color:var(--gray-700);
border-left:3px solid transparent;
text-decoration:none; transition:background 0.1s;
white-space:nowrap; overflow:hidden;
}
.fe-tree-item:hover { background:var(--color-gray-200); }
.fe-tree-item.active { background:var(--blue-100); color:var(--blue-700); font-weight:600; border-left-color:var(--blue-500); }
.fe-tree-item span.label { overflow:hidden; text-overflow:ellipsis; flex:1; min-width:0; }
.fe-tree-toggle {
flex-shrink:0; width:18px; height:18px;
display:flex; align-items:center; justify-content:center;
color:var(--slate-400); cursor:pointer; background:none; border:none; padding:0;
transition:transform 0.15s;
}
.fe-tree-toggle.open { transform:rotate(90deg); }
.fe-tree-children { display:none; }
.fe-tree-children.open { display:block; }
.fe-sidebar-divider { height:1px; background:var(--slate-200); margin:0.4rem 0; }
.fe-sidebar-foot { margin-top:auto; padding:0.6rem 0.875rem; border-top:1px solid var(--slate-200); }
.fe-storage-bar-wrap { height:6px; background:var(--slate-200); border-radius:99px; overflow:hidden; margin-bottom:0.3rem; }
.fe-storage-bar { height:6px; background:var(--blue-500); border-radius:99px; }
.fe-storage-text { font-size:0.7rem; color:var(--slate-400); }
/* ── Main ── */
.fe-main { flex:1; display:flex; flex-direction:column; overflow:hidden; background:var(--color-bg-alt); }
/* ── Toolbar ── */
.fe-toolbar {
display:flex; align-items:center; gap:0.6rem; flex-wrap:wrap;
padding:0.65rem 1.25rem; background:var(--color-bg);
border-bottom:1px solid var(--slate-200); flex-shrink:0;
}
.fe-toolbar-breadcrumb { flex:1; min-width:0; display:flex; align-items:center; gap:0.3rem;
flex-wrap:wrap; font-size:0.82rem; }
.fe-toolbar-breadcrumb a { color:var(--color-primary); }
.fe-toolbar-breadcrumb a:hover { text-decoration:underline; }
.fe-toolbar-breadcrumb .sep { color:var(--slate-300); margin:0 0.1rem; }
.fe-toolbar-breadcrumb .cur { color:var(--slate-800); font-weight:600; }
.fe-burger {
display:none; background:none; border:1px solid var(--slate-200); border-radius:5px;
padding:0.3rem 0.45rem; cursor:pointer; color:var(--slate-600);
}
/* ── Search ── */
.fe-search { position:relative; }
.fe-search input {
width:230px; max-width:48vw; padding:0.4rem 0.7rem 0.4rem 1.9rem;
border:1px solid var(--gray-300); border-radius:5px; font-size:0.82rem; outline:none;
transition:border-color .15s, box-shadow .15s;
}
.fe-search input:focus { border-color:var(--blue-500); box-shadow:0 0 0 3px rgba(59,130,246,.1); }
.fe-search svg { position:absolute; left:0.55rem; top:50%; transform:translateY(-50%); color:var(--slate-400); pointer-events:none; }
.fe-search-results {
position:absolute; top:calc(100% + 4px); right:0; width:340px; max-width:80vw;
max-height:60vh; overflow-y:auto; background:var(--color-bg); border:1px solid var(--slate-200);
border-radius:6px; box-shadow:0 12px 36px rgba(0,0,0,.14); z-index:1056; display:none;
}
.fe-search-results.open { display:block; }
.fe-sr-group { font-size:0.66rem; font-weight:700; text-transform:uppercase; letter-spacing:.05em;
color:var(--slate-400); padding:0.5rem 0.85rem 0.25rem; }
.fe-sr-item { display:flex; align-items:center; gap:0.5rem; padding:0.45rem 0.85rem; color:var(--gray-700); }
.fe-sr-item:hover { background:var(--slate-100); }
.fe-sr-name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:0.82rem; }
.fe-sr-path { font-size:0.7rem; color:var(--slate-400); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.fe-sr-empty { padding:0.85rem; font-size:0.8rem; color:var(--slate-400); text-align:center; }
/* ── Scroll area ── */
.fe-scroll { flex:1; overflow-y:auto; padding:1.25rem; }
/* ── Drop zone ── */
.fe-dropzone {
border:2px dashed var(--slate-300); border-radius:6px;
padding:2rem 1rem; text-align:center; color:var(--slate-400);
background:var(--color-bg); cursor:pointer; margin-bottom:1rem;
transition:border-color 0.15s, background 0.15s;
}
.fe-dropzone:hover, .fe-dropzone.dragover {
border-color:var(--blue-500); background:var(--blue-50); color:var(--blue-700);
}
.fe-dropzone-icon { font-size:1.75rem; margin-bottom:0.35rem; }
.fe-dropzone-text { font-size:0.85rem; font-weight:500; }
.fe-dropzone-hint { font-size:0.75rem; margin-top:0.2rem; opacity:.7; }
/* ── Upload list ── */
.fe-upload-list { margin-bottom:0.75rem; }
.fe-upload-item {
background:var(--color-bg); border:1px solid var(--slate-200); border-radius:4px;
padding:0.45rem 0.75rem; margin-bottom:0.35rem;
display:flex; align-items:center; gap:0.5rem; font-size:0.81rem;
}
.fe-upload-name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--gray-700); }
.fe-upload-bar-wrap { width:100px; flex-shrink:0; background:var(--slate-100); border-radius:99px; height:5px; }
.fe-upload-bar { height:5px; border-radius:99px; background:var(--blue-500); width:0; transition:width 0.15s; }
.fe-upload-status { font-size:0.74rem; white-space:nowrap; }
.fe-upload-status.ok { color:var(--green-600); }
.fe-upload-status.err { color:var(--red-600); }
/* ── Bulk action bar ── */
.fe-bulkbar {
display:none; align-items:center; gap:0.75rem;
background:var(--slate-800); color:var(--color-on-accent); border-radius:6px;
padding:0.5rem 0.9rem; margin-bottom:1rem; font-size:0.83rem;
}
.fe-bulkbar.open { display:flex; }
.fe-bulkbar-count { font-weight:600; }
.fe-bulkbar-spacer { flex:1; }
.fe-bulkbar button { font-size:0.8rem; }
.fe-check { width:16px; height:16px; cursor:pointer; accent-color:var(--blue-500); flex-shrink:0; }
/* ── Section headers ── */
.fe-sec {
font-size:0.68rem; font-weight:700; text-transform:uppercase;
letter-spacing:0.06em; color:var(--slate-400); margin:0 0 0.6rem;
}
/* ── Folder cards ── */
.fe-folder-grid {
display:grid; grid-template-columns:repeat(auto-fill, minmax(190px,1fr));
gap:0.65rem; margin-bottom:1.25rem;
}
.fe-folder-card {
background:var(--color-bg); border:1px solid var(--slate-200); border-radius:6px;
display:flex; align-items:flex-start; gap:0.65rem;
padding:0.75rem 0.875rem; cursor:pointer; position:relative;
transition:box-shadow 0.12s, border-color 0.12s;
text-decoration:none; color:inherit;
}
.fe-folder-card:hover { box-shadow:0 2px 10px rgba(0,0,0,.08); border-color:var(--blue-200); }
.fe-folder-card.selected { border-color:var(--blue-500); background:var(--blue-50); }
.fe-folder-card-check { position:absolute; top:6px; left:6px; opacity:0; transition:opacity .1s; z-index:2; }
.fe-folder-card:hover .fe-folder-card-check, .fe-folder-card.selected .fe-folder-card-check { opacity:1; }
.fe-folder-card-icon { font-size:1.5rem; line-height:1; flex-shrink:0; }
.fe-folder-card-body { min-width:0; flex:1; }
.fe-folder-card-name { font-size:0.84rem; font-weight:600; color:var(--slate-800);
word-break:break-word; }
.fe-folder-card-meta { font-size:0.71rem; color:var(--slate-400); margin-top:0.1rem; }
/* Ordner-Aktionen: ein Kebab-Icon klappt die Optionen aus, damit beim Öffnen
des Ordners nicht versehentlich eine Aktion getroffen wird. */
.fe-folder-card.menu-open { z-index:30; }
.fe-folder-menu-wrap { position:absolute; top:4px; right:4px; z-index:3; }
.fe-folder-menu-btn {
display:none; background:rgba(255,255,255,.95); border:1px solid var(--slate-200);
border-radius:4px; width:1.5rem; height:1.5rem; cursor:pointer; color:var(--gray-500);
line-height:1; font-size:1rem; align-items:center; justify-content:center; padding:0;
}
.fe-folder-card:hover .fe-folder-menu-btn,
.fe-folder-menu-wrap.open .fe-folder-menu-btn { display:flex; }
.fe-folder-menu-btn:hover { background:var(--color-bg); color:var(--gray-700); }
.fe-folder-menu {
position:absolute; top:100%; right:0; margin-top:3px; min-width:180px;
background:var(--color-bg); border:1px solid var(--slate-200); border-radius:6px;
box-shadow:0 6px 20px rgba(0,0,0,.14); padding:0.25rem; display:none; z-index:40;
}
.fe-folder-menu-wrap.open .fe-folder-menu { display:block; }
.fe-folder-menu button, .fe-folder-menu a {
display:flex; align-items:center; gap:0.55rem; width:100%;
background:none; border:none; text-align:left; cursor:pointer;
padding:0.4rem 0.55rem; border-radius:4px; font-size:0.8rem; color:var(--gray-700);
text-decoration:none; line-height:1.2;
}
.fe-folder-menu button:hover, .fe-folder-menu a:hover { background:var(--slate-100); }
.fe-folder-menu button.danger { color:var(--red-600); }
.fe-folder-menu button.danger:hover { background:var(--red-50); }
.fe-card-btn {
background:rgba(255,255,255,.95); border:1px solid var(--slate-200);
border-radius:4px; padding:0.2rem 0.35rem; cursor:pointer;
color:var(--gray-500); line-height:1; display:flex; align-items:center;
font-size:0.72rem; transition:color 0.1s;
}
.fe-card-btn:hover { background:var(--color-bg); color:var(--gray-700); }
.fe-card-btn.danger:hover { color:var(--red-600); border-color:var(--red-300); }
/* ── File table ── */
.fe-file-box {
background:var(--color-bg); border:1px solid var(--slate-200); border-radius:6px; overflow:hidden;
}
.fe-file-box table { width:100%; border-collapse:collapse; }
.fe-file-box th {
padding:0.5rem 1rem; text-align:left; font-size:0.69rem; font-weight:700;
text-transform:uppercase; letter-spacing:0.05em; color:var(--slate-400);
background:var(--slate-50); border-bottom:1px solid var(--slate-100); white-space:nowrap;
}
.fe-file-box th.sortable { cursor:pointer; user-select:none; }
.fe-file-box th.sortable:hover { color:var(--slate-600); }
.fe-sort-ind { opacity:.5; font-size:0.6rem; }
.fe-file-box td {
padding:0.6rem 1rem; border-bottom:1px solid var(--slate-50);
font-size:0.84rem; vertical-align:middle;
}
.fe-file-box tbody tr:last-child td { border-bottom:none; }
.fe-file-box tbody tr:hover td { background:var(--slate-50); }
.fe-file-box tbody tr.selected td { background:var(--blue-50); }
.fe-col-check { width:34px; text-align:center; }
.fe-file-name-cell { display:flex; align-items:center; gap:0.5rem; font-weight:500; color:var(--slate-800); }
.fe-file-name-text { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:260px; }
.fe-ext-badge {
font-size:0.68rem; font-weight:700; padding:0.1rem 0.35rem;
border-radius:3px; background:var(--slate-100); color:var(--slate-500); white-space:nowrap; flex-shrink:0;
}
.fe-file-size { color:var(--slate-500); white-space:nowrap; }
.fe-file-date { color:var(--slate-400); font-size:0.79rem; white-space:nowrap; }
.fe-file-by { color:var(--slate-500); font-size:0.79rem; }
.fe-file-actions { display:flex; gap:0.35rem; justify-content:flex-end; }
/* ── Thumbnails ── */
.fe-thumb { width:26px; height:26px; border-radius:4px; object-fit:cover; flex-shrink:0; background:var(--slate-100); }
/* ── View toggle ── */
.fe-viewtoggle { display:inline-flex; border:1px solid var(--gray-300); border-radius:5px; overflow:hidden; }
.fe-viewtoggle button {
background:var(--color-bg); border:none; padding:0.35rem 0.5rem; cursor:pointer; color:var(--slate-500);
display:flex; align-items:center; line-height:1;
}
.fe-viewtoggle button + button { border-left:1px solid var(--slate-200); }
.fe-viewtoggle button.active { background:var(--blue-500); color:var(--color-on-accent); }
/* ── List vs. grid switching ── */
.fe-view-list .fe-files-grid { display:none; }
.fe-view-grid .fe-files-list { display:none; }
/* ── File grid cards ── */
.fe-files-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(150px,1fr)); gap:0.65rem; }
.fe-file-card {
background:var(--color-bg); border:1px solid var(--slate-200); border-radius:6px; position:relative;
display:flex; flex-direction:column; overflow:hidden; transition:box-shadow .12s, border-color .12s;
}
.fe-file-card:hover { box-shadow:0 2px 10px rgba(0,0,0,.08); border-color:var(--blue-200); }
.fe-file-card.selected { border-color:var(--blue-500); background:var(--blue-50); }
.fe-file-card-preview {
height:104px; display:flex; align-items:center; justify-content:center;
background:var(--slate-50); border-bottom:1px solid var(--slate-100); cursor:pointer; overflow:hidden;
}
.fe-file-card-preview img { width:100%; height:100%; object-fit:cover; }
.fe-file-card-body { padding:0.5rem 0.6rem; min-width:0; }
.fe-file-card-name { font-size:0.8rem; font-weight:500; color:var(--slate-800); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.fe-file-card-meta { font-size:0.7rem; color:var(--slate-400); margin-top:0.1rem; }
.fe-file-card-check { position:absolute; top:6px; left:6px; opacity:0; z-index:2; }
.fe-file-card:hover .fe-file-card-check, .fe-file-card.selected .fe-file-card-check { opacity:1; }
.fe-file-card-actions { position:absolute; top:4px; right:4px; display:none; gap:2px; z-index:2; }
.fe-file-card:hover .fe-file-card-actions { display:flex; }
/* ── Drag & Drop verschieben ── */
[draggable="true"] { cursor:grab; }
.fe-drag-active { opacity:0.5; }
.fe-drop-target { outline:2px dashed var(--blue-500); outline-offset:-2px; background:var(--blue-50) !important; }
.fe-tree-item.fe-drop-target, .fe-tree-root.fe-drop-target { background:var(--blue-100) !important; }
/* ── File type icon colors ── */
.fi { display:inline-flex; align-items:center; justify-content:center; width:22px; height:22px; flex-shrink:0; }
.fi svg { width:18px; height:18px; }
.fi-folder { color:var(--amber-500); }
.fi-file { color:var(--slate-400); }
.fi-image { color:var(--violet-500); }
.fi-pdf { color:var(--red-500); }
.fi-text { color:var(--blue-500); }
.fi-sheet { color:var(--green-600); }
.fi-code { color:var(--cyan-500); }
.fi-archive { color:var(--orange-500); }
.fi-exe { color:var(--red-600); }
.fi-video { color:var(--violet-600); }
.fi-audio { color:var(--pink-600); }
/* ── Empty state ── */
.fe-empty-text { font-size:0.875rem; }
.fe-empty-hint { font-size:0.78rem; margin-top:0.25rem; }
/* ── Modals: Basis kommt aus app.css (.modal-overlay/.modal-card, .form-group/.form-control),
hier nur Zustands-Toggle und Explorer-spezifische Maße ── */
.fe-modal { display:none; z-index:1055; }
.fe-modal.open { display:flex; }
.fe-modal .modal-card { max-width:400px; }
.fe-modal .form-group:last-child { margin-bottom:0; }
.fe-modal .fe-label { display:block; font-size:0.8rem; font-weight:600; color:var(--gray-700); margin-bottom:0.35rem; }
/* ── Vorschau-Modal (Bilder & PDFs inline, wie im Ticket-Modul) ── */
.fe-preview-modal { position:fixed; inset:0; z-index:1080; display:flex; align-items:center; justify-content:center; padding:1.25rem 1.5rem; }
.fe-preview-modal[hidden] { display:none; }
.fe-preview-backdrop { position:absolute; inset:0; background:rgba(0,0,0,0.82); cursor:zoom-out; }
.fe-preview-figure { position:relative; z-index:1; margin:0; display:flex; flex-direction:column; align-items:center; gap:0.6rem; width:100%; max-width:100%; max-height:100%; }
.fe-preview-img { max-width:96vw; max-height:calc(100vh - 5rem); border-radius:0.4rem; box-shadow:0 12px 40px rgba(0,0,0,0.5); background:var(--color-bg); }
.fe-preview-img[hidden], .fe-preview-frame[hidden] { display:none; }
.fe-preview-frame { width:min(1600px, 96vw); height:calc(100vh - 5rem); border:none; border-radius:0.4rem; box-shadow:0 12px 40px rgba(0,0,0,0.5); background:var(--color-bg); }
.fe-preview-caption { display:flex; align-items:center; gap:1rem; max-width:100%; color:var(--gray-200); font-size:0.85rem; }
.fe-preview-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:500; }
.fe-preview-link { color:var(--blue-300); text-decoration:none; white-space:nowrap; flex-shrink:0; }
.fe-preview-link:hover { color:var(--blue-200); text-decoration:underline; }
.fe-preview-btn { position:absolute; z-index:2; background:rgba(255,255,255,0.12); border:none; color:var(--color-on-accent); cursor:pointer; border-radius:999px; display:flex; align-items:center; justify-content:center; transition:background-color 0.15s; }
.fe-preview-btn:hover { background:rgba(255,255,255,0.25); }
.fe-preview-close { top:1.1rem; right:1.1rem; width:2.4rem; height:2.4rem; font-size:1.6rem; line-height:1; }
.fe-preview-nav { top:50%; transform:translateY(-50%); width:2.8rem; height:2.8rem; font-size:1.3rem; }
.fe-preview-prev { left:1rem; }
.fe-preview-next { right:1rem; }
@media(max-width:768px) {
.fe-preview-modal { padding:1rem; }
.fe-preview-nav { width:2.2rem; height:2.2rem; }
.fe-preview-prev { left:0.25rem; }
.fe-preview-next { right:0.25rem; }
}
/* ── Mobile ── */
.fe-sidebar-backdrop { display:none; }
@media(max-width:768px) {
.fe-burger { display:inline-flex; }
.fe-sidebar {
position:absolute; top:0; left:0; bottom:0; z-index:1057;
transform:translateX(-100%); transition:transform .2s ease;
box-shadow:0 0 30px rgba(0,0,0,.2);
}
.fe-sidebar.open { transform:translateX(0); }
.fe-sidebar-backdrop.open {
display:block; position:absolute; inset:0; background:rgba(0,0,0,.35); z-index:1056;
}
.fe-folder-grid { grid-template-columns:repeat(auto-fill, minmax(140px,1fr)); }
.fe-hide-sm { display:none !important; }
.fe-search input { width:150px; }
}
</style>
<?php
/* ── Icon helpers ────────────────────────────────────────── */
function feIcon(string $type, int $size = 16, string $extraClass = ''): string
{
$icons = [
'folder' => '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
'home' => '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
'chevron' => '<polyline points="9 18 15 12 9 6"/>',
'plus' => '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
'upload' => '<polyline points="16 16 12 12 8 16"/><line x1="12" y1="12" x2="12" y2="21"/><path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3"/>',
'download' => '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
'trash' => '<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/>',
'edit' => '<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>',
'move' => '<polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/>',
'x' => '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
'file' => '<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/>',
'image' => '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>',
'pdf' => '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>',
'code' => '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><polyline points="10 13 8 15 10 17"/><polyline points="14 13 16 15 14 17"/>',
'archive' => '<path d="M21 8v13H3V8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/>',
'video' => '<polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2"/>',
'music' => '<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>',
'eye' => '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
'search' => '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
'menu' => '<line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>',
'grid' => '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>',
'list' => '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
'zip' => '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/><line x1="12" y1="11" x2="12" y2="17"/><line x1="9" y1="14" x2="15" y2="14"/>',
'history' => '<path d="M3 3v5h5"/><path d="M3.05 13A9 9 0 1 0 6 5.3L3 8"/><polyline points="12 7 12 12 15 14"/>',
'lock' => '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
'activity' => '<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>',
'share' => '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>',
'cog' => '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
];
$d = $icons[$type] ?? $icons['file'];
$cls = match($type) {
'folder' => 'fi fi-folder',
'image' => 'fi fi-image',
'pdf' => 'fi fi-pdf',
'code' => 'fi fi-code',
'archive' => 'fi fi-archive',
'video' => 'fi fi-video',
'music' => 'fi fi-audio',
default => 'fi fi-file',
};
return '<span class="' . $cls . ' ' . htmlspecialchars($extraClass) . '"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="' . $size . '" height="' . $size . '">' . $d . '</svg></span>';
}
function feFileIcon(string $name): string
{
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if (in_array($ext, ['jpg','jpeg','png','gif','bmp','svg','webp','ico','tiff'])) return 'image';
if ($ext === 'pdf') return 'pdf';
if (in_array($ext, ['doc','docx','odt','rtf','txt','md','log'])) return 'file';
if (in_array($ext, ['php','js','ts','py','rb','java','cs','cpp','c','h','sh','bash','zsh','ps1','psm1','cmd','bat','vbs','sql','html','htm','css','xml','json','yaml','yml','toml','ini','conf','cfg','env'])) return 'code';
if (in_array($ext, ['zip','tar','gz','bz2','xz','rar','7z','tgz'])) return 'archive';
if (in_array($ext, ['mp4','mkv','avi','mov','wmv','flv','webm'])) return 'video';
if (in_array($ext, ['mp3','wav','flac','aac','ogg','m4a','wma'])) return 'music';
return 'file';
}
/** Hat die Datei ein Bild-Thumbnail? */
function feHasThumb(string $name): bool
{
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
return in_array($ext, ['jpg','jpeg','png','gif','webp','bmp']);
}
/**
* Vorschau-Typ: image | pdf | text | video | audio | '' (nicht previewbar).
* Muss mit FileExplorerController::previewPlan() übereinstimmen. SVG/HTML/JS
* sind bewusst NICHT previewbar (werden serverseitig als Download erzwungen).
*/
function fePreviewType(string $name): string
{
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if (in_array($ext, ['jpg','jpeg','png','gif','webp'])) return 'image';
if ($ext === 'pdf') return 'pdf';
if (in_array($ext, ['txt','md','log','csv','tsv','json','xml','yaml','yml','ini','conf','cfg','sql','py','sh','bash','ps1','ts','css','c','cpp','h','hpp','java','rb','go','rs','toml','env','properties','tex','diff','patch'])) return 'text';
if (in_array($ext, ['mp4','m4v','webm','ogv'])) return 'video';
if (in_array($ext, ['mp3','wav','ogg','m4a','aac','flac'])) return 'audio';
return '';
}
function feRenderTree(array $nodes, ?int $currentId, int $depth = 0): void
{
foreach ($nodes as $node) {
$id = (int)$node['id'];
$isActive = $currentId === $id;
$hasKids = !empty($node['children']);
$isOpen = $isActive || feIsAncestor($node, $currentId);
$pad = ($depth * 16) + 12;
?>
<div class="fe-tree-node">
<div class="fe-tree-item <?= $isActive ? 'active' : '' ?>"
style="padding-left:<?= $pad ?>px;"
data-drop-folder="<?= $id ?>"
onclick="location.href='/files/folder/<?= $id ?>'">
<?php if ($hasKids): ?>
<button class="fe-tree-toggle <?= $isOpen ? 'open' : '' ?>"
onclick="event.stopPropagation();feTreeToggle(this)" type="button">
<?= feIcon('chevron', 12) ?>
</button>
<?php else: ?>
<span style="width:18px;flex-shrink:0;display:inline-block;"></span>
<?php endif; ?>
<?= feIcon('folder', 14) ?>
<span class="label" style="padding-left:0.25rem;"><?= htmlspecialchars($node['name']) ?></span>
</div>
<?php if ($hasKids): ?>
<div class="fe-tree-children <?= $isOpen ? 'open' : '' ?>">
<?php feRenderTree($node['children'], $currentId, $depth + 1); ?>
</div>
<?php endif; ?>
</div>
<?php
}
}
function feIsAncestor(array $node, ?int $targetId): bool
{
if ($targetId === null) return false;
foreach ($node['children'] ?? [] as $child) {
if ((int)$child['id'] === $targetId) return true;
if (feIsAncestor($child, $targetId)) return true;
}
return false;
}
/** Flache, eingerückte <option>-Liste für den Ziel-Ordner-Picker (Verschieben). */
function feFolderOptions(array $flat): void
{
// nach Pfad sortieren für hierarchische Reihenfolge
usort($flat, fn($a, $b) => strcmp($a['path'], $b['path']));
foreach ($flat as $f) {
$depth = max(0, substr_count(trim((string)$f['path'], '/'), '/'));
$indent = str_repeat("\u{00A0}\u{00A0}", $depth);
echo '<option value="' . (int)$f['id'] . '" data-path="' . htmlspecialchars($f['path']) . '">'
. $indent . htmlspecialchars($f['name']) . '</option>';
}
}
/** Aktions-Buttons einer Datei – wird in Listen- und Kachelansicht wiederverwendet. */
function feFileActions(array $file, bool $canUpload, bool $canDelete): void
{
$id = (int)$file['id'];
$name = (string)$file['original_name'];
$type = fePreviewType($name);
$jn = htmlspecialchars(json_encode($name));
$esc = htmlspecialchars($name);
if ($type !== '') {
echo '<button type="button" class="btn btn-default btn-xs" title="Vorschau"'
. ' data-fe-view="/files/preview/' . $id . '" data-fe-view-name="' . $esc . '"'
. ' data-fe-view-type="' . $type . '" data-fe-view-download="/files/download/' . $id . '">'
. feIcon('eye', 13) . '</button>';
}
echo '<a href="/files/download/' . $id . '" class="btn btn-default btn-xs" title="Herunterladen">'
. feIcon('download', 13) . '</a>';
$vcount = (int)($file['version_count'] ?? 0);
if ($vcount > 0) {
echo '<button class="btn btn-default btn-xs" type="button" title="' . $vcount . ' frühere Version(en)"'
. ' onclick="feOpenVersions(' . $id . ',' . $jn . ')">' . feIcon('history', 13)
. '<span style="font-size:.68rem;margin-left:2px;">' . $vcount . '</span></button>';
}
if ($canUpload) {
echo '<button class="btn btn-default btn-xs" type="button" title="Teilen (Link)"'
. ' onclick="feOpenShare(\'file\',' . $id . ',' . $jn . ')">' . feIcon('share', 13) . '</button>';
echo '<button class="btn btn-default btn-xs" type="button" title="Zugriffe"'
. ' onclick="feOpenAccess(' . $id . ',' . $jn . ')">' . feIcon('activity', 13) . '</button>';
echo '<button class="btn btn-default btn-xs" type="button" title="Umbenennen"'
. ' onclick="feOpenRenameFile(' . $id . ',' . $jn . ')">' . feIcon('edit', 13) . '</button>';
echo '<button class="btn btn-default btn-xs" type="button" title="Verschieben"'
. ' onclick="feOpenMoveFile(' . $id . ',' . $jn . ')">' . feIcon('move', 13) . '</button>';
}
if ($canDelete) {
echo '<button class="btn btn-default btn-xs" type="button" title="Löschen" style="color:var(--red-600);"'
. ' onclick="feOpenDeleteFile(' . $id . ',' . $jn . ')">' . feIcon('trash', 13) . '</button>';
}
}
?>
<div class="fe-page">
<div class="content-header">
<div>
<h1><?= $this->te('Datei-Explorer') ?></h1>
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/"><?= $this->te('Home') ?></a></li>
<li class="breadcrumb-item active"><?= $this->te('Datei-Explorer') ?></li>
</ol>
</div>
<div class="content-header-actions">
<a class="btn btn-default btn-sm" href="/files/trash" title="<?= $this->te('Papierkorb') ?>">
<?= feIcon('trash', 13) ?> <?= $this->te('Papierkorb') ?><?php if (!empty($trash_count)): ?> <span style="background:var(--gray-200);border-radius:99px;padding:0 .4rem;font-size:.72rem;"><?= (int)$trash_count ?></span><?php endif; ?>
</a>
<a class="btn btn-default btn-sm" title="<?= $this->te('Diesen Ordner als ZIP herunterladen') ?>"
href="<?= $current_folder_id !== null ? '/files/folder/' . $current_folder_id . '/zip' : '/files/zip' ?>">
<?= feIcon('download', 13) ?> ZIP
</a>
<?php if ($can_manage_folders): ?>
<button class="btn btn-default btn-sm" onclick="feOpenNewFolder()" type="button">
<?= feIcon('plus', 13) ?> <?= $this->te('Neuer Ordner') ?>
</button>
<?php endif; ?>
<?php if ($can_upload): ?>
<button class="btn btn-primary btn-sm" onclick="document.getElementById('feFileInput').click()" type="button">
<?= feIcon('upload', 13) ?> <?= $this->te('Hochladen') ?>
</button>
<?php endif; ?>
</div>
</div>
<div class="fe-wrap">
<!-- ── Toolbar / breadcrumb ── -->
<div class="fe-toolbar">
<button class="fe-burger" type="button" onclick="feToggleSidebar()" title="<?= $this->te('Ordner') ?>"><?= feIcon('menu', 16) ?></button>
<div class="fe-toolbar-breadcrumb">
<a href="/files" data-drop-folder=""><?= feIcon('home', 14) ?> <?= $this->te('Dateien') ?></a>
<?php foreach ($breadcrumb as $crumb): ?>
<span class="sep">/</span>
<?php if ((int)$crumb['id'] === $current_folder_id): ?>
<span class="cur"><?= htmlspecialchars($crumb['name']) ?></span>
<?php else: ?>
<a href="/files/folder/<?= $crumb['id'] ?>" data-drop-folder="<?= $crumb['id'] ?>"><?= htmlspecialchars($crumb['name']) ?></a>
<?php endif; ?>
<?php endforeach; ?>
</div>
<div class="fe-search">
<?= feIcon('search', 15) ?>
<input type="text" id="feSearchInput" placeholder="<?= $this->te('Suchen…') ?>" autocomplete="off"
oninput="feSearch(this.value)" onfocus="feSearch(this.value)">
<div class="fe-search-results" id="feSearchResults"></div>
</div>
<div class="fe-viewtoggle" title="<?= $this->te('Ansicht') ?>">
<button type="button" id="feViewList" class="<?= $pref_view !== 'grid' ? 'active' : '' ?>"
onclick="feSetView('list')" title="<?= $this->te('Liste') ?>"><?= feIcon('list', 15) ?></button>
<button type="button" id="feViewGrid" class="<?= $pref_view === 'grid' ? 'active' : '' ?>"
onclick="feSetView('grid')" title="<?= $this->te('Kacheln') ?>"><?= feIcon('grid', 15) ?></button>
</div>
</div>
<div class="fe-body">
<div class="fe-sidebar-backdrop" id="feSidebarBackdrop" onclick="feToggleSidebar()"></div>
<!-- ── Sidebar tree ── -->
<div class="fe-sidebar" id="feSidebar">
<div class="fe-sidebar-sec"><?= $this->te('Ordner') ?></div>
<a class="fe-tree-root <?= $current_folder_id === null ? 'active' : '' ?>" href="/files" data-drop-folder="">
<?= feIcon('home', 14) ?>
<span><?= $this->te('Alle Dateien') ?></span>
</a>
<div class="fe-sidebar-divider"></div>
<?php feRenderTree($tree, $current_folder_id); ?>
<?php
$pct = $storage_max > 0 ? min(100, round($storage_used / $storage_max * 100)) : 0;
?>
<div class="fe-sidebar-foot">
<div class="fe-storage-bar-wrap"><div class="fe-storage-bar" style="width:<?= $pct ?>%;"></div></div>
<div class="fe-storage-text"><?= $this->te('{genutzt} von {gesamt} belegt', ['genutzt' => $mgr->formatSize((int)$storage_used), 'gesamt' => $mgr->formatSize((int)$storage_max)]) ?></div>
</div>
</div>
<!-- ── Main scroll area ── -->
<div class="fe-main">
<div class="fe-scroll" id="feDropTarget">
<!-- Upload zone -->
<?php if ($can_upload): ?>
<div class="fe-dropzone" id="feDropzone" onclick="document.getElementById('feFileInput').click()">
<div class="fe-dropzone-icon">📤</div>
<div class="fe-dropzone-text"><?= $this->t('Dateien hierher ziehen oder <strong>klicken</strong> zum Auswählen') ?></div>
<div class="fe-dropzone-hint"><?= $this->t('Alle Formate erlaubt · max. <span id="feMaxLabel"></span> pro Datei') ?></div>
</div>
<input type="file" id="feFileInput" style="display:none" multiple onchange="feUploadFiles(this.files)">
<div class="fe-upload-list" id="feUploadList"></div>
<?php endif; ?>
<!-- Bulk action bar -->
<div class="fe-bulkbar" id="feBulkbar">
<span class="fe-bulkbar-count" id="feBulkCount"><?= $this->te('0 ausgewählt') ?></span>
<span class="fe-bulkbar-spacer"></span>
<button class="btn btn-default btn-sm" type="button" onclick="feBulkZip()"><?= feIcon('zip', 13) ?> <?= $this->te('Als ZIP') ?></button>
<?php if ($can_delete || $can_manage_folders): ?>
<button class="btn btn-danger btn-sm" type="button" onclick="feBulkDelete()"><?= feIcon('trash', 13) ?> <?= $this->te('Löschen') ?></button>
<?php endif; ?>
<button class="btn btn-default btn-sm" type="button" onclick="feClearSelection()"><?= $this->te('Abbrechen') ?></button>
</div>
<!-- Sub-folders -->
<?php if (!empty($subfolders)): ?>
<p class="fe-sec"><?= $this->te('Ordner') ?></p>
<div class="fe-folder-grid">
<?php foreach ($subfolders as $f):
$meta = [];
if ((int)$f['subfolder_count'] > 0) $meta[] = $f['subfolder_count'] . ' Unterordner';
if ((int)$f['file_count'] > 0) $meta[] = $f['file_count'] . ' Datei' . ((int)$f['file_count'] !== 1 ? 'en' : '');
$metaStr = $meta ? implode(' · ', $meta) : 'Leer';
?>
<div class="fe-folder-card" data-folder-id="<?= $f['id'] ?>"
<?php if ($can_manage_folders): ?>draggable="true" data-drag-folder="<?= $f['id'] ?>"<?php endif; ?>
data-drop-folder="<?= $f['id'] ?>"
onclick="location.href='/files/folder/<?= $f['id'] ?>'">
<input type="checkbox" class="fe-check fe-folder-card-check" title="<?= $this->te('Auswählen') ?>"
onclick="event.stopPropagation();feToggleFolder(<?= $f['id'] ?>,this)">
<div class="fe-folder-card-icon">📁</div>
<div class="fe-folder-card-body">
<div class="fe-folder-card-name"><?= htmlspecialchars($f['name']) ?></div>
<div class="fe-folder-card-meta"><?= $metaStr ?></div>
</div>
<div class="fe-folder-menu-wrap">
<button class="fe-folder-menu-btn" type="button" title="<?= $this->te('Optionen') ?>" aria-label="<?= $this->te('Optionen') ?>"
onclick="feToggleFolderMenu(this, event)">⋮</button>
<div class="fe-folder-menu" role="menu">
<a href="/files/folder/<?= $f['id'] ?>/zip" role="menuitem"
onclick="event.stopPropagation();feCloseFolderMenus()"><?= feIcon('download', 13) ?> <?= $this->te('Als ZIP herunterladen') ?></a>
<?php if (!empty($f['acl_can_manage'])): ?>
<button type="button" role="menuitem"
onclick="event.stopPropagation();feCloseFolderMenus();feOpenShare('folder',<?= $f['id'] ?>,<?= htmlspecialchars(json_encode($f['name'])) ?>)"><?= feIcon('share', 13) ?> <?= $this->te('Teilen (Link)') ?></button>
<button type="button" role="menuitem"
onclick="event.stopPropagation();feCloseFolderMenus();feOpenAcl(<?= $f['id'] ?>,<?= htmlspecialchars(json_encode($f['name'])) ?>)"><?= feIcon('lock', 13) ?> <?= $this->te('Berechtigungen') ?></button>
<button type="button" role="menuitem"
onclick="event.stopPropagation();feCloseFolderMenus();feOpenMoveFolder(<?= $f['id'] ?>,<?= htmlspecialchars(json_encode($f['name'])) ?>)"><?= feIcon('move', 13) ?> <?= $this->te('Verschieben') ?></button>
<button type="button" role="menuitem"
onclick="event.stopPropagation();feCloseFolderMenus();feOpenRename(<?= $f['id'] ?>,<?= htmlspecialchars(json_encode($f['name'])) ?>)"><?= feIcon('edit', 13) ?> <?= $this->te('Umbenennen') ?></button>
<button type="button" role="menuitem" class="danger"
onclick="event.stopPropagation();feCloseFolderMenus();feOpenDeleteFolder(<?= $f['id'] ?>,<?= htmlspecialchars(json_encode($f['name'])) ?>)"><?= feIcon('trash', 13) ?> <?= $this->te('Löschen') ?></button>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<!-- Files -->
<?php if (!empty($files)): ?>
<p class="fe-sec" style="margin-top:<?= !empty($subfolders) ? '.75rem' : '0' ?>">
<?= $this->te('Dateien ({anzahl})', ['anzahl' => count($files)]) ?>
</p>
<div id="feFiles" class="fe-view-<?= $pref_view === 'grid' ? 'grid' : 'list' ?>">
<!-- Listenansicht -->
<div class="fe-files-list">
<div class="fe-file-box">
<table id="feFileTable">
<thead>
<tr>
<th scope="col" class="fe-col-check"><input type="checkbox" class="fe-check" title="<?= $this->te('Alle auswählen') ?>" onclick="feToggleAllFiles(this)"></th>
<th scope="col" class="sortable" data-sort-col="name" onclick="feSort(1,'name')">Name <span class="fe-sort-ind">↕</span></th>
<th scope="col" class="fe-hide-sm sortable" data-sort-col="size" onclick="feSort(2,'size')"><?= $this->te('Größe') ?> <span class="fe-sort-ind">↕</span></th>
<th scope="col" class="fe-hide-sm sortable" data-sort-col="by" onclick="feSort(3,'by')"><?= $this->te('Hochgeladen von') ?> <span class="fe-sort-ind">↕</span></th>
<th scope="col" class="fe-hide-sm sortable" data-sort-col="date" onclick="feSort(4,'date')"><?= $this->te('Datum') ?> <span class="fe-sort-ind">↕</span></th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
<?php foreach ($files as $file):
$iconType = feFileIcon($file['original_name']);
$ext = strtoupper(pathinfo($file['original_name'], PATHINFO_EXTENSION)) ?: '–';
$ts = strtotime($file['created_at']);
?>
<tr data-file-id="<?= $file['id'] ?>" <?php if ($can_upload): ?>draggable="true" data-drag-file="<?= $file['id'] ?>"<?php endif; ?>>
<td class="fe-col-check">
<input type="checkbox" class="fe-check" onclick="feToggleFile(<?= $file['id'] ?>,this)">
</td>
<td data-sort="<?= htmlspecialchars(mb_strtolower($file['original_name'])) ?>">
<div class="fe-file-name-cell">
<?php if (feHasThumb($file['original_name'])): ?>
<img class="fe-thumb" draggable="false" loading="lazy" src="/files/thumb/<?= $file['id'] ?>" alt="" onerror="feThumbFail(this)">
<?php else: ?>
<?= feIcon($iconType, 18) ?>
<?php endif; ?>
<span class="fe-file-name-text" title="<?= htmlspecialchars($file['original_name']) ?>">
<?= htmlspecialchars($file['original_name']) ?>
</span>
<span class="fe-ext-badge"><?= htmlspecialchars($ext) ?></span>
</div>
</td>
<td class="fe-file-size fe-hide-sm" data-sort="<?= (int)$file['file_size'] ?>"><?= $mgr->formatSize((int)$file['file_size']) ?></td>
<td class="fe-file-by fe-hide-sm" data-sort="<?= htmlspecialchars(mb_strtolower($file['uploader_name'] ?? '')) ?>"><?= htmlspecialchars($file['uploader_name'] ?? '–') ?></td>
<td class="fe-file-date fe-hide-sm" data-sort="<?= (int)$ts ?>"><?= date('d.m.Y H:i', $ts) ?></td>
<td>
<div class="fe-file-actions"><?php feFileActions($file, $can_upload, $can_delete); ?></div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div><!-- /fe-files-list -->
<!-- Kachelansicht -->
<div class="fe-files-grid">
<?php foreach ($files as $file):
$iconType = feFileIcon($file['original_name']);
$ext = strtoupper(pathinfo($file['original_name'], PATHINFO_EXTENSION)) ?: '–';
$ptype = fePreviewType($file['original_name']);
?>
<div class="fe-file-card" data-file-id="<?= $file['id'] ?>" <?php if ($can_upload): ?>draggable="true" data-drag-file="<?= $file['id'] ?>"<?php endif; ?>>
<input type="checkbox" class="fe-check fe-file-card-check" title="<?= $this->te('Auswählen') ?>" onclick="feToggleFile(<?= $file['id'] ?>,this)">
<div class="fe-file-card-actions"><?php feFileActions($file, $can_upload, $can_delete); ?></div>
<div class="fe-file-card-preview"
<?php if ($ptype !== ''): ?>data-fe-view="/files/preview/<?= $file['id'] ?>"
data-fe-view-name="<?= htmlspecialchars($file['original_name']) ?>"
data-fe-view-type="<?= $ptype ?>" data-fe-view-download="/files/download/<?= $file['id'] ?>"<?php endif; ?>>
<?php if (feHasThumb($file['original_name'])): ?>
<img draggable="false" loading="lazy" src="/files/thumb/<?= $file['id'] ?>" alt="" onerror="feThumbFail(this)">
<?php else: ?>
<?= feIcon($iconType, 40) ?>
<?php endif; ?>
</div>
<div class="fe-file-card-body">
<div class="fe-file-card-name" title="<?= htmlspecialchars($file['original_name']) ?>"><?= htmlspecialchars($file['original_name']) ?></div>
<div class="fe-file-card-meta"><?= htmlspecialchars($ext) ?> · <?= $mgr->formatSize((int)$file['file_size']) ?></div>
</div>
</div>
<?php endforeach; ?>
</div><!-- /fe-files-grid -->
</div><!-- /feFiles -->
<?php elseif (empty($subfolders)): ?>
<div class="empty-state" style="border:none;background:transparent;">
<div class="empty-icon">📂</div>
<div class="fe-empty-text"><?= $this->te('Dieser Ordner ist leer.') ?></div>
<?php if ($can_upload): ?>
<div class="fe-empty-hint"><?= $this->te('Dateien per Drag & Drop ablegen oder auf „Hochladen“ klicken.') ?></div>
<?php endif; ?>
</div>
<?php endif; ?>
</div><!-- /fe-scroll -->
</div><!-- /fe-main -->
</div><!-- /fe-body -->
</div><!-- /fe-wrap -->
</div><!-- /fe-page -->
<!-- ══ Modals ══════════════════════════════════════════════════ -->
<!-- Neuer Ordner -->
<div class="modal-overlay fe-modal" id="modalNewFolder">
<div class="modal-card">
<div class="modal-header">
<h2><?= $this->te('📁 Neuer Ordner') ?></h2>
<button class="modal-close" type="button" onclick="feClose('modalNewFolder')"><?= feIcon('x', 15) ?></button>
</div>
<form method="POST" action="/files/folders">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<input type="hidden" name="parent_id" id="newFolderParentId" value="<?= $current_folder_id ?? '' ?>">
<div class="modal-body">
<label class="fe-label" for="newFolderName"><?= $this->te('Ordnername') ?></label>
<input type="text" id="newFolderName" name="name" class="form-control"
placeholder="<?= $this->te('z.B. Skripte') ?>" maxlength="255" autocomplete="off" required>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default btn-sm" onclick="feClose('modalNewFolder')"><?= $this->te('Abbrechen') ?></button>
<button type="submit" class="btn btn-primary btn-sm"><?= $this->te('Erstellen') ?></button>
</div>
</form>
</div>
</div>
<!-- Ordner umbenennen -->
<div class="modal-overlay fe-modal" id="modalRenameFolder">
<div class="modal-card">
<div class="modal-header">
<h2><?= $this->te('Ordner umbenennen') ?></h2>
<button class="modal-close" type="button" onclick="feClose('modalRenameFolder')"><?= feIcon('x', 15) ?></button>
</div>
<form method="POST" id="renameFolderForm" action="">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<div class="modal-body">
<label class="fe-label" for="renameFolderName"><?= $this->te('Neuer Name') ?></label>
<input type="text" id="renameFolderName" name="name" class="form-control"
placeholder="<?= $this->te('Ordnername') ?>" maxlength="255" autocomplete="off" required>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default btn-sm" onclick="feClose('modalRenameFolder')"><?= $this->te('Abbrechen') ?></button>
<button type="submit" class="btn btn-primary btn-sm"><?= $this->te('Umbenennen') ?></button>
</div>
</form>
</div>
</div>
<!-- Datei umbenennen -->
<div class="modal-overlay fe-modal" id="modalRenameFile">
<div class="modal-card">
<div class="modal-header">
<h2><?= $this->te('Datei umbenennen') ?></h2>
<button class="modal-close" type="button" onclick="feClose('modalRenameFile')"><?= feIcon('x', 15) ?></button>
</div>
<form method="POST" id="renameFileForm" action="">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<div class="modal-body">
<label class="fe-label" for="renameFileName"><?= $this->te('Neuer Dateiname') ?></label>
<input type="text" id="renameFileName" name="name" class="form-control"
placeholder="datei.pdf" maxlength="255" autocomplete="off" required>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default btn-sm" onclick="feClose('modalRenameFile')"><?= $this->te('Abbrechen') ?></button>
<button type="submit" class="btn btn-primary btn-sm"><?= $this->te('Umbenennen') ?></button>
</div>
</form>
</div>
</div>
<!-- Verschieben (Ordner & Datei) -->
<div class="modal-overlay fe-modal" id="modalMove">
<div class="modal-card">
<div class="modal-header">
<h2 id="moveTitle"><?= $this->te('Verschieben') ?></h2>
<button class="modal-close" type="button" onclick="feClose('modalMove')"><?= feIcon('x', 15) ?></button>
</div>
<form method="POST" id="moveForm" action="">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<div class="modal-body">
<p style="font-size:.82rem;color:var(--gray-500);margin:0 0 .6rem;"><?= $this->t('<strong id="moveItemName"></strong> verschieben nach') ?>:</p>
<label class="fe-label" for="moveTarget"><?= $this->te('Zielordner') ?></label>
<select id="moveTarget" name="target_id" class="form-control">
<option value=""><?= $this->te('Alle Dateien (Hauptebene)') ?></option>
<?php feFolderOptions($flat_folders); ?>
</select>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default btn-sm" onclick="feClose('modalMove')"><?= $this->te('Abbrechen') ?></button>
<button type="submit" class="btn btn-primary btn-sm"><?= $this->te('Verschieben') ?></button>
</div>
</form>
</div>
</div>
<!-- Versionen -->
<div class="modal-overlay fe-modal" id="modalVersions">
<div class="modal-card" style="max-width:560px;">
<div class="modal-header">
<h2><?= feIcon('history', 15) ?> <?= $this->te('Versionen:') ?> <span id="versionsFileName"></span></h2>
<button class="modal-close" type="button" onclick="feClose('modalVersions')"><?= feIcon('x', 15) ?></button>
</div>
<div class="modal-body" style="max-height:60vh;overflow-y:auto;">
<div id="versionsList" style="font-size:0.85rem;color:var(--gray-500);"><?= $this->te('Lädt…') ?></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default btn-sm" onclick="feClose('modalVersions')"><?= $this->te('Schließen') ?></button>
</div>
</div>
</div>
<!-- Version wiederherstellen (dynamisch befüllt) -->
<form method="POST" id="versionRestoreForm" action="" style="display:none;">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
</form>
<!-- Zugriffe (Audit-Log) -->
<div class="modal-overlay fe-modal" id="modalAccess">
<div class="modal-card" style="max-width:640px;">
<div class="modal-header">
<h2><?= feIcon('activity', 15) ?> <?= $this->te('Zugriffe:') ?> <span id="accessFileName"></span></h2>
<button class="modal-close" type="button" onclick="feClose('modalAccess')"><?= feIcon('x', 15) ?></button>
</div>
<div class="modal-body" style="max-height:62vh;overflow-y:auto;">
<div id="accessList" style="font-size:0.83rem;color:var(--gray-500);"><?= $this->te('Lädt…') ?></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default btn-sm" onclick="feClose('modalAccess')"><?= $this->te('Schließen') ?></button>
</div>
</div>
</div>
<!-- Teilen (geteilte Links) -->
<div class="modal-overlay fe-modal" id="modalShare">
<div class="modal-card" style="max-width:600px;">
<div class="modal-header">
<h2><?= feIcon('share', 15) ?> <?= $this->te('Teilen:') ?> <span id="shareItemName"></span></h2>
<button class="modal-close" type="button" onclick="feClose('modalShare')"><?= feIcon('x', 15) ?></button>
</div>
<div class="modal-body" style="max-height:62vh;overflow-y:auto;">
<div style="display:flex;gap:.4rem;align-items:flex-end;flex-wrap:wrap;margin-bottom:.85rem;">
<div style="width:150px;">
<label for="shareExpiry" class="fe-label"><?= $this->te('Gültig für') ?></label>
<select id="shareExpiry" class="form-control">
<option value="1"><?= $this->te('1 Tag') ?></option>
<option value="7" selected><?= $this->te('7 Tage') ?></option>
<option value="30"><?= $this->te('30 Tage') ?></option>
<option value="0"><?= $this->te('Unbegrenzt') ?></option>
</select>
</div>
<div style="flex:1;min-width:140px;">
<label class="fe-label"><?= $this->te('Passwort (optional)') ?></label>
<input id="sharePassword" class="form-control" type="text" autocomplete="off" placeholder="<?= $this->te('leer = ohne') ?>">
</div>
<button class="btn btn-primary btn-sm" type="button" onclick="feShareCreate()"><?= $this->te('Link erstellen') ?></button>
</div>
<div id="shareList" style="font-size:0.83rem;color:var(--gray-500);"><?= $this->te('Lädt…') ?></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default btn-sm" onclick="feClose('modalShare')"><?= $this->te('Schließen') ?></button>
</div>
</div>
</div>
<!-- Ordner-Berechtigungen (ACL) -->
<div class="modal-overlay fe-modal" id="modalAcl">
<div class="modal-card" style="max-width:600px;">
<div class="modal-header">
<h2><?= feIcon('lock', 15) ?> <?= $this->te('Berechtigungen:') ?> <span id="aclFolderName"></span></h2>
<button class="modal-close" type="button" onclick="feClose('modalAcl')"><?= feIcon('x', 15) ?></button>
</div>
<div class="modal-body" style="max-height:62vh;overflow-y:auto;">
<p id="aclHint" style="font-size:0.78rem;color:var(--gray-500);margin:0 0 .75rem;"></p>
<div id="aclList" style="font-size:0.85rem;"><?= $this->te('Lädt…') ?></div>
<div style="border-top:1px solid var(--gray-100);margin-top:.85rem;padding-top:.85rem;display:flex;flex-direction:column;gap:.6rem;">
<div style="display:flex;gap:.4rem;align-items:flex-end;flex-wrap:wrap;">
<div style="flex:1;min-width:140px;">
<label for="aclRoleSelect" class="fe-label"><?= $this->te('Rolle') ?></label>
<select id="aclRoleSelect" class="form-control"></select>
</div>
<div style="width:130px;">
<label for="aclRolePerm" class="fe-label"><?= $this->te('Recht') ?></label>
<select id="aclRolePerm" class="form-control">
<option value="view"><?= $this->te('Ansehen') ?></option>
<option value="upload"><?= $this->te('Hochladen') ?></option>
<option value="manage"><?= $this->te('Verwalten') ?></option>
</select>
</div>
<button class="btn btn-default btn-sm" type="button" onclick="feAclAddRole()"><?= $this->te('Hinzufügen') ?></button>
</div>
<div style="display:flex;gap:.4rem;align-items:flex-end;flex-wrap:wrap;">
<div style="flex:1;min-width:140px;">
<label class="fe-label"><?= $this->te('Benutzer (E-Mail)') ?></label>
<input id="aclUserEmail" class="form-control" type="email" placeholder="<?= $this->te('name@firma.de') ?>" autocomplete="off">
</div>
<div style="width:130px;">
<label for="aclUserPerm" class="fe-label"><?= $this->te('Recht') ?></label>
<select id="aclUserPerm" class="form-control">
<option value="view"><?= $this->te('Ansehen') ?></option>
<option value="upload"><?= $this->te('Hochladen') ?></option>
<option value="manage"><?= $this->te('Verwalten') ?></option>
</select>
</div>
<button class="btn btn-default btn-sm" type="button" onclick="feAclAddUser()"><?= $this->te('Hinzufügen') ?></button>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default btn-sm" onclick="feClose('modalAcl')"><?= $this->te('Schließen') ?></button>
</div>
</div>
</div>
<!-- Ordner löschen -->
<div class="modal-overlay fe-modal" id="modalDeleteFolder">
<div class="modal-card">
<div class="modal-header">
<h2 style="color:var(--red-600);"><?= $this->te('⚠️ Ordner löschen') ?></h2>
<button class="modal-close" type="button" onclick="feClose('modalDeleteFolder')"><?= feIcon('x', 15) ?></button>
</div>
<form method="POST" id="deleteFolderForm" action="">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<div class="modal-body">
<p style="font-size:.875rem;color:var(--gray-700);margin:0;">
<?= $this->t('Ordner <strong id="deleteFolderName"></strong> und alle enthaltenen Unterordner und Dateien löschen?') ?>
</p>
<p style="font-size:.8rem;color:var(--red-600);margin:.6rem 0 0;"><?= $this->te('Diese Aktion kann nicht rückgängig gemacht werden.') ?></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default btn-sm" onclick="feClose('modalDeleteFolder')"><?= $this->te('Abbrechen') ?></button>
<button type="submit" class="btn btn-danger btn-sm"><?= $this->te('Endgültig löschen') ?></button>
</div>
</form>
</div>
</div>
<!-- Datei löschen -->
<div class="modal-overlay fe-modal" id="modalDeleteFile">
<div class="modal-card">
<div class="modal-header">
<h2 style="color:var(--red-600);"><?= $this->te('⚠️ Datei löschen') ?></h2>
<button class="modal-close" type="button" onclick="feClose('modalDeleteFile')"><?= feIcon('x', 15) ?></button>
</div>
<form method="POST" id="deleteFileForm" action="">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<div class="modal-body">
<p style="font-size:.875rem;color:var(--gray-700);margin:0;">
<?= $this->t('Datei <strong id="deleteFileName"></strong> wirklich löschen?') ?>
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default btn-sm" onclick="feClose('modalDeleteFile')"><?= $this->te('Abbrechen') ?></button>
<button type="submit" class="btn btn-danger btn-sm"><?= $this->te('Löschen') ?></button>
</div>
</form>
</div>
</div>
<!-- Bulk löschen -->
<form method="POST" id="bulkDeleteForm" action="/files/bulk-delete" style="display:none;">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<input type="hidden" name="folder_id" value="<?= $current_folder_id ?? '' ?>">
<div id="bulkDeleteInputs"></div>
</form>
<!-- Bulk ZIP -->
<form method="POST" id="bulkZipForm" action="/files/bulk-zip" style="display:none;">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<input type="hidden" name="folder_id" value="<?= $current_folder_id ?? '' ?>">
<div id="bulkZipInputs"></div>
</form>
<!-- Vorschau-Modal (Bilder, PDFs, Text, Video & Audio inline) -->
<div id="fePreviewModal" class="fe-preview-modal" hidden>
<div class="fe-preview-backdrop" data-fe-preview-close></div>
<button type="button" class="fe-preview-btn fe-preview-close" data-fe-preview-close
aria-label="<?= $this->te('Schließen') ?>" title="<?= $this->te('Schließen (Esc)') ?>">×</button>
<button type="button" class="fe-preview-btn fe-preview-nav fe-preview-prev"
aria-label="<?= $this->te('Vorheriges') ?>" title="<?= $this->te('Vorheriges (←)') ?>">❮</button>
<figure class="fe-preview-figure" role="dialog" aria-modal="true" aria-label="<?= $this->te('Datei-Vorschau') ?>">
<img id="fePreviewImg" class="fe-preview-img" src="" alt="" hidden>
<iframe id="fePreviewFrame" class="fe-preview-frame" src="" title="<?= $this->te('Vorschau') ?>" hidden></iframe>
<video id="fePreviewVideo" class="fe-preview-frame" controls hidden></video>
<audio id="fePreviewAudio" controls hidden style="width:min(640px,80vw);"></audio>
<figcaption class="fe-preview-caption">
<span id="fePreviewName" class="fe-preview-name"></span>
<a id="fePreviewOpen" class="fe-preview-link" href="#" target="_blank" rel="noopener"><?= $this->te('In neuem Tab öffnen ↗') ?></a>
<a id="fePreviewDownload" class="fe-preview-link" href="#"><?= $this->te('Herunterladen ↧') ?></a>
</figcaption>
</figure>
<button type="button" class="fe-preview-btn fe-preview-nav fe-preview-next"
aria-label="<?= $this->te('Nächstes') ?>" title="<?= $this->te('Nächstes (→)') ?>">❯</button>
</div>
<script>
// ── Config ─────────────────────────────────────────────────
var feMaxUpload = <?= (int)$max_upload_bytes ?>; // max. pro Einzel-Request (post_max_size)
var feHardMax = <?= (int)$hard_max_bytes ?>; // absolutes Datei-Limit des Moduls
var feCurrentFolder = '<?= $current_folder_id ?? '' ?>';
var feFolderPaths = <?= json_encode(array_column($flat_folders, 'path', 'id')) ?>;
// Chunk-Größe: kleiner als das Einzel-Request-Limit (Shared Hosting!), gedeckelt auf 8 MB.
var feChunkSize = Math.max(1048576, Math.min(8388608, feMaxUpload > 0 ? Math.floor(feMaxUpload * 0.8) : 4194304));
document.getElementById('feMaxLabel') && (document.getElementById('feMaxLabel').textContent = feFmtBytes(feHardMax));
function feRandHex(n) {
var a = new Uint8Array(n);
(window.crypto || window.msCrypto).getRandomValues(a);
return Array.prototype.map.call(a, function(b){ return ('0' + b.toString(16)).slice(-2); }).join('');
}
function feFmtBytes(b) {
if (b < 1024) return b + ' B';
if (b < 1048576) return (b/1024).toFixed(1) + ' KB';
if (b < 1073741824) return (b/1048576).toFixed(1) + ' MB';
return (b/1073741824).toFixed(2) + ' GB';
}
// ── Modals ─────────────────────────────────────────────────
function feOpen(id) { document.getElementById(id).classList.add('open'); }
function feClose(id) { document.getElementById(id).classList.remove('open'); }
// Ordner-Optionsmenü (Kebab) auf-/zuklappen.
function feCloseFolderMenus() {
document.querySelectorAll('.fe-folder-menu-wrap.open').forEach(function(w) {
w.classList.remove('open');
var c = w.closest('.fe-folder-card');
if (c) c.classList.remove('menu-open');
});
}
function feToggleFolderMenu(btn, ev) {
if (ev) ev.stopPropagation();
var wrap = btn.closest('.fe-folder-menu-wrap');
if (!wrap) return;
var wasOpen = wrap.classList.contains('open');
feCloseFolderMenus();
if (!wasOpen) {
wrap.classList.add('open');
var card = btn.closest('.fe-folder-card');
if (card) card.classList.add('menu-open');
}
}
document.addEventListener('click', feCloseFolderMenus);
document.addEventListener('keydown', function(e) { if (e.key === 'Escape') feCloseFolderMenus(); });
document.querySelectorAll('.fe-modal').forEach(function(el) {
el.addEventListener('click', function(e) { if (e.target === el) feClose(el.id); });
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
document.querySelectorAll('.fe-modal.open').forEach(function(el){ el.classList.remove('open'); });
feCloseSearch();
}
});
// ── New Folder ─────────────────────────────────────────────
function feOpenNewFolder() {
document.getElementById('newFolderName').value = '';
feOpen('modalNewFolder');
setTimeout(function() { document.getElementById('newFolderName').focus(); }, 80);
}
// ── Rename Folder ──────────────────────────────────────────
function feOpenRename(id, name) {
document.getElementById('renameFolderForm').action = '/files/folders/' + id + '/rename';
document.getElementById('renameFolderName').value = name;
feOpen('modalRenameFolder');
setTimeout(function() {
var el = document.getElementById('renameFolderName'); el.focus(); el.select();
}, 80);
}
// ── Rename File ────────────────────────────────────────────
function feOpenRenameFile(id, name) {
document.getElementById('renameFileForm').action = '/files/files/' + id + '/rename';
document.getElementById('renameFileName').value = name;
feOpen('modalRenameFile');
setTimeout(function() {
var el = document.getElementById('renameFileName'); el.focus();
var dot = name.lastIndexOf('.');
if (dot > 0) el.setSelectionRange(0, dot); else el.select();
}, 80);
}
// ── Move ───────────────────────────────────────────────────
function feOpenMoveFolder(id, name) {
var form = document.getElementById('moveForm');
form.action = '/files/folders/' + id + '/move';
document.getElementById('moveTitle').textContent = 'Ordner verschieben';
document.getElementById('moveItemName').textContent = name;
var movedPath = feFolderPaths[id] || '';
// Eigenen Ordner und alle Nachfahren als Ziel sperren
document.querySelectorAll('#moveTarget option').forEach(function(opt) {
var p = opt.getAttribute('data-path') || '';
opt.disabled = (opt.value == id) || (movedPath && p.indexOf(movedPath) === 0);
});
document.getElementById('moveTarget').value = '';
feOpen('modalMove');
}
function feOpenMoveFile(id, name) {
var form = document.getElementById('moveForm');
form.action = '/files/files/' + id + '/move';
document.getElementById('moveTitle').textContent = 'Datei verschieben';
document.getElementById('moveItemName').textContent = name;
document.querySelectorAll('#moveTarget option').forEach(function(opt){ opt.disabled = false; });
document.getElementById('moveTarget').value = feCurrentFolder;
feOpen('modalMove');
}
// ── Delete Folder / File ───────────────────────────────────
function feOpenDeleteFolder(id, name) {
document.getElementById('deleteFolderForm').action = '/files/folders/' + id + '/delete';
document.getElementById('deleteFolderName').textContent = name;
feOpen('modalDeleteFolder');
}
function feOpenDeleteFile(id, name) {
document.getElementById('deleteFileForm').action = '/files/' + id + '/delete';
document.getElementById('deleteFileName').textContent = name;
feOpen('modalDeleteFile');
}
// ── Versionen ──────────────────────────────────────────────
function feOpenVersions(id, name) {
document.getElementById('versionsFileName').textContent = name;
document.getElementById('versionsList').textContent = 'Lädt…';
feOpen('modalVersions');
fetch('/files/files/' + id + '/versions', { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function(r){ return r.json(); })
.then(function(data){ feRenderVersions(id, data); })
.catch(function(){ document.getElementById('versionsList').textContent = 'Fehler beim Laden.'; });
}
function feRenderVersions(fileId, data) {
var html = '';
html += '<div style="display:flex;align-items:center;gap:.5rem;padding:.5rem .6rem;background:#eff6ff;border:1px solid #bfdbfe;border-radius:5px;margin-bottom:.6rem;">'
+ '<strong style="color:#1e40af;">Aktuell · v' + data.file.version + '</strong>'
+ '<span style="flex:1;"></span>'
+ '<a class="btn btn-default btn-xs" href="/files/download/' + fileId + '">Herunterladen</a></div>';
if (!data.versions || !data.versions.length) {
html += '<div style="color:#94a3b8;">Keine früheren Versionen.</div>';
} else {
data.versions.forEach(function(v) {
html += '<div style="display:flex;align-items:center;gap:.5rem;padding:.5rem .6rem;border-bottom:1px solid #f1f5f9;">'
+ '<span style="font-weight:600;color:#475569;">v' + v.version + '</span>'
+ '<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#64748b;">'
+ feEsc(v.date) + ' · ' + feEsc(v.by) + ' · ' + feEsc(v.size) + '</span>'
+ '<a class="btn btn-default btn-xs" href="/files/versions/' + v.id + '/download">Herunterladen</a>';
if (data.can_restore) {
html += '<button class="btn btn-default btn-xs" type="button" onclick="feRestoreVersion(' + fileId + ',' + v.id + ')">Wiederherstellen</button>';
}
html += '</div>';
});
}
document.getElementById('versionsList').innerHTML = html;
}
function feRestoreVersion(fileId, versionId) {
if (!confirm('Diese Version wiederherstellen? Die aktuelle Fassung wird dabei als neue Version archiviert.')) return;
var form = document.getElementById('versionRestoreForm');
form.action = '/files/files/' + fileId + '/versions/' + versionId + '/restore';
form.submit();
}
// ── Zugriffe (Audit-Log einer Datei) ───────────────────────
function feOpenAccess(id, name) {
document.getElementById('accessFileName').textContent = name;
document.getElementById('accessList').textContent = 'Lädt…';
feOpen('modalAccess');
fetch('/files/files/' + id + '/access', { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function(r){ return r.json(); })
.then(function(data){
if (data.error) { document.getElementById('accessList').textContent = data.error; return; }
if (!data.entries || !data.entries.length) {
document.getElementById('accessList').innerHTML = '<div style="color:#94a3b8;">Noch keine Zugriffe protokolliert.</div>';
return;
}
var html = '<table style="width:100%;border-collapse:collapse;">'
+ '<thead><tr style="text-align:left;color:#94a3b8;font-size:.7rem;text-transform:uppercase;">'
+ '<th style="padding:.3rem .4rem;">Zeit</th><th>Benutzer</th><th>Aktion</th><th>IP</th></tr></thead><tbody>';
data.entries.forEach(function(e){
var col = e.result === 'denied' ? '#dc2626' : '#374151';
html += '<tr style="border-top:1px solid #f1f5f9;color:' + col + ';">'
+ '<td style="padding:.35rem .4rem;white-space:nowrap;">' + feEsc(e.time) + '</td>'
+ '<td>' + feEsc(e.user) + '</td>'
+ '<td>' + feEsc(e.action) + (e.result === 'denied' ? ' (abgelehnt)' : '') + '</td>'
+ '<td style="color:#94a3b8;">' + feEsc(e.ip) + '</td></tr>';
});
html += '</tbody></table>';
document.getElementById('accessList').innerHTML = html;
})
.catch(function(){ document.getElementById('accessList').textContent = 'Fehler beim Laden.'; });
}
// ── Geteilte Links ─────────────────────────────────────────
var feShareType = null, feShareId = null;
function feOpenShare(type, id, name) {
feShareType = type; feShareId = id;
document.getElementById('shareItemName').textContent = name;
document.getElementById('sharePassword').value = '';
document.getElementById('shareList').textContent = 'Lädt…';
feOpen('modalShare');
feShareLoad();
}
function feShareBase() {
return feShareType === 'folder' ? '/files/folders/' + feShareId + '/shares' : '/files/files/' + feShareId + '/shares';
}
function feShareLoad() {
fetch(feShareBase(), { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function(r){ return r.json(); })
.then(feRenderShares)
.catch(function(){ document.getElementById('shareList').textContent = 'Fehler beim Laden.'; });
}
function feRenderShares(data) {
if (data.error) { document.getElementById('shareList').textContent = data.error; return; }
if (!data.shares || !data.shares.length) {
document.getElementById('shareList').innerHTML = '<div style="color:#94a3b8;">Noch keine Links.</div>';
return;
}
var html = '';
data.shares.forEach(function(s){
var meta = [];
meta.push(s.expires ? 'bis ' + s.expires : 'unbegrenzt');
if (s.has_password) meta.push('🔒 Passwort');
meta.push(s.count + 'x abgerufen');
html += '<div style="padding:.5rem 0;border-bottom:1px solid #f1f5f9;">'
+ '<div style="display:flex;gap:.4rem;align-items:center;">'
+ '<input class="form-control" style="flex:1;font-size:.78rem;" readonly value="' + feEsc(s.url) + '" onclick="this.select()">'
+ '<button class="btn btn-default btn-xs" type="button" onclick="feShareCopy(this,\'' + feEsc(s.url) + '\')">Kopieren</button>'
+ '<button class="btn btn-outline-danger btn-xs" type="button" onclick="feShareDelete(' + s.id + ')">Widerrufen</button>'
+ '</div>'
+ '<div style="font-size:.72rem;color:#94a3b8;margin-top:.2rem;">' + feEsc(meta.join(' · ')) + '</div>'
+ '</div>';
});
document.getElementById('shareList').innerHTML = html;
}
function feShareCreate() {
var fd = new FormData();
fd.append('_csrf', feCsrf());
fd.append('expires_days', document.getElementById('shareExpiry').value);
fd.append('password', document.getElementById('sharePassword').value);
fetch(feShareBase(), { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function(r){ return r.json(); })
.then(function(d){ if (d.error) { alert(d.error); } else { document.getElementById('sharePassword').value=''; feShareLoad(); } })
.catch(function(){ alert('Fehler beim Erstellen.'); });
}
function feShareDelete(id) {
if (!confirm('Diesen Link widerrufen? Er funktioniert danach nicht mehr.')) return;
var fd = new FormData();
fd.append('_csrf', feCsrf());
fetch('/files/shares/' + id + '/delete', { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function(r){ return r.json(); })
.then(function(d){ if (d.error) { alert(d.error); } else { feShareLoad(); } });
}
function feShareCopy(btn, url) {
if (navigator.clipboard) { navigator.clipboard.writeText(url); btn.textContent = 'Kopiert'; setTimeout(function(){ btn.textContent='Kopieren'; }, 1200); }
}
// ── Berechtigungen (ACL) ───────────────────────────────────
var feAclFolderId = null;
var fePermLabel = { view: 'Ansehen', upload: 'Hochladen', manage: 'Verwalten' };
function feOpenAcl(id, name) {
feAclFolderId = id;
document.getElementById('aclFolderName').textContent = name;
document.getElementById('aclList').textContent = 'Lädt…';
document.getElementById('aclUserEmail').value = '';
feOpen('modalAcl');
feAclLoad();
}
function feAclLoad() {
fetch('/files/folders/' + feAclFolderId + '/acl', { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function(r){ return r.json(); })
.then(feRenderAcl)
.catch(function(){ document.getElementById('aclList').textContent = 'Fehler beim Laden.'; });
}
function feRenderAcl(data) {
if (data.error) { document.getElementById('aclList').textContent = data.error; return; }
// Rollen-Dropdown füllen
var sel = document.getElementById('aclRoleSelect');
sel.innerHTML = '';
(data.roles || []).forEach(function(r){
var o = document.createElement('option'); o.value = r.id; o.textContent = r.name; sel.appendChild(o);
});
// Hinweis zur Vererbung
var hint = document.getElementById('aclHint');
if (!data.has_own) {
hint.innerHTML = 'Dieser Ordner hat <strong>keine eigenen Berechtigungen</strong> und erbt sie vom übergeordneten Ordner. '
+ 'Ohne Einträge ist er für alle mit globaler Datei-Berechtigung sichtbar. Der erste Eintrag schränkt den Zugriff ein.';
} else {
hint.innerHTML = 'Eigene Berechtigungen gesetzt – nur die unten gelisteten Rollen/Benutzer (plus Admins und der Ordner-Eigentümer) haben Zugriff. Unterordner erben diese Regeln.';
}
// Einträge
var list = document.getElementById('aclList');
if (!data.entries || !data.entries.length) {
list.innerHTML = '<div style="color:#94a3b8;padding:.4rem 0;">Keine eigenen Einträge.</div>';
return;
}
var html = '';
data.entries.forEach(function(e){
var icon = e.type === 'role' ? '👥' : '👤';
var sub = e.type === 'user' && e.email ? ' <span style="color:#94a3b8;">' + feEsc(e.email) + '</span>' : '';
html += '<div style="display:flex;align-items:center;gap:.5rem;padding:.45rem .1rem;border-bottom:1px solid #f1f5f9;">'
+ '<span>' + icon + '</span>'
+ '<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + feEsc(e.name) + sub + '</span>'
+ '<span class="tr-badge" style="background:#eff6ff;color:#1d4ed8;border-radius:99px;padding:.05rem .5rem;font-size:.72rem;">' + (fePermLabel[e.permission] || e.permission) + '</span>'
+ '<button class="btn btn-outline-danger btn-xs" type="button" onclick="feAclRemove(' + e.id + ')">Entfernen</button>'
+ '</div>';
});
list.innerHTML = html;
}
function feAclPost(url, fd) {
var csrf = document.querySelector('meta[name="csrf-token"]');
if (csrf) fd.append('_csrf', csrf.content);
return fetch(url, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function(r){ return r.json(); })
.then(function(d){ if (d.error) { alert(d.error); } else { feAclLoad(); } });
}
function feAclAddRole() {
var fd = new FormData();
fd.append('principal_type', 'role');
fd.append('role_id', document.getElementById('aclRoleSelect').value);
fd.append('permission', document.getElementById('aclRolePerm').value);
feAclPost('/files/folders/' + feAclFolderId + '/acl', fd);
}
function feAclAddUser() {
var email = document.getElementById('aclUserEmail').value.trim();
if (!email) { alert('Bitte eine E-Mail-Adresse angeben.'); return; }
var fd = new FormData();
fd.append('principal_type', 'user');
fd.append('user_email', email);
fd.append('permission', document.getElementById('aclUserPerm').value);
feAclPost('/files/folders/' + feAclFolderId + '/acl', fd).then(function(){
document.getElementById('aclUserEmail').value = '';
});
}
function feAclRemove(entryId) {
var fd = new FormData();
fd.append('entry_id', entryId);
feAclPost('/files/folders/' + feAclFolderId + '/acl/delete', fd);
}
// ── Sidebar (mobile) ───────────────────────────────────────
function feToggleSidebar() {
document.getElementById('feSidebar').classList.toggle('open');
document.getElementById('feSidebarBackdrop').classList.toggle('open');
}
// ── Tree toggle ────────────────────────────────────────────
function feTreeToggle(btn) {
btn.classList.toggle('open');
var children = btn.closest('.fe-tree-node').querySelector('.fe-tree-children');
if (children) children.classList.toggle('open');
}
// ── Selection / Bulk ───────────────────────────────────────
var feSelFiles = new Set();
var feSelFolders = new Set();
function feToggleFile(id, cb) {
if (cb.checked) feSelFiles.add(id); else feSelFiles.delete(id);
cb.closest('tr').classList.toggle('selected', cb.checked);
feUpdateBulk();
}
function feToggleFolder(id, cb) {
if (cb.checked) feSelFolders.add(id); else feSelFolders.delete(id);
cb.closest('.fe-folder-card').classList.toggle('selected', cb.checked);
feUpdateBulk();
}
function feToggleAllFiles(cb) {
document.querySelectorAll('#feFileTable tbody tr').forEach(function(tr) {
var box = tr.querySelector('.fe-check');
box.checked = cb.checked;
feToggleFile(parseInt(tr.getAttribute('data-file-id'), 10), box);
});
}
function feUpdateBulk() {
var n = feSelFiles.size + feSelFolders.size;
var bar = document.getElementById('feBulkbar');
bar.classList.toggle('open', n > 0);
document.getElementById('feBulkCount').textContent = n + ' ausgewählt';
}
function feClearSelection() {
feSelFiles.clear(); feSelFolders.clear();
document.querySelectorAll('.fe-check').forEach(function(c){ c.checked = false; });
document.querySelectorAll('.fe-folder-card.selected, tr.selected').forEach(function(e){ e.classList.remove('selected'); });
feUpdateBulk();
}
function feBulkDelete() {
var n = feSelFiles.size + feSelFolders.size;
if (!n) return;
if (!confirm(n + ' Element(e) endgültig löschen? Ordner werden inkl. Inhalt entfernt.')) return;
var box = document.getElementById('bulkDeleteInputs');
box.innerHTML = '';
feSelFiles.forEach(function(id){
box.insertAdjacentHTML('beforeend', '<input type="hidden" name="file_ids[]" value="' + id + '">');
});
feSelFolders.forEach(function(id){
box.insertAdjacentHTML('beforeend', '<input type="hidden" name="folder_ids[]" value="' + id + '">');
});
document.getElementById('bulkDeleteForm').submit();
}
// ── Sorting (mit gemerkter Präferenz) ──────────────────────
var feSortCol = null, feSortAsc = true;
function feSort(col, key, persist) {
var tbody = document.querySelector('#feFileTable tbody');
if (!tbody) return;
var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
feSortAsc = (feSortCol === col) ? !feSortAsc : true;
feSortCol = col;
rows.sort(function(a, b) {
var av = a.children[col].getAttribute('data-sort') || a.children[col].textContent.trim();
var bv = b.children[col].getAttribute('data-sort') || b.children[col].textContent.trim();
var an = parseFloat(av), bn = parseFloat(bv);
var cmp;
if (!isNaN(an) && !isNaN(bn)) cmp = an - bn;
else cmp = String(av).localeCompare(String(bv), 'de');
return feSortAsc ? cmp : -cmp;
});
rows.forEach(function(r){ tbody.appendChild(r); });
if (persist !== false && key) feSavePref(null, key + ':' + (feSortAsc ? 'asc' : 'desc'));
}
// Gemerkte Sortierung beim Laden anwenden
(function () {
var sort = '<?= htmlspecialchars($pref_sort) ?>';
if (!sort) return;
var parts = sort.split(':');
var colMap = { name:1, size:2, by:3, date:4 };
var col = colMap[parts[0]];
if (!col) return;
// gewünschte Richtung herstellen (feSort toggelt, daher ggf. zweimal)
feSortCol = col; feSortAsc = (parts[1] === 'asc');
feSort(col, parts[0], false);
if ((parts[1] === 'asc') !== feSortAsc) feSort(col, parts[0], false);
})();
// ── Ansicht (Liste/Kacheln) + Präferenz speichern ──────────
function feSetView(view) {
var box = document.getElementById('feFiles');
if (box) box.className = 'fe-view-' + view;
var lb = document.getElementById('feViewList'), gb = document.getElementById('feViewGrid');
if (lb) lb.classList.toggle('active', view === 'list');
if (gb) gb.classList.toggle('active', view === 'grid');
feSavePref(view, null);
}
function feSavePref(view, sort) {
var fd = new FormData();
var csrf = document.querySelector('meta[name="csrf-token"]');
if (csrf) fd.append('_csrf', csrf.content);
if (view) fd.append('view', view);
if (sort) fd.append('sort', sort);
fetch('/files/pref', { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.catch(function(){});
}
// ── Thumbnail-Fallback (wenn keine Vorschau generierbar) ───
function feThumbFail(img) {
var span = document.createElement('span');
span.className = 'fi fi-file';
span.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>';
if (img.parentNode) img.parentNode.replaceChild(span, img);
}
// ── Bulk: Als ZIP herunterladen ────────────────────────────
function feBulkZip() {
var n = feSelFiles.size + feSelFolders.size;
if (!n) return;
var box = document.getElementById('bulkZipInputs');
box.innerHTML = '';
feSelFiles.forEach(function(id){
box.insertAdjacentHTML('beforeend', '<input type="hidden" name="file_ids[]" value="' + id + '">');
});
feSelFolders.forEach(function(id){
box.insertAdjacentHTML('beforeend', '<input type="hidden" name="folder_ids[]" value="' + id + '">');
});
document.getElementById('bulkZipForm').submit();
}
// ── Drag & Drop: Dateien/Ordner verschieben ────────────────
(function () {
var dragType = null, dragId = null;
document.addEventListener('dragstart', function (e) {
var el = e.target.closest('[data-drag-file], [data-drag-folder]');
if (!el) return;
if (el.hasAttribute('data-drag-file')) { dragType = 'file'; dragId = el.getAttribute('data-drag-file'); }
else { dragType = 'folder'; dragId = el.getAttribute('data-drag-folder'); }
el.classList.add('fe-drag-active');
e.dataTransfer.effectAllowed = 'move';
try { e.dataTransfer.setData('text/plain', dragType + ':' + dragId); } catch (err) {}
});
document.addEventListener('dragend', function (e) {
var el = e.target.closest('[data-drag-file], [data-drag-folder]');
if (el) el.classList.remove('fe-drag-active');
clearDropHints();
dragType = null; dragId = null;
});
function targetOf(e) { return e.target.closest('[data-drop-folder]'); }
function clearDropHints() {
document.querySelectorAll('.fe-drop-target').forEach(function(el){ el.classList.remove('fe-drop-target'); });
}
function validTarget(t) {
if (!t || dragId === null) return false;
var targetFolder = t.getAttribute('data-drop-folder'); // '' = Wurzel
if (dragType === 'folder') {
if (targetFolder === dragId) return false; // in sich selbst
var movedPath = feFolderPaths[dragId] || '';
var tPath = targetFolder === '' ? '' : (feFolderPaths[targetFolder] || '');
if (movedPath && tPath && tPath.indexOf(movedPath) === 0) return false; // in Nachfahren
}
return true;
}
document.addEventListener('dragover', function (e) {
if (dragId === null) return;
var t = targetOf(e);
if (!validTarget(t)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (!t.classList.contains('fe-drop-target')) { clearDropHints(); t.classList.add('fe-drop-target'); }
});
document.addEventListener('drop', function (e) {
if (dragId === null) return;
var t = targetOf(e);
if (!validTarget(t)) return;
e.preventDefault();
var targetFolder = t.getAttribute('data-drop-folder');
feMove(dragType, dragId, targetFolder);
});
function feMove(type, id, targetFolder) {
var url = type === 'file' ? '/files/files/' + id + '/move' : '/files/folders/' + id + '/move';
var fd = new FormData();
var csrf = document.querySelector('meta[name="csrf-token"]');
if (csrf) fd.append('_csrf', csrf.content);
fd.append('target_id', targetFolder);
fetch(url, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }, redirect: 'manual' })
.then(function(){ location.reload(); })
.catch(function(){ location.reload(); });
}
})();
// ── Vorschau-Modal (Bilder & PDFs inline, mit Vor/Zurück) ──
(function () {
var modal = document.getElementById('fePreviewModal');
if (!modal) return;
var imgEl = document.getElementById('fePreviewImg');
var frameEl = document.getElementById('fePreviewFrame');
var videoEl = document.getElementById('fePreviewVideo');
var audioEl = document.getElementById('fePreviewAudio');
var nameEl = document.getElementById('fePreviewName');
var openEl = document.getElementById('fePreviewOpen');
var dlEl = document.getElementById('fePreviewDownload');
var prevEl = modal.querySelector('.fe-preview-prev');
var nextEl = modal.querySelector('.fe-preview-next');
var mediaEls = [imgEl, frameEl, videoEl, audioEl];
var nodes = [];
var index = 0;
function dataFor(node) {
return {
url: node.getAttribute('data-fe-view'),
name: node.getAttribute('data-fe-view-name') || '',
type: node.getAttribute('data-fe-view-type') || 'image',
dl: node.getAttribute('data-fe-view-download') || node.getAttribute('data-fe-view')
};
}
function resetMedia() {
mediaEls.forEach(function(el){ el.hidden = true; });
imgEl.src = ''; frameEl.src = '';
videoEl.pause && videoEl.pause(); videoEl.removeAttribute('src'); videoEl.load && videoEl.load();
audioEl.pause && audioEl.pause(); audioEl.removeAttribute('src'); audioEl.load && audioEl.load();
}
function render() {
var d = dataFor(nodes[index]);
resetMedia();
if (d.type === 'image') {
imgEl.src = d.url; imgEl.alt = d.name; imgEl.hidden = false;
} else if (d.type === 'video') {
videoEl.src = d.url; videoEl.hidden = false;
} else if (d.type === 'audio') {
audioEl.src = d.url; audioEl.hidden = false;
} else { // pdf, text
frameEl.src = d.url; frameEl.hidden = false;
}
nameEl.textContent = d.name;
openEl.href = d.url;
dlEl.href = d.dl;
var multiple = nodes.length > 1;
prevEl.style.display = multiple ? '' : 'none';
nextEl.style.display = multiple ? '' : 'none';
}
function open(startNode) {
// Nur sichtbare Trigger der aktiven Ansicht, dedupliziert nach URL
// (Kachelansicht hat pro Datei zwei Trigger: Vorschaufläche + Button).
var all = Array.prototype.slice.call(document.querySelectorAll('[data-fe-view]'))
.filter(function(n){ return n.offsetParent !== null; });
if (!all.length) all = Array.prototype.slice.call(document.querySelectorAll('[data-fe-view]'));
var seen = {};
nodes = [];
all.forEach(function(n){
var u = n.getAttribute('data-fe-view');
if (!seen[u]) { seen[u] = 1; nodes.push(n); }
});
var startUrl = startNode.getAttribute('data-fe-view');
index = nodes.findIndex(function(n){ return n.getAttribute('data-fe-view') === startUrl; });
if (index < 0) index = 0;
render();
modal.removeAttribute('hidden');
document.body.style.overflow = 'hidden';
}
function close() {
modal.setAttribute('hidden', '');
resetMedia();
document.body.style.overflow = '';
}
function step(delta) {
if (nodes.length < 2) return;
index = (index + delta + nodes.length) % nodes.length;
render();
}
document.addEventListener('click', function (e) {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
var node = e.target.closest('[data-fe-view]');
if (!node) return;
e.preventDefault();
open(node);
});
prevEl.addEventListener('click', function () { step(-1); });
nextEl.addEventListener('click', function () { step(1); });
modal.querySelectorAll('[data-fe-preview-close]').forEach(function (el) {
el.addEventListener('click', close);
});
document.addEventListener('keydown', function (e) {
if (modal.hasAttribute('hidden')) return;
if (e.key === 'Escape') { close(); }
else if (e.key === 'ArrowLeft') { step(-1); }
else if (e.key === 'ArrowRight') { step(1); }
});
window.feClosePreview = close;
})();
// ── Search ─────────────────────────────────────────────────
var feSearchTimer = null;
function feSearch(q) {
clearTimeout(feSearchTimer);
q = (q || '').trim();
if (q.length < 2) { feCloseSearch(); return; }
feSearchTimer = setTimeout(function() {
fetch('/files/search?q=' + encodeURIComponent(q), { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function(r){ return r.json(); })
.then(feRenderSearch)
.catch(function(){ feCloseSearch(); });
}, 220);
}
function feRenderSearch(data) {
var box = document.getElementById('feSearchResults');
var html = '';
if (data.folders && data.folders.length) {
html += '<div class="fe-sr-group">Ordner</div>';
data.folders.forEach(function(f) {
html += '<a class="fe-sr-item" href="/files/folder/' + f.id + '">'
+ feSvgFolder() + '<span class="fe-sr-name">' + feEsc(f.name) + '</span>'
+ '<span class="fe-sr-path">' + feEsc(f.path) + '</span></a>';
});
}
if (data.files && data.files.length) {
html += '<div class="fe-sr-group">Dateien</div>';
data.files.forEach(function(f) {
var href = f.folder_id ? '/files/folder/' + f.folder_id : '/files';
html += '<a class="fe-sr-item" href="' + href + '">'
+ '<span class="fe-sr-name">' + feEsc(f.name) + '</span>'
+ '<span class="fe-sr-path">' + feEsc(f.path) + '</span></a>';
});
}
if (!html) html = '<div class="fe-sr-empty">Keine Treffer.</div>';
box.innerHTML = html;
box.classList.add('open');
}
function feCloseSearch() { document.getElementById('feSearchResults').classList.remove('open'); }
function feSvgFolder() {
return '<span class="fi fi-folder"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></span>';
}
document.addEventListener('click', function(e) {
if (!e.target.closest('.fe-search')) feCloseSearch();
});
// ── Drag & Drop ────────────────────────────────────────────
var feTarget = document.getElementById('feDropTarget');
if (feTarget) {
feTarget.addEventListener('dragover', function(e) {
e.preventDefault();
document.getElementById('feDropzone')?.classList.add('dragover');
});
feTarget.addEventListener('dragleave', function(e) {
if (!feTarget.contains(e.relatedTarget))
document.getElementById('feDropzone')?.classList.remove('dragover');
});
feTarget.addEventListener('drop', function(e) {
e.preventDefault();
document.getElementById('feDropzone')?.classList.remove('dragover');
if (e.dataTransfer.files.length) feUploadFiles(e.dataTransfer.files);
});
}
// ── Upload ─────────────────────────────────────────────────
var feUploadPending = 0;
var feUploadOk = 0;
function feUploadFiles(fileList) {
Array.from(fileList).forEach(function(file) { feUploadOne(file); });
var inp = document.getElementById('feFileInput');
if (inp) inp.value = '';
}
var feUploadSeq = 0;
function feUploadOne(file) {
var seq = ++feUploadSeq;
var listEl = document.getElementById('feUploadList');
var barId = 'fe-bar-' + seq;
var stId = 'fe-st-' + seq;
var item = document.createElement('div');
item.className = 'fe-upload-item';
item.innerHTML =
'<span class="fe-upload-name">' + feEsc(file.name) + '</span>' +
'<div class="fe-upload-bar-wrap"><div class="fe-upload-bar" id="' + barId + '"></div></div>' +
'<span class="fe-upload-status" id="' + stId + '">0 %</span>';
listEl.prepend(item);
// Absolutes Modul-Limit – größere Dateien werden gar nicht erst versucht
if (feHardMax > 0 && file.size > feHardMax) {
var stEl = document.getElementById(stId);
document.getElementById(barId).style.background = '#dc2626';
stEl.textContent = 'Zu groß (max. ' + feFmtBytes(feHardMax) + ')';
stEl.className = 'fe-upload-status err';
return;
}
feUploadPending++;
var bar = document.getElementById(barId), st = document.getElementById(stId);
function setPct(p) { bar.style.width = p + '%'; st.innerHTML = p + ' %'; }
function fail(msg) { bar.style.background = '#dc2626'; st.textContent = msg; st.className = 'fe-upload-status err'; feUploadDone(false); }
function done() { bar.style.background = '#16a34a'; bar.style.width = '100%'; st.textContent = 'OK'; st.className = 'fe-upload-status ok'; feUploadDone(true); }
// Große Dateien stückweise hochladen (umgeht kleines post_max_size)
if (file.size > feChunkSize) {
feUploadChunked(file, setPct, done, fail);
} else {
feUploadSingle(file, setPct, done, fail);
}
}
function feCsrf() { var m = document.querySelector('meta[name="csrf-token"]'); return m ? m.content : ''; }
function feUploadSingle(file, setPct, done, fail) {
var fd = new FormData();
fd.append('files[]', file);
if (feCurrentFolder !== '') fd.append('folder_id', feCurrentFolder);
fd.append('_csrf', feCsrf());
var xhr = new XMLHttpRequest();
xhr.open('POST', '/files/upload', true);
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.upload.onprogress = function(e) { if (e.lengthComputable) setPct(Math.round(e.loaded / e.total * 100)); };
xhr.onload = function() {
if (xhr.status === 200) {
try {
var data = JSON.parse(xhr.responseText);
var res = data.results && data.results[0];
if (res && res.ok) { done(); } else { fail((res && res.error) ? res.error : 'Fehler'); }
} catch(e) { fail('Fehler'); }
} else if (xhr.status === 413) { fail('Zu groß für Server'); }
else { fail('HTTP ' + xhr.status); }
};
xhr.onerror = function() { fail('Netzwerkfehler'); };
xhr.send(fd);
}
function feUploadChunked(file, setPct, done, fail) {
var uploadId = feRandHex(24);
var total = Math.ceil(file.size / feChunkSize);
var idx = 0;
function sendNext() {
var start = idx * feChunkSize;
var end = Math.min(file.size, start + feChunkSize);
var blob = file.slice(start, end);
var fd = new FormData();
fd.append('_csrf', feCsrf());
fd.append('upload_id', uploadId);
fd.append('chunk_index', idx);
fd.append('total_chunks', total);
fd.append('filename', file.name);
if (feCurrentFolder !== '') fd.append('folder_id', feCurrentFolder);
fd.append('chunk', blob, file.name);
var xhr = new XMLHttpRequest();
xhr.open('POST', '/files/upload/chunk', true);
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.onload = function() {
if (xhr.status !== 200) { fail('HTTP ' + xhr.status); return; }
var d;
try { d = JSON.parse(xhr.responseText); } catch(e) { fail('Fehler'); return; }
if (d.error) { fail(d.error); return; }
idx++;
setPct(Math.round(idx / total * 100));
if (idx >= total) { done(); } else { sendNext(); }
};
xhr.onerror = function() { fail('Netzwerkfehler'); };
xhr.send(fd);
}
sendNext();
}
function feUploadDone(ok) {
if (ok) feUploadOk++;
feUploadPending--;
// Erst neu laden, wenn ALLE Uploads abgeschlossen sind (kein Race mehr)
if (feUploadPending === 0 && feUploadOk > 0) {
setTimeout(function() { location.reload(); }, 700);
}
}
function feEsc(s) {
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
</script>