feat: Virtual Files, Multi-Sync-Pfade, Full Sync, Ordner-Dialog

Virtual Files System:
- .cloud Platzhalter-Dateien (JSON mit ID, Name, Groesse, Checksum)
- 0 Bytes Speicherverbrauch pro Datei
- Doppelklick auf .cloud -> Download + Oeffnen mit Standard-App + Lock
- Nach Schliessen: Sync zurueck, lokale Kopie entfernen, .cloud neu
- Offline-Markierung: Echte Dateien bleiben lokal (kein .cloud)
- Server-Dateien loeschen -> .cloud wird automatisch entfernt

Multi-Sync-Pfade (wie Nextcloud):
- Beliebig viele Server-Ordner auf lokale Ordner mappen
- z.B. /Projekte/2026 -> ~/Projekte oder /Shared/Team -> ~/Team
- Freigegebene Ordner von anderen Benutzern sync-bar
- Jeder Pfad hat eigenen Modus (Virtual oder Full)
- Hinzufuegen/Entfernen/Modus wechseln in der UI

Full Sync:
- Pro Sync-Pfad waehlbar: Virtual oder Full
- Full = alle Dateien lokal spiegeln (bidirektional)
- Virtual = .cloud Platzhalter (Standard)
- Klick auf Modus-Badge zum Umschalten

Ordner-Dialog:
- "Durchsuchen..." Button oeffnet nativen Ordner-Auswahl-Dialog
- Server-Ordner per Dropdown aus Dateibaum waehlen
- Ordner werden automatisch erstellt wenn noetig

