feat: Desktop Sync Client (Tauri) - Grundgeruest
Tauri 2 Desktop-Client mit: Rust-Backend: - MiniCloudApi: Login, Token-Refresh, Upload, Download, Sync-Tree, Sync-Changes, File-Locking (Lock/Unlock/Heartbeat) - SyncEngine: Full-Sync (Server-Tree vs. lokales Dateisystem), Delta-Sync (nur Aenderungen seit letztem Sync), bidirektionaler Abgleich mit SHA-256 Checksummen, Ordner-Erstellung, Lock-Status-Pruefung vor Upload, Konflikt-Erkennung - FileWatcher: Filesystem-Watcher (notify crate) fuer Echtzeit- Erkennung lokaler Aenderungen, filtert temp/hidden files Vue-Frontend: - Login-Screen: Server-URL, Benutzername, Passwort - Main-Screen: Sync-Ordner setzen, Sync starten, Dateiliste mit Lock-Status, Sync-Protokoll - Dark-Mode Support Tauri-Kommandos: login, set_sync_dir, start_sync, delta_sync, get_status, get_file_tree Zum Bauen (Linux): sudo apt install libwebkit2gtk-4.1-dev libgtk-3-dev cd clients/desktop && npm install && npm run tauri build Windows/Mac: Tauri Voraussetzungen installieren, dann gleicher Befehl Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
const screen = ref("login"); // login | main
|
||||
const serverUrl = ref("https://");
|
||||
const username = ref("");
|
||||
const password = ref("");
|
||||
const loginError = ref("");
|
||||
const loginLoading = ref(false);
|
||||
|
||||
const syncDir = ref("");
|
||||
const syncLog = ref([]);
|
||||
const syncing = ref(false);
|
||||
const userInfo = ref(null);
|
||||
const fileTree = ref([]);
|
||||
const statusMsg = ref("Nicht verbunden");
|
||||
|
||||
async function handleLogin() {
|
||||
loginError.value = "";
|
||||
loginLoading.value = true;
|
||||
try {
|
||||
const result = await invoke("login", {
|
||||
serverUrl: serverUrl.value,
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
});
|
||||
userInfo.value = result;
|
||||
screen.value = "main";
|
||||
statusMsg.value = `Verbunden als ${result.username}`;
|
||||
|
||||
// Set default sync dir
|
||||
syncDir.value = `${await getDefaultSyncDir()}/MiniCloud`;
|
||||
await loadFileTree();
|
||||
} catch (err) {
|
||||
loginError.value = String(err);
|
||||
} finally {
|
||||
loginLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getDefaultSyncDir() {
|
||||
// Use home directory
|
||||
try {
|
||||
const home = await invoke("get_status");
|
||||
return home.sync_dir || (navigator.platform.includes("Win") ? "C:\\Users" : "/home");
|
||||
} catch {
|
||||
return "/home";
|
||||
}
|
||||
}
|
||||
|
||||
async function setSyncFolder() {
|
||||
try {
|
||||
await invoke("set_sync_dir", { path: syncDir.value });
|
||||
statusMsg.value = `Sync-Ordner: ${syncDir.value}`;
|
||||
} catch (err) {
|
||||
statusMsg.value = `Fehler: ${err}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function runSync() {
|
||||
syncing.value = true;
|
||||
statusMsg.value = "Synchronisiere...";
|
||||
try {
|
||||
await invoke("set_sync_dir", { path: syncDir.value });
|
||||
const log = await invoke("start_sync");
|
||||
syncLog.value = log;
|
||||
statusMsg.value = `Sync fertig: ${log.length} Aktionen`;
|
||||
await loadFileTree();
|
||||
} catch (err) {
|
||||
statusMsg.value = `Sync-Fehler: ${err}`;
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFileTree() {
|
||||
try {
|
||||
fileTree.value = await invoke("get_file_tree");
|
||||
} catch (err) {
|
||||
console.error("Tree-Fehler:", err);
|
||||
}
|
||||
}
|
||||
|
||||
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]}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Login -->
|
||||
<div v-if="screen === 'login'" class="login-screen">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<div class="logo-icon">☁</div>
|
||||
<h1>Mini-Cloud</h1>
|
||||
<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 v-if="loginError" class="error">{{ loginError }}</div>
|
||||
<button type="submit" :disabled="loginLoading" class="btn-primary">
|
||||
{{ loginLoading ? "Verbinde..." : "Anmelden" }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main -->
|
||||
<div v-else class="main-screen">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="logo-small">☁</span>
|
||||
<strong>Mini-Cloud</strong>
|
||||
<span class="status-badge">{{ statusMsg }}</span>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<span class="user-info">{{ userInfo?.username }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- Sync Settings -->
|
||||
<div class="section">
|
||||
<h3>Sync-Ordner</h3>
|
||||
<div class="sync-row">
|
||||
<input v-model="syncDir" class="sync-input" />
|
||||
<button @click="runSync" :disabled="syncing" class="btn-primary">
|
||||
{{ syncing ? "Synchronisiere..." : "Jetzt synchronisieren" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Tree -->
|
||||
<div class="section">
|
||||
<h3>Server-Dateien</h3>
|
||||
<div class="file-tree">
|
||||
<div v-for="entry in fileTree" :key="entry.id" 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" :title="'Gesperrt von ' + entry.locked_by">
|
||||
🔒 {{ entry.locked_by }}
|
||||
</span>
|
||||
<span v-if="!entry.is_folder" class="tree-size">{{ formatSize(entry.size) }}</span>
|
||||
</div>
|
||||
<div v-if="!fileTree.length" class="empty">Keine Dateien</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>
|
||||
</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 {
|
||||
width: 100%; padding: 0.6rem; background: #4a90d9; color: white;
|
||||
border: none; border-radius: 6px; font-size: 0.9rem; cursor: pointer; font-weight: 500;
|
||||
}
|
||||
.btn-primary:hover { background: #3a7bc8; }
|
||||
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
.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; color: #666; background: #f0f0f0; padding: 0.2rem 0.5rem; border-radius: 4px; }
|
||||
.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: 1rem; }
|
||||
.section h3 { margin-bottom: 0.75rem; font-size: 0.95rem; }
|
||||
.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; }
|
||||
.file-tree { max-height: 300px; overflow-y: auto; }
|
||||
.tree-item {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
padding: 0.375rem 0; border-bottom: 1px solid #f5f5f5; font-size: 0.85rem;
|
||||
}
|
||||
.tree-icon { flex-shrink: 0; }
|
||||
.tree-name { flex: 1; }
|
||||
.tree-lock { font-size: 0.75rem; color: #e67e22; }
|
||||
.tree-size { font-size: 0.75rem; color: #999; }
|
||||
.empty { text-align: center; color: #999; padding: 1rem; }
|
||||
.log-list { max-height: 200px; overflow-y: auto; font-family: monospace; font-size: 0.8rem; }
|
||||
.log-item { padding: 0.25rem 0; border-bottom: 1px solid #f5f5f5; }
|
||||
|
||||
@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: #3a3a3a; color: #aaa; }
|
||||
.tree-item { border-color: #333; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,4 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
Reference in New Issue
Block a user