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>
This commit is contained in:
7
remarkable/web/Dockerfile
Executable file
7
remarkable/web/Dockerfile
Executable file
@@ -0,0 +1,7 @@
|
||||
FROM php:8.3-cli
|
||||
RUN apt-get update && apt-get install -y unzip && rm -rf /var/lib/apt/lists/*
|
||||
COPY backup/rmapi /usr/local/bin/rmapi
|
||||
RUN chmod +x /usr/local/bin/rmapi
|
||||
WORKDIR /app
|
||||
COPY web/ /app
|
||||
CMD ["php", "-S", "0.0.0.0:8080", "-t", "/app"]
|
||||
26
remarkable/web/auth.php
Executable file
26
remarkable/web/auth.php
Executable file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$user = getenv('RM_USER') ?: 'admin';
|
||||
$pass = getenv('RM_PASS') ?: '';
|
||||
|
||||
if ($pass === '') return; // kein Passwort gesetzt → kein Schutz
|
||||
|
||||
// PHP Built-in Server liefert keine PHP_AUTH_* Variablen — Header manuell parsen
|
||||
$authUser = $_SERVER['PHP_AUTH_USER'] ?? '';
|
||||
$authPass = $_SERVER['PHP_AUTH_PW'] ?? '';
|
||||
|
||||
if ($authUser === '' && isset($_SERVER['HTTP_AUTHORIZATION'])) {
|
||||
$decoded = base64_decode(substr($_SERVER['HTTP_AUTHORIZATION'], 6));
|
||||
[$authUser, $authPass] = explode(':', $decoded, 2) + ['', ''];
|
||||
}
|
||||
|
||||
$ok = $authUser === $user
|
||||
&& hash_equals(hash('sha256', $pass), hash('sha256', $authPass));
|
||||
|
||||
if (!$ok) {
|
||||
header('WWW-Authenticate: Basic realm="reMarkable"');
|
||||
header('HTTP/1.1 401 Unauthorized');
|
||||
echo 'Zugriff verweigert.';
|
||||
exit;
|
||||
}
|
||||
852
remarkable/web/index.php
Executable file
852
remarkable/web/index.php
Executable file
@@ -0,0 +1,852 @@
|
||||
<?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>
|
||||
157
remarkable/web/index2.php
Executable file
157
remarkable/web/index2.php
Executable file
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/auth.php';
|
||||
|
||||
$backupRoot = getenv('RM_BACKUP_ROOT') ?: '/www/remarkable/notizen/snapshots/latest';
|
||||
$backupRootReal = realpath($backupRoot);
|
||||
|
||||
function h(string $value): string
|
||||
{
|
||||
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
function isInsideRoot(string $root, string $path): bool
|
||||
{
|
||||
$rootPrefix = rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
||||
return str_starts_with($path, $rootPrefix) || $path === $root;
|
||||
}
|
||||
|
||||
$query = isset($_GET['q']) ? trim((string)$_GET['q']) : '';
|
||||
$selected = isset($_GET['file']) ? (string)$_GET['file'] : '';
|
||||
$error = '';
|
||||
|
||||
if ($backupRootReal === false || !is_dir($backupRootReal)) {
|
||||
$error = 'Backup-Pfad nicht gefunden: ' . $backupRoot;
|
||||
}
|
||||
|
||||
$files = [];
|
||||
$selectedPath = null;
|
||||
|
||||
if ($error === '') {
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator(
|
||||
$backupRootReal,
|
||||
FilesystemIterator::SKIP_DOTS
|
||||
),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
|
||||
$qLower = mb_strtolower($query);
|
||||
foreach ($iterator as $entry) {
|
||||
$absolutePath = $entry->getPathname();
|
||||
$relativePath = ltrim(substr($absolutePath, strlen($backupRootReal)), DIRECTORY_SEPARATOR);
|
||||
if ($relativePath === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relativePathLower = mb_strtolower($relativePath);
|
||||
if ($query !== '' && !str_contains($relativePathLower, $qLower)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$files[] = [
|
||||
'relative' => str_replace(DIRECTORY_SEPARATOR, '/', $relativePath),
|
||||
'is_dir' => $entry->isDir(),
|
||||
'size' => $entry->isFile() ? $entry->getSize() : 0,
|
||||
'mtime' => $entry->getMTime(),
|
||||
'is_pdf' => $entry->isFile() && str_ends_with(strtolower($relativePath), '.pdf'),
|
||||
];
|
||||
}
|
||||
|
||||
usort($files, static function (array $a, array $b): int {
|
||||
if ($a['is_dir'] !== $b['is_dir']) {
|
||||
return $a['is_dir'] ? -1 : 1;
|
||||
}
|
||||
return strnatcasecmp($a['relative'], $b['relative']);
|
||||
});
|
||||
|
||||
if ($selected !== '') {
|
||||
$candidate = realpath($backupRootReal . DIRECTORY_SEPARATOR . $selected);
|
||||
if ($candidate !== false && is_file($candidate) && isInsideRoot($backupRootReal, $candidate)) {
|
||||
$selectedPath = $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($_GET['download']) && $selectedPath !== null) {
|
||||
header('Content-Type: application/octet-stream');
|
||||
header('Content-Disposition: inline; filename="' . basename($selectedPath) . '"');
|
||||
header('Content-Length: ' . (string) filesize($selectedPath));
|
||||
readfile($selectedPath);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>reMarkable Archiv</title>
|
||||
<style>
|
||||
body { margin: 0; font-family: Arial, sans-serif; background: #f6f7f9; color: #222; }
|
||||
.layout { display: grid; grid-template-columns: 430px 1fr; min-height: 100vh; }
|
||||
.sidebar { border-right: 1px solid #ddd; background: #fff; padding: 14px; overflow: auto; }
|
||||
.content { padding: 14px; overflow: auto; }
|
||||
.muted { color: #666; font-size: 12px; }
|
||||
.item { display: block; padding: 8px 10px; margin: 4px 0; border: 1px solid #eee; border-radius: 8px; text-decoration: none; color: #222; background: #fafafa; }
|
||||
.item:hover { background: #f0f4ff; border-color: #c9d7ff; }
|
||||
.dir { font-weight: 600; }
|
||||
.meta { color: #777; font-size: 12px; margin-top: 4px; }
|
||||
form { margin-bottom: 12px; }
|
||||
input[type="text"] { width: 75%; padding: 8px; border: 1px solid #ccc; border-radius: 6px; }
|
||||
button { padding: 8px 10px; border-radius: 6px; border: 1px solid #bbb; background: #fff; cursor: pointer; }
|
||||
iframe { width: 100%; height: calc(100vh - 110px); border: 1px solid #ccc; border-radius: 8px; background: #fff; }
|
||||
.notice { padding: 10px; border: 1px solid #f0c36d; background: #fff8e1; border-radius: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<h2 style="margin: 0 0 8px 0;">reMarkable Archiv</h2>
|
||||
<div class="muted">Quelle: <?= h($backupRootReal ?: $backupRoot) ?></div>
|
||||
<form method="get">
|
||||
<input type="text" name="q" value="<?= h($query) ?>" placeholder="Suche nach Dateinamen oder Ordner...">
|
||||
<button type="submit">Suchen</button>
|
||||
</form>
|
||||
<?php if ($error !== ''): ?>
|
||||
<div class="notice"><?= h($error) ?></div>
|
||||
<?php else: ?>
|
||||
<?php if (count($files) === 0): ?>
|
||||
<div class="notice">Keine Treffer.</div>
|
||||
<?php else: ?>
|
||||
<?php foreach ($files as $entry): ?>
|
||||
<?php if ($entry['is_dir']): ?>
|
||||
<div class="item dir">📁 <?= h($entry['relative']) ?></div>
|
||||
<?php else: ?>
|
||||
<a class="item" href="?q=<?= rawurlencode($query) ?>&file=<?= rawurlencode($entry['relative']) ?>">
|
||||
📄 <?= h($entry['relative']) ?>
|
||||
<div class="meta">
|
||||
<?= number_format((int) $entry['size'] / 1024, 1, ',', '.') ?> KB ·
|
||||
<?= h(date('Y-m-d H:i:s', (int) $entry['mtime'])) ?>
|
||||
<?= $entry['is_pdf'] ? ' · PDF' : '' ?>
|
||||
</div>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</aside>
|
||||
<main class="content">
|
||||
<?php if ($selectedPath === null): ?>
|
||||
<div class="notice">Links eine Datei waehlen. PDFs werden direkt angezeigt.</div>
|
||||
<?php else: ?>
|
||||
<?php $rel = ltrim(substr($selectedPath, strlen($backupRootReal)), DIRECTORY_SEPARATOR); ?>
|
||||
<h3 style="margin-top: 0;"><?= h(str_replace(DIRECTORY_SEPARATOR, '/', $rel)) ?></h3>
|
||||
<div class="muted" style="margin-bottom: 10px;">
|
||||
<a href="?q=<?= rawurlencode($query) ?>&file=<?= rawurlencode(str_replace(DIRECTORY_SEPARATOR, '/', $rel)) ?>&download=1">Datei oeffnen/herunterladen</a>
|
||||
</div>
|
||||
<?php if (str_ends_with(strtolower($selectedPath), '.pdf')): ?>
|
||||
<iframe src="?q=<?= rawurlencode($query) ?>&file=<?= rawurlencode(str_replace(DIRECTORY_SEPARATOR, '/', $rel)) ?>&download=1"></iframe>
|
||||
<?php else: ?>
|
||||
<div class="notice">Keine PDF-Datei. Bitte ueber den Link oben herunterladen/oeffnen.</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
118
remarkable/web/setup.php
Executable file
118
remarkable/web/setup.php
Executable file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/auth.php';
|
||||
|
||||
$RMAPI = '/usr/local/bin/rmapi';
|
||||
$flash = '';
|
||||
$flashOk = true;
|
||||
|
||||
function h(string $v): string {
|
||||
return htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
// rmapi-Status prüfen
|
||||
function rmapiStatus(string $bin): array {
|
||||
exec($bin . ' ls / 2>&1', $out, $code);
|
||||
return ['ok' => $code === 0, 'out' => implode("\n", $out)];
|
||||
}
|
||||
|
||||
// Token einrichten
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['code'])) {
|
||||
$code = trim($_POST['code']);
|
||||
$code = preg_replace('/[^a-zA-Z0-9]/', '', $code);
|
||||
if (strlen($code) === 8) {
|
||||
$descriptors = [
|
||||
0 => ['pipe', 'r'],
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
];
|
||||
$proc = proc_open(escapeshellarg($RMAPI) . ' ls /', $descriptors, $pipes);
|
||||
if (is_resource($proc)) {
|
||||
fwrite($pipes[0], $code . "\n");
|
||||
fclose($pipes[0]);
|
||||
$out = stream_get_contents($pipes[1]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
$exitCode = proc_close($proc);
|
||||
$flashOk = $exitCode === 0;
|
||||
$flash = $flashOk ? 'Token erfolgreich eingerichtet!' : 'Fehler: Ungültiger Code oder Verbindungsproblem.';
|
||||
}
|
||||
} else {
|
||||
$flash = 'Code muss genau 8 Zeichen lang sein.';
|
||||
$flashOk = false;
|
||||
}
|
||||
}
|
||||
|
||||
$status = rmapiStatus($RMAPI);
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>reMarkable Setup</title>
|
||||
<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>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f4f4f4;color:#222;min-height:100vh;display:flex;align-items:center;justify-content:center}
|
||||
.card{background:#fff;border-radius:12px;padding:32px;width:440px;max-width:95vw;box-shadow:0 4px 24px rgba(0,0,0,.1)}
|
||||
h1{font-size:20px;font-weight:700;margin-bottom:8px}
|
||||
.subtitle{font-size:14px;color:#666;margin-bottom:24px}
|
||||
.status{padding:12px 16px;border-radius:8px;font-size:14px;margin-bottom:20px;display:flex;align-items:center;gap:10px}
|
||||
.status.ok{background:#dcfce7;border:1px solid #86efac;color:#166534}
|
||||
.status.err{background:#fee2e2;border:1px solid #fca5a5;color:#991b1b}
|
||||
.flash{padding:10px 16px;border-radius:8px;font-size:14px;margin-bottom:16px}
|
||||
.flash.ok{background:#dcfce7;border:1px solid #86efac;color:#166534}
|
||||
.flash.err{background:#fee2e2;border:1px solid #fca5a5;color:#991b1b}
|
||||
label{display:block;font-size:13px;font-weight:500;margin-bottom:6px;color:#444}
|
||||
input[type=text]{width:100%;padding:10px 12px;border:1px solid #ccc;border-radius:8px;font-size:16px;letter-spacing:4px;text-align:center;margin-bottom:12px}
|
||||
.btn{width:100%;padding:10px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;border:none;background:#2563eb;color:#fff}
|
||||
.btn:hover{background:#1d4ed8}
|
||||
.hint{font-size:13px;color:#666;margin-top:16px;line-height:1.6}
|
||||
.hint a{color:#2563eb}
|
||||
.back{display:block;text-align:center;margin-top:20px;font-size:13px;color:#2563eb;text-decoration:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>📄 reMarkable Setup</h1>
|
||||
<p class="subtitle">Verbindung zur reMarkable Cloud einrichten</p>
|
||||
|
||||
<div class="status <?= $status['ok'] ? 'ok' : 'err' ?>">
|
||||
<?= $status['ok'] ? '✓ Verbunden mit reMarkable Cloud' : '✗ Nicht verbunden — Token fehlt oder abgelaufen' ?>
|
||||
</div>
|
||||
|
||||
<?php if ($flash !== ''): ?>
|
||||
<div class="flash <?= $flashOk ? 'ok' : 'err' ?>"><?= h($flash) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!$status['ok']): ?>
|
||||
<form method="post">
|
||||
<label for="code">One-Time-Code</label>
|
||||
<input type="text" id="code" name="code" maxlength="8" placeholder="xxxxxxxx" autocomplete="off" autofocus>
|
||||
<button type="submit" class="btn">Verbinden</button>
|
||||
</form>
|
||||
<p class="hint">
|
||||
Code abrufen unter:<br>
|
||||
<a href="https://my.remarkable.com/device/desktop/connect" target="_blank">
|
||||
my.remarkable.com/device/desktop/connect
|
||||
</a>
|
||||
</p>
|
||||
<?php else: ?>
|
||||
<form method="post">
|
||||
<label for="code">Token erneuern (neuer One-Time-Code)</label>
|
||||
<input type="text" id="code" name="code" maxlength="8" placeholder="xxxxxxxx" autocomplete="off">
|
||||
<button type="submit" class="btn btn-secondary" style="background:#6b7280">Token erneuern</button>
|
||||
</form>
|
||||
<p class="hint">
|
||||
Neuen Code abrufen unter:<br>
|
||||
<a href="https://my.remarkable.com/device/desktop/connect" target="_blank">
|
||||
my.remarkable.com/device/desktop/connect
|
||||
</a>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<a href="/" class="back">← Zurück zur Übersicht</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
64
remarkable/web/sync.php
Executable file
64
remarkable/web/sync.php
Executable file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/auth.php';
|
||||
|
||||
$BASE_DIR = rtrim(getenv('BASE_DIR') ?: '/data', '/');
|
||||
$BACKUP_SCRIPT = '/usr/local/bin/remarkable-backup.sh';
|
||||
$CONVERT_SCRIPT= '/usr/local/bin/remarkable-convert.sh';
|
||||
$SNAPSHOT_KEEP = getenv('SNAPSHOT_KEEP') ?: '60';
|
||||
|
||||
// Läuft bereits ein Sync?
|
||||
$LOCK = $BASE_DIR . '/.backup.lock';
|
||||
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
header('X-Accel-Buffering: no');
|
||||
header('Cache-Control: no-cache');
|
||||
if (ob_get_level()) ob_end_clean();
|
||||
|
||||
function stream(string $cmd): int {
|
||||
$handle = popen($cmd . ' 2>&1', 'r');
|
||||
if ($handle === false) return 1;
|
||||
while (!feof($handle)) {
|
||||
$chunk = fread($handle, 256);
|
||||
if ($chunk !== false && $chunk !== '') {
|
||||
echo $chunk;
|
||||
flush();
|
||||
}
|
||||
}
|
||||
return pclose($handle);
|
||||
}
|
||||
|
||||
echo "=== Backup ===\n";
|
||||
flush();
|
||||
$exit = stream("SNAPSHOT_KEEP={$SNAPSHOT_KEEP} {$BACKUP_SCRIPT} {$BASE_DIR} /");
|
||||
|
||||
if ($exit !== 0) {
|
||||
echo "\n[FEHLER] Backup fehlgeschlagen (exit {$exit})\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
// Web-gelöschte Dateien wieder entfernen (kommen durch Sync zurück)
|
||||
$excludeFile = $BASE_DIR . '/.web_deleted';
|
||||
if (file_exists($excludeFile)) {
|
||||
$lines = file($excludeFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
$removed = 0;
|
||||
foreach (array_unique($lines) as $p) {
|
||||
$p = '/' . ltrim($p, '/');
|
||||
$local = $BASE_DIR . '/current' . $p;
|
||||
if (is_dir($local)) {
|
||||
shell_exec('rm -rf ' . escapeshellarg($local));
|
||||
$removed++;
|
||||
} else {
|
||||
foreach ([$local, $local.'.pdf', $local.'.rmdoc', $local.'.thumb.jpg'] as $f) {
|
||||
if (is_file($f) && @unlink($f)) $removed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($removed) echo "\n[Web-Löschung] $removed Element(e) erneut entfernt.\n";
|
||||
}
|
||||
|
||||
echo "\n=== PDF-Konvertierung ===\n";
|
||||
flush();
|
||||
stream("{$CONVERT_SCRIPT} {$BASE_DIR}/current");
|
||||
|
||||
echo "\n=== Fertig ===\n";
|
||||
Reference in New Issue
Block a user