UI:
- Sync-Pfade als Karten: ☁ /Server/Pfad → 📁 /Lokaler/Pfad
- Modus-Badge (Virtual/Full) mit Klick zum Wechseln
- Tray-Menue: "Jetzt synchronisieren" Eintrag

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker
2026-04-12 00:34:03 +02:00
parent 4662286959
commit 16d514f7f1
4 changed files with 638 additions and 375 deletions
+250 -204
View File
@@ -2,15 +2,16 @@
import { ref, onMounted, onUnmounted } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { open as dialogOpen } from "@tauri-apps/plugin-dialog";
const screen = ref("login"); // login | main
const screen = ref("login");
const serverUrl = ref("https://");
const username = ref("");
const password = ref("");
const loginError = ref("");
const loginLoading = ref(false);
const syncDir = ref("");
const syncPaths = ref([]);
const syncLog = ref([]);
const syncing = ref(false);
const syncStatus = ref("Nicht verbunden");
@@ -19,10 +20,15 @@ const fileTree = ref([]);
const fileChanges = ref([]);
const autoSyncActive = ref(false);
let unlistenStatus = null;
let unlistenLog = null;
let unlistenError = null;
let unlistenFileChange = null;
// New sync path form
const showAddPath = ref(false);
const newPathLocal = ref("");
const newPathServerFolder = ref("");
const newPathServerId = ref(null);
const newPathMode = ref("virtual");
const serverFolders = ref([]);
let unlistenStatus, unlistenLog, unlistenError, unlistenFileChange, unlistenTrigger;
async function handleLogin() {
loginError.value = "";
@@ -36,12 +42,8 @@ async function handleLogin() {
userInfo.value = result;
screen.value = "main";
syncStatus.value = `Verbunden als ${result.username}`;
// Default sync dir
const home = navigator.platform.includes("Win") ? "C:\\Users\\" + result.username : "/home/" + result.username;
syncDir.value = `${home}/MiniCloud`;
await loadFileTree();
await loadSyncPaths();
} catch (err) {
loginError.value = String(err);
} finally {
@@ -49,79 +51,123 @@ async function handleLogin() {
}
}
async function runSync() {
syncing.value = true;
syncStatus.value = "Erster Sync...";
try {
await invoke("set_sync_dir", { path: syncDir.value });
const log = await invoke("start_sync");
syncLog.value = [...log, ...syncLog.value].slice(0, 100);
syncStatus.value = "Synchronisiert";
autoSyncActive.value = true;
await loadFileTree();
} catch (err) {
syncStatus.value = `Fehler: ${err}`;
} finally {
syncing.value = false;
}
}
async function loadFileTree() {
try {
fileTree.value = await invoke("get_file_tree");
} catch (err) {
console.error("Tree-Fehler:", err);
// Build flat folder list for sync path selection
serverFolders.value = [{ id: null, name: "/ (Alle Dateien)", path: "/" }];
flattenFolders(fileTree.value, "", serverFolders.value);
} catch (err) { console.error(err); }
}
function flattenFolders(entries, prefix, list) {
for (const e of entries) {
if (e.is_folder) {
const path = `${prefix}/${e.name}`;
list.push({ id: e.id, name: path, path });
if (e.children) flattenFolders(e.children, path, list);
}
}
}
function formatSize(bytes) {
if (!bytes) return "";
const units = ["B", "KB", "MB", "GB"];
let i = 0;
let size = bytes;
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
return `${size.toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
async function loadSyncPaths() {
try { syncPaths.value = await invoke("get_sync_paths"); }
catch { syncPaths.value = []; }
}
function timestamp() {
async function browseFolder() {
try {
const selected = await dialogOpen({ directory: true, multiple: false, title: "Sync-Ordner waehlen" });
if (selected) newPathLocal.value = selected;
} catch { /* dialog cancelled */ }
}
async function addSyncPath() {
if (!newPathLocal.value) return;
try {
await invoke("add_sync_path", {
serverPath: newPathServerFolder.value || "/",
serverFolderId: newPathServerId.value,
localDir: newPathLocal.value,
mode: newPathMode.value,
});
showAddPath.value = false;
newPathLocal.value = "";
newPathServerFolder.value = "";
newPathServerId.value = null;
newPathMode.value = "virtual";
await loadSyncPaths();
} catch (err) { alert(err); }
}
async function removeSyncPath(id) {
await invoke("remove_sync_path", { id });
await loadSyncPaths();
}
async function toggleMode(id) {
await invoke("toggle_sync_mode", { id });
await loadSyncPaths();
}
function selectServerFolder(folder) {
newPathServerFolder.value = folder.path;
newPathServerId.value = folder.id;
}
async function startSync() {
syncing.value = true;
syncStatus.value = "Erster Sync...";
try {
const log = await invoke("start_sync");
syncLog.value = [...log.map(m => `[${ts()}] ${m}`), ...syncLog.value].slice(0, 200);
syncStatus.value = "Synchronisiert";
autoSyncActive.value = true;
await loadFileTree();
} catch (err) { syncStatus.value = `Fehler: ${err}`; }
finally { syncing.value = false; }
}
async function syncNow() {
syncing.value = true;
try {
const log = await invoke("run_sync_now");
syncLog.value = [...log.map(m => `[${ts()}] ${m}`), ...syncLog.value].slice(0, 200);
await loadFileTree();
} catch (err) { syncStatus.value = `Fehler: ${err}`; }
finally { syncing.value = false; }
}
function ts() {
return new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
function formatSize(b) {
if (!b) return "";
const u = ["B","KB","MB","GB"]; let i=0; let s=b;
while (s>=1024 && i<u.length-1) { s/=1024; i++; }
return `${s.toFixed(i>0?1:0)} ${u[i]}`;
}
onMounted(async () => {
// Listen for sync events from Rust backend
unlistenStatus = await listen("sync-status", (event) => {
if (event.payload === "syncing") {
syncStatus.value = "Synchronisiere...";
syncing.value = true;
} else if (event.payload === "synced") {
syncStatus.value = "Synchronisiert";
syncing.value = false;
loadFileTree();
}
unlistenStatus = await listen("sync-status", e => {
syncing.value = e.payload === "syncing";
syncStatus.value = e.payload === "syncing" ? "Synchronisiere..." : "Synchronisiert";
if (e.payload === "synced") loadFileTree();
});
unlistenLog = await listen("sync-log", (event) => {
const entries = event.payload.map(msg => `[${timestamp()}] ${msg}`);
syncLog.value = [...entries, ...syncLog.value].slice(0, 200);
unlistenLog = await listen("sync-log", e => {
syncLog.value = [...e.payload.map(m => `[${ts()}] ${m}`), ...syncLog.value].slice(0, 200);
});
unlistenError = await listen("sync-error", (event) => {
syncStatus.value = `Fehler: ${event.payload}`;
syncLog.value = [`[${timestamp()}] FEHLER: ${event.payload}`, ...syncLog.value].slice(0, 200);
unlistenError = await listen("sync-error", e => {
syncStatus.value = `Fehler: ${e.payload}`;
syncing.value = false;
});
unlistenFileChange = await listen("file-change", (event) => {
fileChanges.value = [`[${timestamp()}] ${event.payload}`, ...fileChanges.value].slice(0, 50);
unlistenFileChange = await listen("file-change", e => {
fileChanges.value = [`[${ts()}] ${e.payload}`, ...fileChanges.value].slice(0, 50);
});
unlistenTrigger = await listen("trigger-sync", () => syncNow());
});
onUnmounted(() => {
unlistenStatus?.();
unlistenLog?.();
unlistenError?.();
unlistenFileChange?.();
});
onUnmounted(() => { unlistenStatus?.(); unlistenLog?.(); unlistenError?.(); unlistenFileChange?.(); unlistenTrigger?.(); });
</script>
<template>
@@ -134,22 +180,11 @@ onUnmounted(() => {
<p>Desktop Sync Client</p>
</div>
<form @submit.prevent="handleLogin">
<div class="field">
<label>Server-URL</label>
<input v-model="serverUrl" placeholder="https://cloud.example.com" />
</div>
<div class="field">
<label>Benutzername</label>
<input v-model="username" placeholder="Benutzername" autofocus />
</div>
<div class="field">
<label>Passwort</label>
<input v-model="password" type="password" placeholder="Passwort" />
</div>
<div class="field"><label>Server-URL</label><input v-model="serverUrl" placeholder="https://cloud.example.com" /></div>
<div class="field"><label>Benutzername</label><input v-model="username" autofocus /></div>
<div class="field"><label>Passwort</label><input v-model="password" type="password" /></div>
<div v-if="loginError" class="error">{{ loginError }}</div>
<button type="submit" :disabled="loginLoading" class="btn-primary">
{{ loginLoading ? "Verbinde..." : "Anmelden" }}
</button>
<button type="submit" :disabled="loginLoading" class="btn-primary full">{{ loginLoading ? "Verbinde..." : "Anmelden" }}</button>
</form>
</div>
</div>
@@ -158,30 +193,79 @@ onUnmounted(() => {
<div v-else class="main-screen">
<div class="toolbar">
<div class="toolbar-left">
<span class="logo-small"></span>
<strong>Mini-Cloud Sync</strong>
<span class="status-badge" :class="{ syncing: syncing, error: syncStatus.startsWith('Fehler') }">
<span v-if="syncing" class="spin"></span>
{{ syncStatus }}
<span class="logo-small">☁</span><strong>Mini-Cloud Sync</strong>
<span class="status-badge" :class="{ syncing, error: syncStatus.startsWith('Fehler') }">
<span v-if="syncing" class="spin">⟳</span> {{ syncStatus }}
</span>
</div>
<div class="toolbar-right">
<span class="user-info">{{ userInfo?.username }}</span>
</div>
<div class="toolbar-right"><span class="user-info">{{ userInfo?.username }}</span></div>
</div>
<div class="content">
<!-- Sync Settings -->
<!-- Sync Paths -->
<div class="section">
<h3>Sync-Ordner</h3>
<div class="sync-row">
<input v-model="syncDir" class="sync-input" :disabled="autoSyncActive" />
<button @click="runSync" :disabled="syncing" class="btn-primary">
{{ autoSyncActive ? "⟳ Auto-Sync aktiv" : (syncing ? "Synchronisiere..." : "Sync starten") }}
</button>
<div class="section-header">
<h3>Sync-Pfade</h3>
<div class="header-btns">
<button v-if="syncPaths.length && !autoSyncActive" @click="startSync" :disabled="syncing" class="btn-primary">Sync starten</button>
<button v-if="autoSyncActive" @click="syncNow" :disabled="syncing" class="btn-small">Jetzt synchronisieren</button>
<button @click="showAddPath = !showAddPath" class="btn-small">+ Pfad hinzufuegen</button>
</div>
</div>
<div v-if="autoSyncActive" class="auto-sync-info">
Auto-Sync alle 30 Sekunden aktiv. Datei-Watcher ueberwacht lokale Aenderungen.
<div v-if="autoSyncActive" class="auto-info">Auto-Sync alle 30s aktiv</div>
<!-- Add new sync path -->
<div v-if="showAddPath" class="add-path-form">
<div class="field">
<label>Server-Ordner</label>
<select v-model="newPathServerId" @change="selectServerFolder(serverFolders.find(f => f.id === newPathServerId))">
<option v-for="f in serverFolders" :key="f.id ?? 'root'" :value="f.id">{{ f.name }}</option>
</select>
</div>
<div class="field">
<label>Lokaler Ordner</label>
<div class="browse-row">
<input v-model="newPathLocal" placeholder="/home/user/MiniCloud" />
<button @click="browseFolder" class="btn-small">Durchsuchen...</button>
</div>
</div>
<div class="field">
<label>Modus</label>
<div class="mode-select">
<label class="mode-option" :class="{ active: newPathMode === 'virtual' }">
<input type="radio" v-model="newPathMode" value="virtual" /> ☁ Virtual Files
<small>Platzhalter, Download bei Bedarf</small>
</label>
<label class="mode-option" :class="{ active: newPathMode === 'full' }">
<input type="radio" v-model="newPathMode" value="full" /> 💾 Full Sync
<small>Alle Dateien lokal spiegeln</small>
</label>
</div>
</div>
<div class="form-actions">
<button @click="showAddPath = false" class="btn-small">Abbrechen</button>
<button @click="addSyncPath" class="btn-primary" :disabled="!newPathLocal">Hinzufuegen</button>
</div>
</div>
<!-- Existing sync paths -->
<div v-for="sp in syncPaths" :key="sp.id" class="sync-path-card">
<div class="sp-info">
<div class="sp-server">☁ {{ sp.server_path }}</div>
<div class="sp-arrow">→</div>
<div class="sp-local">📁 {{ sp.local_dir }}</div>
</div>
<div class="sp-actions">
<span class="sp-mode" :class="sp.mode" @click="toggleMode(sp.id)" :title="'Klicken zum Wechseln'">
{{ sp.mode === 'Full' ? '💾 Full' : '☁ Virtual' }}
</span>
<button @click="removeSyncPath(sp.id)" class="btn-danger" title="Entfernen">✕</button>
</div>
</div>
<div v-if="!syncPaths.length && !showAddPath" class="empty">
Noch keine Sync-Pfade. Klicke "Pfad hinzufuegen" um loszulegen.
</div>
</div>
@@ -189,133 +273,95 @@ onUnmounted(() => {
<div class="section">
<div class="section-header">
<h3>Server-Dateien</h3>
<button v-if="autoSyncActive" @click="loadFileTree" class="btn-small">Aktualisieren</button>
<button @click="loadFileTree" class="btn-small">↻</button>
</div>
<div class="file-tree">
<template v-for="entry in fileTree" :key="entry.id">
<template v-for="e in fileTree" :key="e.id">
<div class="tree-item">
<span class="tree-icon">{{ entry.is_folder ? "📁" : "📄" }}</span>
<span class="tree-name">{{ entry.name }}</span>
<span v-if="entry.locked" class="tree-lock">🔒 {{ entry.locked_by }}</span>
<span v-if="!entry.is_folder" class="tree-size">{{ formatSize(entry.size) }}</span>
<span class="tree-icon">{{ e.is_folder ? '📁' : '📄' }}</span>
<span class="tree-name">{{ e.name }}</span>
<span v-if="e.locked" class="tree-lock">🔒 {{ e.locked_by }}</span>
<span v-if="!e.is_folder" class="tree-size">{{ formatSize(e.size) }}</span>
</div>
<div v-if="entry.children" v-for="child in entry.children" :key="child.id" class="tree-item indent">
<span class="tree-icon">{{ child.is_folder ? "📁" : "📄" }}</span>
<span class="tree-name">{{ child.name }}</span>
<span v-if="child.locked" class="tree-lock">🔒 {{ child.locked_by }}</span>
<span v-if="!child.is_folder" class="tree-size">{{ formatSize(child.size) }}</span>
<div v-if="e.children" v-for="c in e.children" :key="c.id" class="tree-item indent">
<span class="tree-icon">{{ c.is_folder ? '📁' : '📄' }}</span>
<span class="tree-name">{{ c.name }}</span>
<span v-if="c.locked" class="tree-lock">🔒 {{ c.locked_by }}</span>
<span v-if="!c.is_folder" class="tree-size">{{ formatSize(c.size) }}</span>
</div>
</template>
<div v-if="!fileTree.length" class="empty">Keine Dateien</div>
</div>
</div>
<!-- File Changes (Watcher) -->
<!-- File Changes -->
<div v-if="fileChanges.length" class="section">
<h3>Lokale Aenderungen</h3>
<div class="log-list">
<div v-for="(msg, i) in fileChanges" :key="i" class="log-item change">{{ msg }}</div>
</div>
<div class="log-list"><div v-for="(m,i) in fileChanges" :key="i" class="log-item change">{{ m }}</div></div>
</div>
<!-- Sync Log -->
<div v-if="syncLog.length" class="section">
<h3>Sync-Protokoll</h3>
<div class="log-list">
<div v-for="(msg, i) in syncLog" :key="i" class="log-item">{{ msg }}</div>
</div>
<div class="log-list"><div v-for="(m,i) in syncLog" :key="i" class="log-item">{{ m }}</div></div>
</div>
</div>
</div>
</template>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px; color: #1a1a1a; background: #f0f2f5;
}
.login-screen { height: 100vh; display: flex; align-items: center; justify-content: center; }
.login-card {
background: white; border-radius: 12px; padding: 2rem; width: 360px;
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
}
.login-header { text-align: center; margin-bottom: 1.5rem; }
.logo-icon { font-size: 2.5rem; }
.login-header h1 { font-size: 1.3rem; margin: 0.5rem 0 0.25rem; }
.login-header p { color: #666; font-size: 0.85rem; }
.field { margin-bottom: 0.75rem; }
.field label { display: block; margin-bottom: 0.25rem; font-weight: 500; font-size: 0.85rem; }
.field input {
width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 6px;
font-size: 0.9rem; background: #fafafa;
}
.field input:focus { border-color: #4a90d9; outline: none; background: white; }
.error { color: #e53e3e; font-size: 0.85rem; margin-bottom: 0.75rem; }
.btn-primary {
padding: 0.6rem 1rem; background: #4a90d9; color: white;
border: none; border-radius: 6px; font-size: 0.9rem; cursor: pointer; font-weight: 500;
white-space: nowrap;
}
.btn-primary:hover { background: #3a7bc8; }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-small {
padding: 0.25rem 0.5rem; background: #e8e8e8; border: none; border-radius: 4px;
font-size: 0.8rem; cursor: pointer;
}
.btn-small:hover { background: #ddd; }
.main-screen { height: 100vh; display: flex; flex-direction: column; }
.toolbar {
display: flex; align-items: center; justify-content: space-between;
padding: 0.5rem 1rem; background: white; border-bottom: 1px solid #e0e0e0;
}
.toolbar-left { display: flex; align-items: center; gap: 0.5rem; }
.logo-small { font-size: 1.2rem; }
.status-badge {
font-size: 0.8rem; padding: 0.2rem 0.5rem; border-radius: 4px;
background: #e8f5e9; color: #2e7d32;
}
.status-badge.syncing { background: #fff3e0; color: #e65100; }
.status-badge.error { background: #ffebee; color: #c62828; }
.spin { display: inline-block; animation: spin 1s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.user-info { font-size: 0.85rem; color: #666; }
.content { flex: 1; overflow-y: auto; padding: 1rem; }
.section { background: white; border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem; }
.section h3 { margin-bottom: 0.5rem; font-size: 0.95rem; }
.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; }
.section-header h3 { margin: 0; }
.sync-row { display: flex; gap: 0.5rem; }
.sync-input {
flex: 1; padding: 0.5rem; border: 1px solid #ddd; border-radius: 6px; font-size: 0.85rem;
}
.sync-input:disabled { background: #f5f5f5; color: #999; }
.auto-sync-info { margin-top: 0.5rem; font-size: 0.8rem; color: #2e7d32; }
.file-tree { max-height: 250px; overflow-y: auto; }
.tree-item {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.3rem 0; border-bottom: 1px solid #f5f5f5; font-size: 0.85rem;
}
.tree-item.indent { padding-left: 1.5rem; }
.tree-icon { flex-shrink: 0; }
.tree-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tree-lock { font-size: 0.75rem; color: #e67e22; flex-shrink: 0; }
.tree-size { font-size: 0.75rem; color: #999; flex-shrink: 0; }
.empty { text-align: center; color: #999; padding: 1rem; }
.log-list { max-height: 150px; overflow-y: auto; font-family: monospace; font-size: 0.78rem; }
.log-item { padding: 0.2rem 0; border-bottom: 1px solid #f8f8f8; color: #555; }
.log-item.change { color: #1565c0; }
@media (prefers-color-scheme: dark) {
body { color: #e0e0e0; background: #1a1a1a; }
.login-card, .section { background: #2a2a2a; }
.toolbar { background: #2a2a2a; border-color: #3a3a3a; }
.field input, .sync-input { background: #333; border-color: #444; color: #e0e0e0; }
.status-badge { background: #1b5e20; color: #a5d6a7; }
.status-badge.syncing { background: #e65100; color: #ffcc80; }
.tree-item { border-color: #333; }
.log-item { border-color: #333; color: #aaa; }
.log-item.change { color: #64b5f6; }
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;font-size:14px;color:#1a1a1a;background:#f0f2f5}
.login-screen{height:100vh;display:flex;align-items:center;justify-content:center}
.login-card{background:#fff;border-radius:12px;padding:2rem;width:360px;box-shadow:0 2px 12px rgba(0,0,0,.1)}
.login-header{text-align:center;margin-bottom:1.5rem}
.logo-icon{font-size:2.5rem}.login-header h1{font-size:1.3rem;margin:.5rem 0 .25rem}.login-header p{color:#666;font-size:.85rem}
.field{margin-bottom:.75rem}.field label{display:block;margin-bottom:.25rem;font-weight:500;font-size:.85rem}
.field input,.field select{width:100%;padding:.5rem;border:1px solid #ddd;border-radius:6px;font-size:.9rem;background:#fafafa}
.field input:focus,.field select:focus{border-color:#4a90d9;outline:none;background:#fff}
.error{color:#e53e3e;font-size:.85rem;margin-bottom:.75rem}
.btn-primary{padding:.5rem 1rem;background:#4a90d9;color:#fff;border:none;border-radius:6px;font-size:.85rem;cursor:pointer;font-weight:500;white-space:nowrap}
.btn-primary:hover{background:#3a7bc8}.btn-primary:disabled{opacity:.6;cursor:not-allowed}
.btn-primary.full{width:100%}
.btn-small{padding:.25rem .5rem;background:#e8e8e8;border:none;border-radius:4px;font-size:.8rem;cursor:pointer}
.btn-small:hover{background:#ddd}
.btn-danger{padding:.25rem .5rem;background:#fee;color:#c00;border:none;border-radius:4px;font-size:.8rem;cursor:pointer}
.btn-danger:hover{background:#fcc}
.main-screen{height:100vh;display:flex;flex-direction:column}
.toolbar{display:flex;align-items:center;justify-content:space-between;padding:.5rem 1rem;background:#fff;border-bottom:1px solid #e0e0e0}
.toolbar-left{display:flex;align-items:center;gap:.5rem}.logo-small{font-size:1.2rem}
.status-badge{font-size:.8rem;padding:.2rem .5rem;border-radius:4px;background:#e8f5e9;color:#2e7d32}
.status-badge.syncing{background:#fff3e0;color:#e65100}.status-badge.error{background:#ffebee;color:#c62828}
.spin{display:inline-block;animation:spin 1s linear infinite}@keyframes spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}
.user-info{font-size:.85rem;color:#666}
.content{flex:1;overflow-y:auto;padding:1rem}
.section{background:#fff;border-radius:8px;padding:1rem;margin-bottom:.75rem}
.section h3{margin-bottom:.5rem;font-size:.95rem}
.section-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:.5rem}
.section-header h3{margin:0}.header-btns{display:flex;gap:.5rem}
.auto-info{font-size:.8rem;color:#2e7d32;margin-bottom:.5rem}
.add-path-form{border:1px solid #e0e0e0;border-radius:8px;padding:1rem;margin-bottom:.75rem;background:#fafafa}
.browse-row{display:flex;gap:.5rem}
.browse-row input{flex:1}
.mode-select{display:flex;gap:.5rem}
.mode-option{flex:1;display:flex;flex-direction:column;padding:.5rem;border:2px solid #e0e0e0;border-radius:6px;cursor:pointer;font-size:.85rem}
.mode-option.active{border-color:#4a90d9;background:#f0f7ff}
.mode-option input{margin-right:.25rem}
.mode-option small{color:#888;font-size:.75rem;margin-top:.25rem}
.form-actions{display:flex;justify-content:flex-end;gap:.5rem;margin-top:.75rem}
.sync-path-card{display:flex;align-items:center;justify-content:space-between;padding:.5rem .75rem;border:1px solid #e8e8e8;border-radius:6px;margin-bottom:.375rem;font-size:.85rem}
.sp-info{display:flex;align-items:center;gap:.375rem;flex:1;min-width:0}
.sp-server,.sp-local{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sp-server{color:#4a90d9}.sp-arrow{color:#999;flex-shrink:0}.sp-local{color:#555}
.sp-actions{display:flex;align-items:center;gap:.375rem;flex-shrink:0}
.sp-mode{font-size:.75rem;padding:.2rem .4rem;border-radius:4px;cursor:pointer;background:#f0f0f0}
.sp-mode.Full{background:#e3f2fd;color:#1565c0}.sp-mode.Virtual{background:#f3e5f5;color:#7b1fa2}
.file-tree{max-height:250px;overflow-y:auto}
.tree-item{display:flex;align-items:center;gap:.5rem;padding:.3rem 0;border-bottom:1px solid #f5f5f5;font-size:.85rem}
.tree-item.indent{padding-left:1.5rem}.tree-icon{flex-shrink:0}.tree-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.tree-lock{font-size:.75rem;color:#e67e22;flex-shrink:0}.tree-size{font-size:.75rem;color:#999;flex-shrink:0}
.empty{text-align:center;color:#999;padding:1rem;font-size:.85rem}
.log-list{max-height:150px;overflow-y:auto;font-family:monospace;font-size:.78rem}
.log-item{padding:.2rem 0;border-bottom:1px solid #f8f8f8;color:#555}.log-item.change{color:#1565c0}
@media(prefers-color-scheme:dark){body{color:#e0e0e0;background:#1a1a1a}.login-card,.section{background:#2a2a2a}.toolbar{background:#2a2a2a;border-color:#3a3a3a}.field input,.field select{background:#333;border-color:#444;color:#e0e0e0}.status-badge{background:#1b5e20;color:#a5d6a7}.status-badge.syncing{background:#e65100;color:#ffcc80}.add-path-form{background:#333;border-color:#444}.mode-option{border-color:#444}.mode-option.active{border-color:#4a90d9;background:#1a3a5c}.sync-path-card{border-color:#3a3a3a}.tree-item{border-color:#333}.log-item{border-color:#333;color:#aaa}.log-item.change{color:#64b5f6}}
</style>