Files
docker/remarkable/web/index.php
Borgal b63131c0c5 Add reMarkable backup system with web UI
- Docker container with rmapi cloud backup, PDF conversion pipeline
- Web UI: file browser, multi-select, delete/move, thumbnail preview
- Sync: backup from reMarkable cloud, rmdoc→PDF conversion via rmc+Inkscape
- Excluded-files mechanism to prevent deleted items from returning after sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 23:43:36 +01:00

853 lines
51 KiB
PHP
Executable File

<?php
declare(strict_types=1);
require __DIR__ . '/auth.php';
$RMAPI = getenv('RMAPI_BIN') ?: '/usr/local/bin/rmapi';
$BACKUP = rtrim(getenv('RM_BACKUP_ROOT') ?: '/data/current', '/');
$TMP = '/tmp/rmweb_cache';
@mkdir($TMP, 0700, true);
function h(string $v): string {
return htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function safePath(string $p): string {
if ($p === '' || $p === '/') return '/';
$parts = array_filter(explode('/', $p), fn($s) => $s !== '' && $s !== '.' && $s !== '..');
return '/' . implode('/', array_values($parts));
}
function rmapiRun(string $bin, array $args): array {
$cmd = $bin;
foreach ($args as $a) $cmd .= ' ' . escapeshellarg((string)$a);
exec($cmd . ' 2>&1', $out, $code);
return ['out' => $out, 'ok' => $code === 0];
}
function addWebDeleted(string $backup, string $path): void {
// Pfad ohne Erweiterung speichern (deckt .pdf und .rmdoc ab)
$clean = preg_replace('/\.(pdf|rmdoc)$/i', '', $path);
$file = dirname(rtrim($backup, '/')) . '/.web_deleted';
file_put_contents($file, $clean . "\n", FILE_APPEND | LOCK_EX);
}
function rmapiRmRecursive(string $bin, string $cloudPath): void {
// rmapi find gibt Pfade relativ zum Parent zurück, z.B.
// find /trash/Scoreblatt → "[f] Scoreblatt/Notebook 4"
// Daher Parent-Pfad vorbauen
$parent = rtrim(dirname($cloudPath), '/');
$out = [];
exec($bin . ' find ' . escapeshellarg($cloudPath) . ' 2>/dev/null', $out);
$files = [];
$dirs = [];
foreach ($out as $line) {
if (preg_match('/^\[(f|d)\] (.+)$/', trim($line), $m)) {
$abs = $parent . '/' . ltrim($m[2], '/');
if ($m[1] === 'f') $files[] = $abs;
else $dirs[] = $abs;
}
}
// Dateien zuerst löschen
foreach ($files as $p) rmapiRun($bin, ['rm', $p]);
// Ordner von tief nach oben löschen
rsort($dirs); // tiefste zuerst via Länge
usort($dirs, fn($a,$b) => strlen($b) - strlen($a));
foreach ($dirs as $p) rmapiRun($bin, ['rm', $p]);
// Den Pfad selbst löschen (falls nicht schon in $dirs)
if (!in_array($cloudPath, $dirs)) rmapiRun($bin, ['rm', $cloudPath]);
}
function listDir(string $backup, string $path, bool $showRmdoc, bool $showTrash = true): array {
$dir = rtrim($backup, '/') . ($path === '/' ? '' : $path);
if (!is_dir($dir)) return [];
$items = [];
foreach (scandir($dir) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') continue;
if ($entry === 'trash' && $path === '/' ) continue;
$full = $dir . '/' . $entry;
$lname = strtolower($entry);
$isRmdoc = str_ends_with($lname, '.rmdoc');
$isPdf = str_ends_with($lname, '.pdf');
$isDir = is_dir($full);
if (!$isDir && !$isPdf && !($showRmdoc && $isRmdoc)) continue;
$items[] = [
'name' => $entry,
'type' => $isDir ? 'folder' : 'file',
'size' => is_file($full) ? filesize($full) : 0,
'mtime' => filemtime($full),
'is_pdf' => $isPdf,
'is_rmdoc' => $isRmdoc,
];
}
return $items;
}
function formatSize(int $bytes): string {
if ($bytes < 1024) return $bytes . ' B';
if ($bytes < 1048576) return round($bytes / 1024, 1) . ' KB';
return round($bytes / 1048576, 1) . ' MB';
}
$action = $_POST['action'] ?? $_GET['action'] ?? '';
$path = safePath($_POST['path'] ?? $_GET['path'] ?? '/');
$flash = '';
$flashOk = true;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($action === 'delete') {
$target = safePath($_POST['target'] ?? '');
$localTarget = realpath($BACKUP . $target);
$realBackup = realpath($BACKUP);
if ($target !== '/' && $localTarget !== false && $realBackup !== false
&& str_starts_with($localTarget, $realBackup . '/'))
{
$wasDir = is_dir($localTarget);
$cloudTarget = preg_replace('/\.(pdf|rmdoc)$/i', '', $target);
if ($wasDir) {
shell_exec('rm -rf ' . escapeshellarg($localTarget));
clearstatcache(true, $localTarget);
$ok = !is_dir($localTarget);
} else {
$ok = @unlink($localTarget);
if (str_ends_with(strtolower($target), '.pdf')) {
@unlink($BACKUP . substr($target, 0, -4) . '.rmdoc');
@unlink($BACKUP . substr($target, 0, -4) . '.thumb.jpg');
}
}
if ($wasDir) {
rmapiRmRecursive($RMAPI, $cloudTarget);
$rmResult = ['ok' => true];
} else {
$rmResult = rmapiRun($RMAPI, ['rm', $cloudTarget]);
}
if ($ok) {
addWebDeleted($BACKUP, $target);
$flash = 'Gelöscht: ' . $target;
$flashOk = true;
if (!$rmResult['ok']) $flash .= ' (wird beim nächsten Sync erneut entfernt)';
} else {
$flash = 'Fehler beim Löschen.';
$flashOk = false;
}
}
}
if ($action === 'delete_multi') {
$targets = is_array($_POST['targets'] ?? null) ? $_POST['targets'] : [];
$deleted = 0;
$realBackup = realpath($BACKUP);
foreach ($targets as $t) {
$t = safePath($t);
$lt = $t !== '/' ? realpath($BACKUP . $t) : false;
if (!$lt || !$realBackup || !str_starts_with($lt, $realBackup . '/')) continue;
$ct = preg_replace('/\.(pdf|rmdoc)$/i', '', $t);
if (is_dir($lt)) {
shell_exec('rm -rf ' . escapeshellarg($lt));
clearstatcache(true, $lt);
if (!is_dir($lt)) { $deleted++; addWebDeleted($BACKUP, $t); rmapiRmRecursive($RMAPI, $ct); }
} else {
if (@unlink($lt)) { $deleted++; clearstatcache(true, $lt); addWebDeleted($BACKUP, $t); rmapiRun($RMAPI, ['rm', $ct]); }
}
}
$flash = $deleted . ' Element(e) gelöscht.';
$flashOk = true;
}
if ($action === 'mkdir') {
$name = trim($_POST['name'] ?? '');
$newPath = safePath(rtrim($path, '/') . '/' . $name);
if ($name !== '') {
$r = rmapiRun($RMAPI, ['mkdir', $newPath]);
if ($r['ok']) @mkdir($BACKUP . $newPath, 0755, true);
$flash = $r['ok'] ? 'Ordner erstellt: ' . $newPath : 'Fehler: ' . implode(' ', $r['out']);
$flashOk = $r['ok'];
}
}
if ($action === 'upload') {
$name = trim($_FILES['file']['name'] ?? '');
if ($name !== '' && isset($_FILES['file']['tmp_name']) && is_uploaded_file($_FILES['file']['tmp_name'])) {
$safeName = preg_replace('/[^a-zA-Z0-9_\-\. ]/', '_', $name);
$tmpFile = $TMP . '/' . $safeName;
move_uploaded_file($_FILES['file']['tmp_name'], $tmpFile);
$r = rmapiRun($RMAPI, ['put', $tmpFile, rtrim($path, '/')]);
@unlink($tmpFile);
$flash = $r['ok'] ? 'Hochgeladen: ' . $name : 'Fehler: ' . implode(' ', $r['out']);
$flashOk = $r['ok'];
}
}
if ($action === 'move') {
$src = safePath($_POST['src'] ?? '');
$dst = safePath($_POST['dst'] ?? '');
if ($src !== '/' && $dst !== '' && $src !== $dst) {
$strip = fn(string $p) => preg_replace('/\.(pdf|rmdoc)$/i', '', $p);
$r = rmapiRun($RMAPI, ['mv', $strip($src), $strip($dst)]);
if ($r['ok']) {
$localSrc = realpath($BACKUP . $src);
if ($localSrc) @rename($localSrc, $BACKUP . $dst);
$rmdocSrc = realpath($BACKUP . $strip($src) . '.rmdoc');
if ($rmdocSrc) @rename($rmdocSrc, $BACKUP . $strip($dst) . '.rmdoc');
$thumbSrc = realpath($BACKUP . $strip($src) . '.thumb.jpg');
if ($thumbSrc) @rename($thumbSrc, $BACKUP . $strip($dst) . '.thumb.jpg');
}
$flash = $r['ok'] ? 'Verschoben nach: ' . $dst : 'Fehler: ' . implode(' ', $r['out']);
$flashOk = $r['ok'];
}
}
if ($action === 'move_multi') {
$targets = is_array($_POST['targets'] ?? null) ? $_POST['targets'] : [];
$destDir = safePath($_POST['destination'] ?? '');
$moved = 0;
$strip = fn(string $p) => preg_replace('/\.(pdf|rmdoc)$/i', '', $p);
foreach ($targets as $t) {
$t = safePath($t);
if ($t === '/' || $destDir === '') continue;
$name = basename($t);
$dst = safePath($destDir . '/' . $name);
if ($dst === $t) continue;
$r = rmapiRun($RMAPI, ['mv', $strip($t), $strip($dst)]);
if ($r['ok']) {
$lt = realpath($BACKUP . $t);
if ($lt) @rename($lt, $BACKUP . $dst);
$rs = realpath($BACKUP . $strip($t) . '.rmdoc');
if ($rs) @rename($rs, $BACKUP . $strip($dst) . '.rmdoc');
$rt = realpath($BACKUP . $strip($t) . '.thumb.jpg');
if ($rt) @rename($rt, $BACKUP . $strip($dst) . '.thumb.jpg');
$moved++;
}
}
$flash = $moved . ' Element(e) verschoben nach: ' . $destDir;
$flashOk = true;
}
}
if ($action === 'folders') {
header('Content-Type: application/json');
$browsePath = safePath($_GET['path'] ?? '/');
$dir = rtrim($BACKUP, '/') . ($browsePath === '/' ? '' : $browsePath);
$folders = [];
foreach (scandir($dir) ?: [] as $e) {
if ($e === '.' || $e === '..') continue;
if (is_dir($dir . '/' . $e)) $folders[] = $e;
}
echo json_encode(['path' => $browsePath, 'folders' => $folders]);
exit;
}
if ($action === 'view') {
$target = safePath($_GET['target'] ?? '');
$realLocal = realpath($BACKUP . $target);
$realBackup = realpath($BACKUP);
if (!$realLocal || !$realBackup || !str_starts_with($realLocal, $realBackup . '/')) {
http_response_code(403); echo 'Zugriff verweigert.'; exit;
}
if (!is_file($realLocal)) { http_response_code(404); echo 'Nicht gefunden.'; exit; }
$ext = strtolower(pathinfo($realLocal, PATHINFO_EXTENSION));
$mime = match($ext) { 'pdf' => 'application/pdf', 'jpg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', default => 'application/octet-stream' };
header('Content-Type: ' . $mime);
header('Content-Disposition: inline; filename="' . basename($realLocal) . '"');
header('Content-Length: ' . filesize($realLocal));
readfile($realLocal);
exit;
}
$showRmdoc = isset($_GET['rmdoc']) && $_GET['rmdoc'] === '1';
$showTrash = isset($_GET['trash']) && $_GET['trash'] === '1';
$items = listDir($BACKUP, $path, $showRmdoc, $showTrash);
$sort = $_GET['sort'] ?? 'name';
$order = $_GET['order'] ?? 'asc';
usort($items, function ($a, $b) use ($sort, $order) {
if ($a['type'] !== $b['type']) return $a['type'] === 'folder' ? -1 : 1;
$cmp = strnatcasecmp($a['name'], $b['name']);
return $order === 'desc' ? -$cmp : $cmp;
});
$breadcrumbs = [['label' => 'reMarkable', 'path' => '/']];
$parts = array_filter(explode('/', $path));
$acc = '';
foreach ($parts as $p) { $acc .= '/' . $p; $breadcrumbs[] = ['label' => $p, 'path' => $acc]; }
$parentPath = count($parts) > 0 ? safePath(dirname($path)) : '/';
$folders = array_values(array_filter($items, fn($i) => $i['type'] === 'folder'));
$files = array_values(array_filter($items, fn($i) => $i['type'] === 'file'));
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>reMarkable</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' rx='18' fill='%23000'/%3E%3Ctext y='.9em' font-size='72' font-family='Georgia,serif' font-weight='bold' fill='white' x='50%25' text-anchor='middle'%3Erm%3C/text%3E%3C/svg%3E">
<style>
[x-cloak] { display: none; }
.thumb-img { width:100%; height:100%; object-fit:cover; }
.item-card:has(.sel-cb:checked) .sel-wrap { display:block !important; }
</style>
</head>
<body class="bg-zinc-50 text-zinc-900 min-h-screen overflow-x-hidden" style="font-family:-apple-system,BlinkMacSystemFont,'Inter','Segoe UI',sans-serif;font-size:14px">
<!-- ── Header ─────────────────────────────────────────────────────── -->
<header class="bg-white border-b border-zinc-200 sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-5 flex items-center gap-3 h-14">
<div class="flex items-center gap-2 font-semibold text-[15px] shrink-0 text-zinc-800">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-zinc-400"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
reMarkable
</div>
<div class="w-px h-5 bg-zinc-200 shrink-0 hidden sm:block"></div>
<nav class="hidden sm:flex items-center gap-0.5 flex-1 min-w-0 overflow-hidden text-[13px]">
<?php foreach ($breadcrumbs as $i => $bc):
$isLast = $i === count($breadcrumbs) - 1;
$isFirst = $i === 0;
// Auf Mobile: nur erstes + letztes Element anzeigen, Rest ausblenden
$hideClass = (!$isFirst && !$isLast) ? 'hidden sm:flex' : 'flex';
?>
<?php if ($i > 0): ?><span class="text-zinc-300 px-0.5 text-xs <?= (!$isLast) ? 'hidden sm:inline' : '' ?>">/</span><?php endif; ?>
<div class="<?= $hideClass ?> items-center">
<?php if (!$isLast): ?>
<a href="?path=<?= rawurlencode($bc['path']) ?><?= $showRmdoc ? '&rmdoc=1' : '' ?><?= $showTrash ? '&trash=1' : '' ?>"
class="text-zinc-500 hover:text-zinc-900 hover:bg-zinc-100 px-2 py-1 rounded-md transition-colors whitespace-nowrap"><?= h($bc['label']) ?></a>
<?php else: ?>
<span class="font-medium text-zinc-900 px-2 py-1 whitespace-nowrap"><?= h($bc['label']) ?></span>
<?php endif; ?>
</div>
<?php endforeach; ?>
</nav>
<div class="flex items-center gap-2 shrink-0">
<a href="setup.php" class="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-[12px] font-medium rounded-lg border border-zinc-200 bg-white text-zinc-600 hover:bg-zinc-50 transition-colors" title="Setup">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><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 0 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 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.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 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.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 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.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"/></svg></a>
<a href="?path=%2Ftrash" title="Papierkorb"
class="inline-flex items-center px-2 py-1.5 rounded-lg border border-zinc-200 bg-white text-zinc-400 hover:text-zinc-600 transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
</a>
<button onclick="startSync()" class="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-[12px] font-medium rounded-lg border border-zinc-200 bg-white text-zinc-600 hover:bg-zinc-50 transition-colors" title="Sync">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
<span class="hidden sm:inline">Sync</span>
</button>
<button onclick="openModal('mkdirModal')" class="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-[12px] font-medium rounded-lg border border-zinc-200 bg-white text-zinc-600 hover:bg-zinc-50 transition-colors" title="Neuer Ordner">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<span class="hidden sm:inline">Ordner</span>
</button>
<button onclick="openModal('uploadModal')" class="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-[12px] font-medium rounded-lg bg-zinc-900 text-white hover:bg-zinc-700 transition-colors" title="Upload">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><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"/></svg>
<span class="hidden sm:inline">Upload</span>
</button>
</div>
</div>
</header>
<!-- ── Main ───────────────────────────────────────────────────────── -->
<main class="max-w-7xl mx-auto px-3 sm:px-5 py-4 sm:py-6">
<?php if ($flash !== ''): ?>
<div class="mb-5 flex items-center gap-2 px-4 py-2.5 rounded-xl text-[13px] <?= $flashOk ? 'bg-emerald-50 border border-emerald-200 text-emerald-800' : 'bg-red-50 border border-red-200 text-red-800' ?>">
<?= $flashOk ? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>' : '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>' ?>
<?= h($flash) ?>
</div>
<?php endif; ?>
<!-- Toolbar -->
<div class="flex items-center gap-2 mb-6">
<div class="flex-1 flex items-center gap-2">
<?php if ($path !== '/'): ?>
<a href="?path=<?= rawurlencode($parentPath) ?><?= $showRmdoc ? '&rmdoc=1' : '' ?><?= $showTrash ? '&trash=1' : '' ?>"
class="inline-flex items-center gap-1 px-3 py-1.5 text-[12px] font-medium rounded-lg border border-zinc-200 bg-white text-zinc-600 hover:bg-zinc-50 transition-colors">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="15 18 9 12 15 6"/></svg>
Zurück
</a>
<?php endif; ?>
</div>
<span class="text-[12px] text-zinc-400"><?= count($items) ?> Einträge</span>
<a href="?path=<?= rawurlencode($path) ?><?= $showRmdoc ? '' : '&rmdoc=1' ?>"
class="inline-flex items-center gap-2 px-2.5 py-1.5 text-[12px] rounded-lg border transition-colors <?= $showRmdoc ? 'border-blue-300 bg-blue-50 text-blue-700' : 'border-zinc-200 bg-white text-zinc-500 hover:border-zinc-300' ?>">
<span class="inline-block w-7 h-4 rounded-full relative transition-colors <?= $showRmdoc ? 'bg-blue-500' : 'bg-zinc-300' ?>">
<span class="absolute top-0.5 w-3 h-3 bg-white rounded-full shadow transition-all <?= $showRmdoc ? 'left-3.5' : 'left-0.5' ?>"></span>
</span>
rmdoc
</a>
</div>
<?php if (empty($items)): ?>
<div class="text-center py-24 text-zinc-400">
<svg class="mx-auto mb-4 text-zinc-300" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><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>
<p class="text-base font-medium text-zinc-500">Keine Dokumente vorhanden</p>
<p class="text-sm mt-1">Backup noch nicht ausgeführt oder Ordner ist leer</p>
</div>
<?php else: ?>
<!-- ── Ordner ──────────────────────────────────────────────────── -->
<?php if (!empty($folders)): ?>
<div class="mb-7">
<p class="text-[11px] font-semibold uppercase tracking-widest text-zinc-400 mb-3">Ordner</p>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
<?php foreach ($folders as $item):
$itemPath = safePath(rtrim($path, '/') . '/' . $item['name']); ?>
<div class="group relative flex items-center gap-3 px-4 py-3 bg-white rounded-xl border border-zinc-200 hover:border-zinc-300 hover:shadow-sm cursor-pointer transition-all item-card"
onclick="location.href='?path=<?= rawurlencode($itemPath) ?><?= $showRmdoc ? '&rmdoc=1' : '' ?>'">
<div class="sel-wrap hidden group-hover:block shrink-0" onclick="event.stopPropagation()">
<input type="checkbox" class="sel-cb w-4 h-4 rounded accent-zinc-800" data-path="<?= h($itemPath) ?>" data-folder="1"
onchange="toggleItem(<?= h(json_encode($itemPath)) ?>, this)">
</div>
<div class="w-9 h-9 rounded-xl bg-amber-50 flex items-center justify-center shrink-0">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h3.172a2 2 0 0 1 1.414.586l1.414 1.414A2 2 0 0 0 12.414 8H19a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z" fill="#e8d5b7" stroke="#c4a46b" stroke-width="1.5"/></svg>
</div>
<span class="text-[13px] font-medium text-zinc-700 truncate flex-1 min-w-0"><?= h($item['name']) ?></span>
<div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" onclick="event.stopPropagation()">
<button title="Umbenennen"
onclick="openRename(<?= h(json_encode($itemPath)) ?>,<?= h(json_encode($item['name'])) ?>)"
class="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-700 hover:bg-zinc-100 transition-colors">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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"/></svg>
</button>
<button title="Löschen"
onclick="confirmDelete(<?= h(json_encode($itemPath)) ?>,<?= h(json_encode($item['name'])) ?>, true)"
class="p-1.5 rounded-lg text-zinc-400 hover:text-red-600 hover:bg-red-50 transition-colors">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
</button>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php if (!empty($folders) && !empty($files)): ?>
<div class="border-t border-zinc-200 mb-7"></div>
<?php endif; ?>
<!-- ── Dokumente ───────────────────────────────────────────────── -->
<?php if (!empty($files)): ?>
<div>
<p class="text-[11px] font-semibold uppercase tracking-widest text-zinc-400 mb-3">Dokumente</p>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2 sm:gap-3">
<?php foreach ($files as $item):
$itemPath = safePath(rtrim($path, '/') . '/' . $item['name']);
$displayName = preg_replace('/\.(pdf|rmdoc)$/i', '', $item['name']);
$thumbLocal = $BACKUP . preg_replace('/\.(pdf|rmdoc)$/i', '.thumb.jpg', $item['name'] === basename($itemPath) ? $itemPath : $itemPath);
$thumbUrl = preg_replace('/\.(pdf|rmdoc)$/i', '.thumb.jpg', $itemPath);
$hasThumb = file_exists($BACKUP . $thumbUrl);
?>
<div class="group relative bg-white rounded-xl border border-zinc-200 hover:border-zinc-300 hover:shadow-md cursor-pointer transition-all overflow-hidden flex flex-col item-card"
onclick="openViewer(<?= h(json_encode($itemPath)) ?>,<?= h(json_encode($displayName)) ?>)">
<!-- Vorschau -->
<div class="relative bg-zinc-100 overflow-hidden" style="aspect-ratio:3/4">
<div class="sel-wrap hidden group-hover:block absolute top-1.5 left-1.5 z-20" onclick="event.stopPropagation()">
<input type="checkbox" class="sel-cb w-4 h-4 rounded accent-zinc-800" data-path="<?= h($itemPath) ?>"
onchange="toggleItem(<?= h(json_encode($itemPath)) ?>, this)">
</div>
<?php if ($hasThumb): ?>
<img src="?action=view&target=<?= rawurlencode($thumbUrl) ?>" alt="" class="thumb-img">
<?php else: ?>
<div class="w-full h-full flex items-center justify-center">
<?php if ($item['is_rmdoc']): ?>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#cbd5e1" stroke-width="1.5"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
<?php else: ?>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#cbd5e1" stroke-width="1.5"><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"/></svg>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- Hover-Overlay mit Aktionen -->
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all flex items-start justify-end p-1.5 opacity-0 group-hover:opacity-100">
<div class="flex gap-1" onclick="event.stopPropagation()">
<a href="?action=view&target=<?= rawurlencode($itemPath) ?>" target="_blank"
class="p-1.5 rounded-lg bg-white/90 text-zinc-600 hover:text-zinc-900 shadow-sm transition-colors" title="In neuem Tab öffnen">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</a>
<button onclick="openRename(<?= h(json_encode($itemPath)) ?>,<?= h(json_encode($item['name'])) ?>)"
class="p-1.5 rounded-lg bg-white/90 text-zinc-600 hover:text-zinc-900 shadow-sm transition-colors" title="Umbenennen">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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"/></svg>
</button>
<button onclick="confirmDelete(<?= h(json_encode($itemPath)) ?>,<?= h(json_encode($item['name'])) ?>)"
class="p-1.5 rounded-lg bg-white/90 text-red-500 hover:text-red-700 shadow-sm transition-colors" title="Löschen">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
</button>
</div>
</div>
</div>
<!-- Name -->
<div class="px-2.5 py-2">
<p class="text-[12px] font-medium text-zinc-700 truncate leading-tight"><?= h($displayName) ?></p>
<p class="text-[11px] text-zinc-400 mt-0.5">
<?= $item['is_rmdoc'] ? 'rmdoc' : 'PDF' ?>
<?php if ($item['size'] > 0): ?> · <?= formatSize($item['size']) ?><?php endif; ?>
</p>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php endif; ?>
</main>
<!-- ── Modals ─────────────────────────────────────────────────────── -->
<div id="mkdirModal" class="modal-bg fixed inset-0 bg-black/40 hidden items-center justify-center z-50 p-4" style="backdrop-filter:blur(2px)">
<div class="bg-white rounded-2xl p-6 w-full max-w-sm shadow-2xl border border-zinc-200">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-base">Neuer Ordner</h3>
<button onclick="closeModal('mkdirModal')" class="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-700 hover:bg-zinc-100 transition-colors">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<form method="post" action="?path=<?= rawurlencode($path) ?>">
<input type="hidden" name="action" value="mkdir">
<input type="hidden" name="path" value="<?= h($path) ?>">
<label class="block text-[12px] font-medium text-zinc-500 mb-1.5">Ordnername</label>
<input type="text" name="name" placeholder="Mein Ordner" autofocus required
class="w-full px-3 py-2 border border-zinc-300 rounded-lg text-[13px] focus:outline-none focus:border-zinc-500 focus:ring-2 focus:ring-zinc-200 mb-4">
<div class="flex gap-2 justify-end">
<button type="button" onclick="closeModal('mkdirModal')" class="px-3 py-2 text-[13px] font-medium rounded-lg border border-zinc-200 text-zinc-600 hover:bg-zinc-50 transition-colors">Abbrechen</button>
<button type="submit" class="px-3 py-2 text-[13px] font-medium rounded-lg bg-zinc-900 text-white hover:bg-zinc-700 transition-colors">Erstellen</button>
</div>
</form>
</div>
</div>
<div id="uploadModal" class="modal-bg fixed inset-0 bg-black/40 hidden items-center justify-center z-50 p-4" style="backdrop-filter:blur(2px)">
<div class="bg-white rounded-2xl p-6 w-full max-w-sm shadow-2xl border border-zinc-200">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-base">Dokument hochladen</h3>
<button onclick="closeModal('uploadModal')" class="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-700 hover:bg-zinc-100 transition-colors">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<form method="post" enctype="multipart/form-data" action="?path=<?= rawurlencode($path) ?>">
<input type="hidden" name="action" value="upload">
<input type="hidden" name="path" value="<?= h($path) ?>">
<label class="block text-[12px] font-medium text-zinc-500 mb-1.5">PDF oder EPUB</label>
<input type="file" name="file" accept=".pdf,.epub" required
class="w-full text-[13px] text-zinc-600 file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0 file:text-[12px] file:font-medium file:bg-zinc-100 file:text-zinc-700 hover:file:bg-zinc-200 mb-4">
<div class="flex gap-2 justify-end">
<button type="button" onclick="closeModal('uploadModal')" class="px-3 py-2 text-[13px] font-medium rounded-lg border border-zinc-200 text-zinc-600 hover:bg-zinc-50 transition-colors">Abbrechen</button>
<button type="submit" class="px-3 py-2 text-[13px] font-medium rounded-lg bg-zinc-900 text-white hover:bg-zinc-700 transition-colors">Hochladen</button>
</div>
</form>
</div>
</div>
<div id="deleteModal" class="modal-bg fixed inset-0 bg-black/40 hidden items-center justify-center z-50 p-4" style="backdrop-filter:blur(2px)">
<div class="bg-white rounded-2xl p-6 w-full max-w-sm shadow-2xl border border-zinc-200">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-base">Löschen bestätigen</h3>
<button onclick="closeModal('deleteModal')" class="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-700 hover:bg-zinc-100 transition-colors">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<p id="deleteLabel" class="text-[13px] text-zinc-500 mb-3"></p>
<div id="deleteFolderWarn" class="hidden mb-4 flex items-start gap-2 bg-red-50 border border-red-200 rounded-lg px-3 py-2.5">
<svg class="shrink-0 mt-0.5" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#dc2626" stroke-width="2" stroke-linecap="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span class="text-[12px] text-red-700">Achtung: Der gesamte Inhalt des Ordners wird ebenfalls unwiderruflich gelöscht!</span>
</div>
<form method="post" action="?path=<?= rawurlencode($path) ?>" onsubmit="showLoading('Wird gelöscht…')">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="path" value="<?= h($path) ?>">
<input type="hidden" name="target" id="deleteTarget">
<div class="flex gap-2 justify-end">
<button type="button" onclick="closeModal('deleteModal')" class="px-3 py-2 text-[13px] font-medium rounded-lg border border-zinc-200 text-zinc-600 hover:bg-zinc-50 transition-colors">Abbrechen</button>
<button type="submit" class="px-3 py-2 text-[13px] font-medium rounded-lg bg-red-600 text-white hover:bg-red-700 transition-colors">Löschen</button>
</div>
</form>
</div>
</div>
<div id="renameModal" class="modal-bg fixed inset-0 bg-black/40 hidden items-center justify-center z-50 p-4" style="backdrop-filter:blur(2px)">
<div class="bg-white rounded-2xl p-6 w-full max-w-sm shadow-2xl border border-zinc-200">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-base">Umbenennen / Verschieben</h3>
<button onclick="closeModal('renameModal')" class="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-700 hover:bg-zinc-100 transition-colors">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<form method="post" action="?path=<?= rawurlencode($path) ?>">
<input type="hidden" name="action" value="move">
<input type="hidden" name="path" value="<?= h($path) ?>">
<input type="hidden" name="src" id="renameSrc">
<label class="block text-[12px] font-medium text-zinc-500 mb-1.5">Neuer Pfad</label>
<input type="text" name="dst" id="renameDst" placeholder="/Ordner/Dateiname" required
class="w-full px-3 py-2 border border-zinc-300 rounded-lg text-[13px] focus:outline-none focus:border-zinc-500 focus:ring-2 focus:ring-zinc-200 mb-4">
<div class="flex gap-2 justify-end">
<button type="button" onclick="closeModal('renameModal')" class="px-3 py-2 text-[13px] font-medium rounded-lg border border-zinc-200 text-zinc-600 hover:bg-zinc-50 transition-colors">Abbrechen</button>
<button type="submit" class="px-3 py-2 text-[13px] font-medium rounded-lg bg-zinc-900 text-white hover:bg-zinc-700 transition-colors">Speichern</button>
</div>
</form>
</div>
</div>
<div id="viewerModal" class="modal-bg fixed inset-0 bg-black/50 hidden items-end sm:items-center justify-center z-50 p-0 sm:p-4" style="backdrop-filter:blur(2px)">
<div class="bg-white rounded-t-2xl sm:rounded-2xl w-full sm:max-w-4xl shadow-2xl border border-zinc-200 flex flex-col overflow-hidden" style="height:92vh;max-height:92vh">
<div class="flex items-center justify-between px-4 py-3 border-b border-zinc-200 shrink-0">
<span id="viewerTitle" class="font-medium text-[14px] truncate min-w-0 mr-4"></span>
<div class="flex gap-2 shrink-0">
<a id="viewerOpen" href="#" target="_blank" class="inline-flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-lg border border-zinc-200 text-zinc-600 hover:bg-zinc-50 transition-colors">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Neues Tab
</a>
<button onclick="closeModal('viewerModal')" class="inline-flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-lg border border-zinc-200 text-zinc-600 hover:bg-zinc-50 transition-colors">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
Schließen
</button>
</div>
</div>
<iframe id="viewerFrame" src="" class="flex-1 border-none w-full"></iframe>
</div>
</div>
<div id="syncModal" class="modal-bg fixed inset-0 bg-black/40 hidden items-center justify-center z-50 p-4" style="backdrop-filter:blur(2px)">
<div class="bg-white rounded-2xl p-6 w-full max-w-lg shadow-2xl border border-zinc-200">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-base">Sync</h3>
<button id="syncClose" onclick="closeModal('syncModal')" class="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-700 hover:bg-zinc-100 transition-colors">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<pre id="syncOutput" class="bg-zinc-950 text-zinc-300 p-4 rounded-xl text-[12px] leading-relaxed max-h-80 overflow-y-auto whitespace-pre-wrap break-all font-mono">Warte auf Start...</pre>
<div id="syncDone" class="mt-4 hidden">
<button onclick="location.reload()" class="w-full py-2 text-[13px] font-medium rounded-lg bg-zinc-900 text-white hover:bg-zinc-700 transition-colors">Seite neu laden</button>
</div>
</div>
</div>
<!-- Loading Overlay -->
<div id="loadingOverlay" class="hidden fixed inset-0 bg-white/70 backdrop-blur-sm z-[60] flex items-center justify-center">
<div class="bg-white rounded-2xl shadow-2xl border border-zinc-200 px-8 py-6 flex flex-col items-center gap-3">
<svg class="animate-spin text-zinc-400" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
<p id="loadingText" class="text-[13px] font-medium text-zinc-600">Wird verarbeitet…</p>
</div>
</div>
<!-- Mehrfachauswahl: Löschen bestätigen -->
<div id="bulkDeleteModal" class="modal-bg fixed inset-0 bg-black/40 hidden items-center justify-center z-50 p-4" style="backdrop-filter:blur(2px)">
<div class="bg-white rounded-2xl p-6 w-full max-w-sm shadow-2xl border border-zinc-200">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-base">Auswahl löschen</h3>
<button onclick="closeModal('bulkDeleteModal')" class="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-700 hover:bg-zinc-100 transition-colors">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<p id="bulkDeleteLabel" class="text-[13px] text-zinc-500 mb-3"></p>
<div id="bulkDeleteFolderWarn" class="hidden mb-4 flex items-start gap-2 bg-red-50 border border-red-200 rounded-lg px-3 py-2.5">
<svg class="shrink-0 mt-0.5" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#dc2626" stroke-width="2" stroke-linecap="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span class="text-[12px] text-red-700">Achtung: Ausgewählte Ordner werden mit ihrem gesamten Inhalt gelöscht!</span>
</div>
<div class="flex gap-2 justify-end">
<button onclick="closeModal('bulkDeleteModal')" class="px-3 py-2 text-[13px] font-medium rounded-lg border border-zinc-200 text-zinc-600 hover:bg-zinc-50 transition-colors">Abbrechen</button>
<button onclick="submitBulkDelete()" class="px-3 py-2 text-[13px] font-medium rounded-lg bg-red-600 text-white hover:bg-red-700 transition-colors">Unwiderruflich löschen</button>
</div>
</div>
</div>
<!-- Mehrfachauswahl: Verschieben -->
<div id="bulkMoveModal" class="modal-bg fixed inset-0 bg-black/40 hidden items-center justify-center z-50 p-4" style="backdrop-filter:blur(2px)">
<div class="bg-white rounded-2xl w-full max-w-sm shadow-2xl border border-zinc-200 flex flex-col" style="max-height:80vh">
<div class="flex items-center justify-between px-5 pt-5 pb-3 shrink-0">
<h3 class="font-semibold text-base">Zielordner wählen</h3>
<button onclick="closeModal('bulkMoveModal')" class="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-700 hover:bg-zinc-100 transition-colors">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<!-- Breadcrumb -->
<div id="folderBreadcrumb" class="flex items-center gap-1 px-5 pb-2 text-[12px] text-zinc-500 shrink-0 flex-wrap"></div>
<!-- Ordner-Liste -->
<div id="folderList" class="flex-1 overflow-y-auto border-t border-b border-zinc-100 divide-y divide-zinc-50 min-h-[120px]"></div>
<!-- Aktuelle Auswahl + Buttons -->
<div class="px-5 py-4 shrink-0">
<p class="text-[11px] text-zinc-400 mb-1">Ziel:</p>
<p id="folderSelected" class="text-[13px] font-medium text-zinc-700 mb-3 truncate">/</p>
<div class="flex gap-2 justify-end">
<button onclick="closeModal('bulkMoveModal')" class="px-3 py-2 text-[13px] font-medium rounded-lg border border-zinc-200 text-zinc-600 hover:bg-zinc-50 transition-colors">Abbrechen</button>
<button onclick="submitBulkMove()" class="px-3 py-2 text-[13px] font-medium rounded-lg bg-zinc-900 text-white hover:bg-zinc-700 transition-colors">Hierher verschieben</button>
</div>
</div>
</div>
</div>
<!-- Mehrfachauswahl-Bar -->
<div id="selBar" class="hidden fixed bottom-6 left-1/2 -translate-x-1/2 bg-zinc-900 text-white rounded-2xl shadow-2xl px-4 py-3 flex items-center gap-3 z-40">
<label class="flex items-center gap-2 text-[13px] cursor-pointer select-none">
<input type="checkbox" id="selAll" class="w-4 h-4 rounded" onchange="toggleAll(this)">
<span id="selCount">0 ausgewählt</span>
</label>
<button onclick="bulkMove()" class="flex items-center gap-1.5 px-3 py-1.5 bg-zinc-700 hover:bg-zinc-600 rounded-lg text-[12px] font-medium transition-colors">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><polyline points="12 5 19 12 12 19"/></svg>
Verschieben
</button>
<button onclick="bulkDelete()" class="flex items-center gap-1.5 px-3 py-1.5 bg-red-500 hover:bg-red-600 rounded-lg text-[12px] font-medium transition-colors">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
Löschen
</button>
<button onclick="cancelSelection()" class="p-1.5 rounded-lg hover:bg-zinc-700 transition-colors" title="Abbrechen">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<script>
function openModal(id) {
const el = document.getElementById(id);
el.classList.remove('hidden');
el.classList.add('flex');
}
function closeModal(id) {
const el = document.getElementById(id);
el.classList.add('hidden');
el.classList.remove('flex');
if (id === 'viewerModal') document.getElementById('viewerFrame').src = '';
}
document.querySelectorAll('.modal-bg').forEach(el => {
el.addEventListener('click', e => { if (e.target === el) closeModal(el.id); });
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') document.querySelectorAll('.modal-bg.flex').forEach(el => closeModal(el.id));
});
function confirmDelete(path, name, isFolder) {
document.getElementById('deleteTarget').value = path;
document.getElementById('deleteLabel').textContent = '"' + name + '" wird unwiderruflich gelöscht.';
const warn = document.getElementById('deleteFolderWarn');
if (isFolder) { warn.classList.remove('hidden'); } else { warn.classList.add('hidden'); }
openModal('deleteModal');
}
function openRename(path, name) {
document.getElementById('renameSrc').value = path;
document.getElementById('renameDst').value = path;
openModal('renameModal');
}
function openViewer(path, title) {
const url = '?action=view&target=' + encodeURIComponent(path);
document.getElementById('viewerTitle').textContent = title || path.split('/').pop();
document.getElementById('viewerFrame').src = url;
document.getElementById('viewerOpen').href = url;
openModal('viewerModal');
}
const selected = new Set();
function toggleItem(path, cb) {
if (cb.checked) selected.add(path); else selected.delete(path);
updateSelBar();
}
function toggleAll(cb) {
document.querySelectorAll('.sel-cb').forEach(c => {
c.checked = cb.checked;
if (cb.checked) selected.add(c.dataset.path); else selected.delete(c.dataset.path);
});
updateSelBar();
}
function updateSelBar() {
const n = selected.size;
document.getElementById('selBar').classList.toggle('hidden', n === 0);
document.getElementById('selCount').textContent = n + ' ausgewählt';
}
function cancelSelection() {
selected.clear();
document.querySelectorAll('.sel-cb').forEach(c => c.checked = false);
document.getElementById('selAll').checked = false;
updateSelBar();
}
function showLoading(text) {
document.getElementById('loadingText').textContent = text || 'Wird verarbeitet…';
const ol = document.getElementById('loadingOverlay');
ol.classList.remove('hidden');
ol.classList.add('flex');
}
function buildMultiForm(action) {
const form = document.createElement('form');
form.method = 'post';
form.action = '?path=<?= rawurlencode($path) ?>';
const ai = document.createElement('input'); ai.type='hidden'; ai.name='action'; ai.value=action;
form.appendChild(ai);
selected.forEach(p => {
const i = document.createElement('input'); i.type='hidden'; i.name='targets[]'; i.value=p;
form.appendChild(i);
});
return form;
}
function bulkDelete() {
if (!selected.size) return;
document.getElementById('bulkDeleteLabel').textContent = selected.size + ' Element(e) werden unwiderruflich gelöscht.';
const hasFolder = [...document.querySelectorAll('.sel-cb:checked')].some(cb => cb.dataset.folder === '1');
const warn = document.getElementById('bulkDeleteFolderWarn');
if (hasFolder) { warn.classList.remove('hidden'); } else { warn.classList.add('hidden'); }
openModal('bulkDeleteModal');
}
function submitBulkDelete() {
closeModal('bulkDeleteModal');
showLoading('Wird gelöscht…');
const form = buildMultiForm('delete_multi');
document.body.appendChild(form);
form.submit();
}
let folderBrowsePath = '/';
function folderBrowse(path) {
folderBrowsePath = path;
document.getElementById('folderSelected').textContent = path;
const list = document.getElementById('folderList');
list.innerHTML = '<div class="px-5 py-3 text-[12px] text-zinc-400">Lädt…</div>';
// Breadcrumb aufbauen
const bc = document.getElementById('folderBreadcrumb');
bc.innerHTML = '';
const parts = path === '/' ? [] : path.split('/').filter(Boolean);
const addCrumb = (label, p) => {
if (bc.children.length) { const s=document.createElement('span'); s.textContent='/'; s.className='text-zinc-300'; bc.appendChild(s); }
const a=document.createElement('button'); a.textContent=label; a.className='hover:text-zinc-900 transition-colors';
a.onclick=()=>folderBrowse(p); bc.appendChild(a);
};
addCrumb('reMarkable', '/');
let acc='';
parts.forEach(p => { acc+='/'+p; addCrumb(p, acc); });
fetch('?action=folders&path='+encodeURIComponent(path))
.then(r=>r.json())
.then(data => {
if (!data.folders.length) {
list.innerHTML='<div class="px-5 py-4 text-[12px] text-zinc-400 text-center">Keine Unterordner</div>';
return;
}
list.innerHTML='';
data.folders.forEach(name => {
const fp = (path==='/'?'':path)+'/'+name;
const row=document.createElement('div');
row.className='flex items-center gap-3 px-5 py-2.5 hover:bg-zinc-50 cursor-pointer transition-colors';
row.innerHTML='<svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="M3 7a2 2 0 0 1 2-2h3.172a2 2 0 0 1 1.414.586l1.414 1.414A2 2 0 0 0 12.414 8H19a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z" fill="#e8d5b7" stroke="#c4a46b" stroke-width="1.5"/></svg>'
+'<span class="text-[13px] text-zinc-700 flex-1">'+name+'</span>'
+'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#a1a1aa" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>';
row.onclick=()=>folderBrowse(fp);
list.appendChild(row);
});
});
}
function bulkMove() {
if (!selected.size) return;
folderBrowse('<?= h($path) ?>');
openModal('bulkMoveModal');
}
function submitBulkMove() {
const dst = folderBrowsePath;
closeModal('bulkMoveModal');
showLoading('Wird verschoben…');
const form = buildMultiForm('move_multi');
const di = document.createElement('input'); di.type='hidden'; di.name='destination'; di.value=dst;
form.appendChild(di);
document.body.appendChild(form);
form.submit();
}
function startSync() {
const output = document.getElementById('syncOutput');
const done = document.getElementById('syncDone');
const close = document.getElementById('syncClose');
output.textContent = '';
done.classList.add('hidden');
close.disabled = true;
openModal('syncModal');
fetch('sync.php')
.then(res => {
const reader = res.body.getReader();
const dec = new TextDecoder();
function read() {
reader.read().then(({ done: d, value }) => {
if (d) { done.classList.remove('hidden'); close.disabled = false; return; }
output.textContent += dec.decode(value);
output.scrollTop = output.scrollHeight;
read();
});
}
read();
})
.catch(e => { output.textContent += '\n[Fehler: ' + e + ']'; close.disabled = false; });
}
</script>
</body>
</html>