Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29cc00e284 | |||
| 9391a58683 | |||
| 714ce1ae53 |
+6
-5
@@ -37,11 +37,12 @@ MAX_UPLOAD_SIZE_MB=500
|
|||||||
ONLYOFFICE_URL=
|
ONLYOFFICE_URL=
|
||||||
|
|
||||||
# =============================================
|
# =============================================
|
||||||
# Client-Build Upload (NUR auf der Entwicklungsmaschine!)
|
# Client-Build Upload (NUR auf der ENTWICKLUNGSMASCHINE!)
|
||||||
# Diese Werte gehoeren NICHT auf den Produktionsserver,
|
# NICHT auf dem Produktionsserver setzen!
|
||||||
# sondern in die .env der Maschine auf der ./build.sh laeuft.
|
# Diese Werte braucht nur die Maschine auf der ./build.sh laeuft.
|
||||||
# =============================================
|
# =============================================
|
||||||
# Oeffentliche URL der Cloud-Instanz wohin die Builds hochgeladen werden
|
# URL der Cloud-Instanz wohin die Builds hochgeladen werden
|
||||||
CLOUD_URL=https://cloud.example.com
|
CLOUD_URL=https://cloud.example.com
|
||||||
# SECRET_KEY des Zielservers (identisch mit SECRET_KEY oben auf dem Server)
|
# SECRET_KEY oder JWT_SECRET_KEY des Zielservers
|
||||||
|
# (den gleichen Wert hier reinkopieren der auf dem Server steht)
|
||||||
BUILD_UPLOAD_TOKEN=
|
BUILD_UPLOAD_TOKEN=
|
||||||
|
|||||||
@@ -39,5 +39,8 @@ backend/static/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
build-output/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -26,11 +26,13 @@ def _clients_dir():
|
|||||||
|
|
||||||
def _verify_build_token():
|
def _verify_build_token():
|
||||||
"""Verify the build upload token from header or query param."""
|
"""Verify the build upload token from header or query param."""
|
||||||
expected = os.environ.get('SECRET_KEY', '')
|
|
||||||
if not expected:
|
|
||||||
return False
|
|
||||||
token = request.headers.get('X-Build-Token', '') or request.args.get('build_token', '')
|
token = request.headers.get('X-Build-Token', '') or request.args.get('build_token', '')
|
||||||
return token == expected
|
if not token:
|
||||||
|
return False
|
||||||
|
# Accept SECRET_KEY or JWT_SECRET_KEY
|
||||||
|
secret = os.environ.get('SECRET_KEY', '')
|
||||||
|
jwt_secret = os.environ.get('JWT_SECRET_KEY', '')
|
||||||
|
return token == secret or token == jwt_secret
|
||||||
|
|
||||||
|
|
||||||
# --- Public: list available clients ---
|
# --- Public: list available clients ---
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
mod sync;
|
mod sync;
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Mutex;
|
use std::sync::{Arc, Mutex};
|
||||||
use tauri::State;
|
use std::time::Duration;
|
||||||
|
use tauri::{
|
||||||
|
menu::{Menu, MenuItem},
|
||||||
|
tray::TrayIconBuilder,
|
||||||
|
AppHandle, Emitter, Manager, State,
|
||||||
|
};
|
||||||
|
|
||||||
use sync::api::MiniCloudApi;
|
use sync::api::MiniCloudApi;
|
||||||
use sync::engine::SyncEngine;
|
use sync::engine::SyncEngine;
|
||||||
|
use sync::watcher::{FileWatcher, ChangeKind};
|
||||||
|
|
||||||
struct AppState {
|
struct AppState {
|
||||||
api: Mutex<Option<MiniCloudApi>>,
|
api: Mutex<Option<MiniCloudApi>>,
|
||||||
sync_engine: Mutex<Option<SyncEngine>>,
|
sync_engine: Mutex<Option<SyncEngine>>,
|
||||||
sync_dir: Mutex<Option<PathBuf>>,
|
sync_dir: Mutex<Option<PathBuf>>,
|
||||||
username: Mutex<Option<String>>,
|
username: Mutex<Option<String>>,
|
||||||
|
watcher: Mutex<Option<FileWatcher>>,
|
||||||
|
sync_running: Arc<Mutex<bool>>,
|
||||||
|
locked_files: Mutex<Vec<i64>>, // file IDs we have locked
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -44,28 +53,36 @@ fn set_sync_dir(state: State<'_, AppState>, path: String) -> Result<String, Stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn start_sync(state: State<'_, AppState>) -> Result<Vec<String>, String> {
|
async fn start_sync(app: AppHandle, state: State<'_, AppState>) -> Result<Vec<String>, String> {
|
||||||
let api = state.api.lock().unwrap().clone()
|
let api = state.api.lock().unwrap().clone()
|
||||||
.ok_or("Nicht eingeloggt")?;
|
.ok_or("Nicht eingeloggt")?;
|
||||||
let sync_dir = state.sync_dir.lock().unwrap().clone()
|
let sync_dir = state.sync_dir.lock().unwrap().clone()
|
||||||
.ok_or("Kein Sync-Ordner gesetzt")?;
|
.ok_or("Kein Sync-Ordner gesetzt")?;
|
||||||
|
|
||||||
let mut engine = SyncEngine::new(sync_dir, api);
|
// Full sync
|
||||||
|
let mut engine = SyncEngine::new(sync_dir.clone(), api.clone());
|
||||||
let log = engine.full_sync().await?;
|
let log = engine.full_sync().await?;
|
||||||
|
|
||||||
*state.sync_engine.lock().unwrap() = Some(engine);
|
*state.sync_engine.lock().unwrap() = Some(engine);
|
||||||
|
|
||||||
|
// Start file watcher
|
||||||
|
let watcher = FileWatcher::new(&sync_dir)?;
|
||||||
|
*state.watcher.lock().unwrap() = Some(watcher);
|
||||||
|
|
||||||
|
// Start background auto-sync + watcher processing
|
||||||
|
start_background_sync(app, state.sync_running.clone(), api, sync_dir);
|
||||||
|
|
||||||
|
let _ = app.emit("sync-status", "synced");
|
||||||
Ok(log)
|
Ok(log)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn delta_sync(state: State<'_, AppState>) -> Result<Vec<String>, String> {
|
async fn delta_sync(state: State<'_, AppState>) -> Result<Vec<String>, String> {
|
||||||
// Extract engine from state, dropping the MutexGuard before .await
|
|
||||||
let mut engine = {
|
let mut engine = {
|
||||||
let mut guard = state.sync_engine.lock().unwrap();
|
let mut guard = state.sync_engine.lock().unwrap();
|
||||||
guard.take().ok_or("Sync nicht gestartet")?
|
guard.take().ok_or("Sync nicht gestartet")?
|
||||||
};
|
};
|
||||||
let result = engine.delta_sync().await;
|
let result = engine.delta_sync().await;
|
||||||
// Put engine back
|
|
||||||
*state.sync_engine.lock().unwrap() = Some(engine);
|
*state.sync_engine.lock().unwrap() = Some(engine);
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
@@ -75,11 +92,15 @@ async fn get_status(state: State<'_, AppState>) -> Result<serde_json::Value, Str
|
|||||||
let logged_in = state.api.lock().unwrap().is_some();
|
let logged_in = state.api.lock().unwrap().is_some();
|
||||||
let sync_dir = state.sync_dir.lock().unwrap().clone();
|
let sync_dir = state.sync_dir.lock().unwrap().clone();
|
||||||
let username = state.username.lock().unwrap().clone();
|
let username = state.username.lock().unwrap().clone();
|
||||||
|
let syncing = *state.sync_running.lock().unwrap();
|
||||||
|
let locked_count = state.locked_files.lock().unwrap().len();
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"logged_in": logged_in,
|
"logged_in": logged_in,
|
||||||
"username": username,
|
"username": username,
|
||||||
"sync_dir": sync_dir.map(|p| p.to_string_lossy().to_string()),
|
"sync_dir": sync_dir.map(|p| p.to_string_lossy().to_string()),
|
||||||
|
"syncing": syncing,
|
||||||
|
"locked_files": locked_count,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,15 +112,143 @@ async fn get_file_tree(state: State<'_, AppState>) -> Result<serde_json::Value,
|
|||||||
Ok(serde_json::to_value(tree).map_err(|e| e.to_string())?)
|
Ok(serde_json::to_value(tree).map_err(|e| e.to_string())?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn lock_file_cmd(state: State<'_, AppState>, file_id: i64) -> Result<String, String> {
|
||||||
|
let api = state.api.lock().unwrap().clone().ok_or("Nicht eingeloggt")?;
|
||||||
|
api.lock_file(file_id, "Desktop Sync Client").await?;
|
||||||
|
state.locked_files.lock().unwrap().push(file_id);
|
||||||
|
Ok("Datei gesperrt".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn unlock_file_cmd(state: State<'_, AppState>, file_id: i64) -> Result<String, String> {
|
||||||
|
let api = state.api.lock().unwrap().clone().ok_or("Nicht eingeloggt")?;
|
||||||
|
api.unlock_file(file_id).await?;
|
||||||
|
state.locked_files.lock().unwrap().retain(|&id| id != file_id);
|
||||||
|
Ok("Datei entsperrt".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Background sync: auto-sync every 30s + process file watcher events + heartbeat for locks
|
||||||
|
fn start_background_sync(
|
||||||
|
app: AppHandle,
|
||||||
|
sync_running: Arc<Mutex<bool>>,
|
||||||
|
api: MiniCloudApi,
|
||||||
|
sync_dir: PathBuf,
|
||||||
|
) {
|
||||||
|
// Auto-sync thread (delta sync every 30 seconds)
|
||||||
|
let app_sync = app.clone();
|
||||||
|
let api_sync = api.clone();
|
||||||
|
let sync_dir_clone = sync_dir.clone();
|
||||||
|
let running = sync_running.clone();
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
|
let mut engine = SyncEngine::new(sync_dir_clone, api_sync);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(Duration::from_secs(30));
|
||||||
|
|
||||||
|
*running.lock().unwrap() = true;
|
||||||
|
let _ = app_sync.emit("sync-status", "syncing");
|
||||||
|
|
||||||
|
match rt.block_on(engine.delta_sync()) {
|
||||||
|
Ok(log) => {
|
||||||
|
if !log.is_empty() {
|
||||||
|
let _ = app_sync.emit("sync-log", log);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = app_sync.emit("sync-error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*running.lock().unwrap() = false;
|
||||||
|
let _ = app_sync.emit("sync-status", "synced");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Heartbeat thread (every 60 seconds for locked files)
|
||||||
|
let app_hb = app.clone();
|
||||||
|
let api_hb = api.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(Duration::from_secs(60));
|
||||||
|
|
||||||
|
let state = app_hb.state::<AppState>();
|
||||||
|
let locked = state.locked_files.lock().unwrap().clone();
|
||||||
|
|
||||||
|
for file_id in locked {
|
||||||
|
let _ = rt.block_on(api_hb.heartbeat(file_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// File watcher processing thread
|
||||||
|
let app_watcher = app.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(Duration::from_secs(2));
|
||||||
|
|
||||||
|
let state = app_watcher.state::<AppState>();
|
||||||
|
let watcher_guard = state.watcher.lock().unwrap();
|
||||||
|
if let Some(watcher) = watcher_guard.as_ref() {
|
||||||
|
while let Ok(change) = watcher.receiver.try_recv() {
|
||||||
|
let rel_path = change.path.strip_prefix(&sync_dir)
|
||||||
|
.unwrap_or(&change.path)
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let msg = match change.kind {
|
||||||
|
ChangeKind::Created => format!("Neu: {}", rel_path),
|
||||||
|
ChangeKind::Modified => format!("Geaendert: {}", rel_path),
|
||||||
|
ChangeKind::Deleted => format!("Geloescht: {}", rel_path),
|
||||||
|
};
|
||||||
|
let _ = app_watcher.emit("file-change", msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.plugin(tauri_plugin_notification::init())
|
||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
api: Mutex::new(None),
|
api: Mutex::new(None),
|
||||||
sync_engine: Mutex::new(None),
|
sync_engine: Mutex::new(None),
|
||||||
sync_dir: Mutex::new(None),
|
sync_dir: Mutex::new(None),
|
||||||
username: Mutex::new(None),
|
username: Mutex::new(None),
|
||||||
|
watcher: Mutex::new(None),
|
||||||
|
sync_running: Arc::new(Mutex::new(false)),
|
||||||
|
locked_files: Mutex::new(Vec::new()),
|
||||||
|
})
|
||||||
|
.setup(|app| {
|
||||||
|
// System Tray
|
||||||
|
let quit = MenuItem::with_id(app, "quit", "Beenden", true, None::<&str>)?;
|
||||||
|
let show = MenuItem::with_id(app, "show", "Oeffnen", true, None::<&str>)?;
|
||||||
|
let menu = Menu::with_items(app, &[&show, &quit])?;
|
||||||
|
|
||||||
|
TrayIconBuilder::new()
|
||||||
|
.tooltip("Mini-Cloud Sync")
|
||||||
|
.menu(&menu)
|
||||||
|
.on_menu_event(|app, event| {
|
||||||
|
match event.id.as_ref() {
|
||||||
|
"quit" => std::process::exit(0),
|
||||||
|
"show" => {
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(app)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
login,
|
login,
|
||||||
@@ -108,6 +257,8 @@ pub fn run() {
|
|||||||
delta_sync,
|
delta_sync,
|
||||||
get_status,
|
get_status,
|
||||||
get_file_tree,
|
get_file_tree,
|
||||||
|
lock_file_cmd,
|
||||||
|
unlock_file_cmd,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
+129
-52
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted, onUnmounted } from "vue";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
|
||||||
const screen = ref("login"); // login | main
|
const screen = ref("login"); // login | main
|
||||||
const serverUrl = ref("https://");
|
const serverUrl = ref("https://");
|
||||||
@@ -12,9 +13,16 @@ const loginLoading = ref(false);
|
|||||||
const syncDir = ref("");
|
const syncDir = ref("");
|
||||||
const syncLog = ref([]);
|
const syncLog = ref([]);
|
||||||
const syncing = ref(false);
|
const syncing = ref(false);
|
||||||
|
const syncStatus = ref("Nicht verbunden");
|
||||||
const userInfo = ref(null);
|
const userInfo = ref(null);
|
||||||
const fileTree = ref([]);
|
const fileTree = ref([]);
|
||||||
const statusMsg = ref("Nicht verbunden");
|
const fileChanges = ref([]);
|
||||||
|
const autoSyncActive = ref(false);
|
||||||
|
|
||||||
|
let unlistenStatus = null;
|
||||||
|
let unlistenLog = null;
|
||||||
|
let unlistenError = null;
|
||||||
|
let unlistenFileChange = null;
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
loginError.value = "";
|
loginError.value = "";
|
||||||
@@ -27,10 +35,12 @@ async function handleLogin() {
|
|||||||
});
|
});
|
||||||
userInfo.value = result;
|
userInfo.value = result;
|
||||||
screen.value = "main";
|
screen.value = "main";
|
||||||
statusMsg.value = `Verbunden als ${result.username}`;
|
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`;
|
||||||
|
|
||||||
// Set default sync dir
|
|
||||||
syncDir.value = `${await getDefaultSyncDir()}/MiniCloud`;
|
|
||||||
await loadFileTree();
|
await loadFileTree();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
loginError.value = String(err);
|
loginError.value = String(err);
|
||||||
@@ -39,36 +49,18 @@ async function handleLogin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
async function runSync() {
|
||||||
syncing.value = true;
|
syncing.value = true;
|
||||||
statusMsg.value = "Synchronisiere...";
|
syncStatus.value = "Erster Sync...";
|
||||||
try {
|
try {
|
||||||
await invoke("set_sync_dir", { path: syncDir.value });
|
await invoke("set_sync_dir", { path: syncDir.value });
|
||||||
const log = await invoke("start_sync");
|
const log = await invoke("start_sync");
|
||||||
syncLog.value = log;
|
syncLog.value = [...log, ...syncLog.value].slice(0, 100);
|
||||||
statusMsg.value = `Sync fertig: ${log.length} Aktionen`;
|
syncStatus.value = "Synchronisiert";
|
||||||
|
autoSyncActive.value = true;
|
||||||
await loadFileTree();
|
await loadFileTree();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
statusMsg.value = `Sync-Fehler: ${err}`;
|
syncStatus.value = `Fehler: ${err}`;
|
||||||
} finally {
|
} finally {
|
||||||
syncing.value = false;
|
syncing.value = false;
|
||||||
}
|
}
|
||||||
@@ -90,6 +82,46 @@ function formatSize(bytes) {
|
|||||||
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
|
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
|
||||||
return `${size.toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
|
return `${size.toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function timestamp() {
|
||||||
|
return new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
unlistenLog = await listen("sync-log", (event) => {
|
||||||
|
const entries = event.payload.map(msg => `[${timestamp()}] ${msg}`);
|
||||||
|
syncLog.value = [...entries, ...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);
|
||||||
|
syncing.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
unlistenFileChange = await listen("file-change", (event) => {
|
||||||
|
fileChanges.value = [`[${timestamp()}] ${event.payload}`, ...fileChanges.value].slice(0, 50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
unlistenStatus?.();
|
||||||
|
unlistenLog?.();
|
||||||
|
unlistenError?.();
|
||||||
|
unlistenFileChange?.();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -127,8 +159,11 @@ function formatSize(bytes) {
|
|||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="toolbar-left">
|
<div class="toolbar-left">
|
||||||
<span class="logo-small">☁</span>
|
<span class="logo-small">☁</span>
|
||||||
<strong>Mini-Cloud</strong>
|
<strong>Mini-Cloud Sync</strong>
|
||||||
<span class="status-badge">{{ statusMsg }}</span>
|
<span class="status-badge" :class="{ syncing: syncing, error: syncStatus.startsWith('Fehler') }">
|
||||||
|
<span v-if="syncing" class="spin">⟳</span>
|
||||||
|
{{ syncStatus }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-right">
|
<div class="toolbar-right">
|
||||||
<span class="user-info">{{ userInfo?.username }}</span>
|
<span class="user-info">{{ userInfo?.username }}</span>
|
||||||
@@ -140,29 +175,49 @@ function formatSize(bytes) {
|
|||||||
<div class="section">
|
<div class="section">
|
||||||
<h3>Sync-Ordner</h3>
|
<h3>Sync-Ordner</h3>
|
||||||
<div class="sync-row">
|
<div class="sync-row">
|
||||||
<input v-model="syncDir" class="sync-input" />
|
<input v-model="syncDir" class="sync-input" :disabled="autoSyncActive" />
|
||||||
<button @click="runSync" :disabled="syncing" class="btn-primary">
|
<button @click="runSync" :disabled="syncing" class="btn-primary">
|
||||||
{{ syncing ? "Synchronisiere..." : "Jetzt synchronisieren" }}
|
{{ autoSyncActive ? "⟳ Auto-Sync aktiv" : (syncing ? "Synchronisiere..." : "Sync starten") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="autoSyncActive" class="auto-sync-info">
|
||||||
|
Auto-Sync alle 30 Sekunden aktiv. Datei-Watcher ueberwacht lokale Aenderungen.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- File Tree -->
|
<!-- File Tree -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
|
<div class="section-header">
|
||||||
<h3>Server-Dateien</h3>
|
<h3>Server-Dateien</h3>
|
||||||
|
<button v-if="autoSyncActive" @click="loadFileTree" class="btn-small">Aktualisieren</button>
|
||||||
|
</div>
|
||||||
<div class="file-tree">
|
<div class="file-tree">
|
||||||
<div v-for="entry in fileTree" :key="entry.id" class="tree-item">
|
<template v-for="entry in fileTree" :key="entry.id">
|
||||||
|
<div class="tree-item">
|
||||||
<span class="tree-icon">{{ entry.is_folder ? "📁" : "📄" }}</span>
|
<span class="tree-icon">{{ entry.is_folder ? "📁" : "📄" }}</span>
|
||||||
<span class="tree-name">{{ entry.name }}</span>
|
<span class="tree-name">{{ entry.name }}</span>
|
||||||
<span v-if="entry.locked" class="tree-lock" :title="'Gesperrt von ' + entry.locked_by">
|
<span v-if="entry.locked" class="tree-lock">🔒 {{ entry.locked_by }}</span>
|
||||||
🔒 {{ entry.locked_by }}
|
|
||||||
</span>
|
|
||||||
<span v-if="!entry.is_folder" class="tree-size">{{ formatSize(entry.size) }}</span>
|
<span v-if="!entry.is_folder" class="tree-size">{{ formatSize(entry.size) }}</span>
|
||||||
</div>
|
</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>
|
||||||
|
</template>
|
||||||
<div v-if="!fileTree.length" class="empty">Keine Dateien</div>
|
<div v-if="!fileTree.length" class="empty">Keine Dateien</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- File Changes (Watcher) -->
|
||||||
|
<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>
|
||||||
|
|
||||||
<!-- Sync Log -->
|
<!-- Sync Log -->
|
||||||
<div v-if="syncLog.length" class="section">
|
<div v-if="syncLog.length" class="section">
|
||||||
<h3>Sync-Protokoll</h3>
|
<h3>Sync-Protokoll</h3>
|
||||||
@@ -180,9 +235,7 @@ body {
|
|||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
font-size: 14px; color: #1a1a1a; background: #f0f2f5;
|
font-size: 14px; color: #1a1a1a; background: #f0f2f5;
|
||||||
}
|
}
|
||||||
.login-screen {
|
.login-screen { height: 100vh; display: flex; align-items: center; justify-content: center; }
|
||||||
height: 100vh; display: flex; align-items: center; justify-content: center;
|
|
||||||
}
|
|
||||||
.login-card {
|
.login-card {
|
||||||
background: white; border-radius: 12px; padding: 2rem; width: 360px;
|
background: white; border-radius: 12px; padding: 2rem; width: 360px;
|
||||||
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
|
||||||
@@ -200,11 +253,17 @@ body {
|
|||||||
.field input:focus { border-color: #4a90d9; outline: none; background: white; }
|
.field input:focus { border-color: #4a90d9; outline: none; background: white; }
|
||||||
.error { color: #e53e3e; font-size: 0.85rem; margin-bottom: 0.75rem; }
|
.error { color: #e53e3e; font-size: 0.85rem; margin-bottom: 0.75rem; }
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
width: 100%; padding: 0.6rem; background: #4a90d9; color: white;
|
padding: 0.6rem 1rem; background: #4a90d9; color: white;
|
||||||
border: none; border-radius: 6px; font-size: 0.9rem; cursor: pointer; font-weight: 500;
|
border: none; border-radius: 6px; font-size: 0.9rem; cursor: pointer; font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.btn-primary:hover { background: #3a7bc8; }
|
.btn-primary:hover { background: #3a7bc8; }
|
||||||
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
.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; }
|
.main-screen { height: 100vh; display: flex; flex-direction: column; }
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -213,32 +272,50 @@ body {
|
|||||||
}
|
}
|
||||||
.toolbar-left { display: flex; align-items: center; gap: 0.5rem; }
|
.toolbar-left { display: flex; align-items: center; gap: 0.5rem; }
|
||||||
.logo-small { font-size: 1.2rem; }
|
.logo-small { font-size: 1.2rem; }
|
||||||
.status-badge { font-size: 0.8rem; color: #666; background: #f0f0f0; padding: 0.2rem 0.5rem; border-radius: 4px; }
|
.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; }
|
.user-info { font-size: 0.85rem; color: #666; }
|
||||||
.content { flex: 1; overflow-y: auto; padding: 1rem; }
|
.content { flex: 1; overflow-y: auto; padding: 1rem; }
|
||||||
.section { background: white; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; }
|
.section { background: white; border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem; }
|
||||||
.section h3 { margin-bottom: 0.75rem; font-size: 0.95rem; }
|
.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-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 {
|
||||||
.file-tree { max-height: 300px; overflow-y: auto; }
|
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 {
|
.tree-item {
|
||||||
display: flex; align-items: center; gap: 0.5rem;
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
padding: 0.375rem 0; border-bottom: 1px solid #f5f5f5; font-size: 0.85rem;
|
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-icon { flex-shrink: 0; }
|
||||||
.tree-name { flex: 1; }
|
.tree-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.tree-lock { font-size: 0.75rem; color: #e67e22; }
|
.tree-lock { font-size: 0.75rem; color: #e67e22; flex-shrink: 0; }
|
||||||
.tree-size { font-size: 0.75rem; color: #999; }
|
.tree-size { font-size: 0.75rem; color: #999; flex-shrink: 0; }
|
||||||
.empty { text-align: center; color: #999; padding: 1rem; }
|
.empty { text-align: center; color: #999; padding: 1rem; }
|
||||||
.log-list { max-height: 200px; overflow-y: auto; font-family: monospace; font-size: 0.8rem; }
|
.log-list { max-height: 150px; overflow-y: auto; font-family: monospace; font-size: 0.78rem; }
|
||||||
.log-item { padding: 0.25rem 0; border-bottom: 1px solid #f5f5f5; }
|
.log-item { padding: 0.2rem 0; border-bottom: 1px solid #f8f8f8; color: #555; }
|
||||||
|
.log-item.change { color: #1565c0; }
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
body { color: #e0e0e0; background: #1a1a1a; }
|
body { color: #e0e0e0; background: #1a1a1a; }
|
||||||
.login-card, .section { background: #2a2a2a; }
|
.login-card, .section { background: #2a2a2a; }
|
||||||
.toolbar { background: #2a2a2a; border-color: #3a3a3a; }
|
.toolbar { background: #2a2a2a; border-color: #3a3a3a; }
|
||||||
.field input, .sync-input { background: #333; border-color: #444; color: #e0e0e0; }
|
.field input, .sync-input { background: #333; border-color: #444; color: #e0e0e0; }
|
||||||
.status-badge { background: #3a3a3a; color: #aaa; }
|
.status-badge { background: #1b5e20; color: #a5d6a7; }
|
||||||
|
.status-badge.syncing { background: #e65100; color: #ffcc80; }
|
||||||
.tree-item { border-color: #333; }
|
.tree-item { border-color: #333; }
|
||||||
|
.log-item { border-color: #333; color: #aaa; }
|
||||||
|
.log-item.change { color: #64b5f6; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -45,6 +45,24 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Client Downloads -->
|
||||||
|
<div v-if="availableClients.length" class="settings-section">
|
||||||
|
<h3>Desktop & Mobile Clients</h3>
|
||||||
|
<div class="client-list">
|
||||||
|
<div v-for="client in availableClients" :key="client.platform" class="client-item">
|
||||||
|
<div class="client-info">
|
||||||
|
<i :class="'pi ' + (client.platform === 'linux' || client.platform === 'windows' || client.platform === 'mac' ? 'pi-desktop' : 'pi-mobile')"></i>
|
||||||
|
<div>
|
||||||
|
<strong>{{ client.name }}</strong>
|
||||||
|
<span class="client-meta">{{ client.filename }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button icon="pi pi-download" :label="'Download'" size="small" outlined
|
||||||
|
@click="downloadClient(client)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Email Accounts -->
|
<!-- Email Accounts -->
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
@@ -167,6 +185,13 @@ import InputSwitch from 'primevue/inputswitch'
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
|
// Client downloads
|
||||||
|
const availableClients = ref([])
|
||||||
|
|
||||||
|
function downloadClient(client) {
|
||||||
|
window.location.href = `/api/clients/${client.platform}/download`
|
||||||
|
}
|
||||||
|
|
||||||
// --- Password change ---
|
// --- Password change ---
|
||||||
const currentPassword = ref('')
|
const currentPassword = ref('')
|
||||||
const newPassword = ref('')
|
const newPassword = ref('')
|
||||||
@@ -307,7 +332,13 @@ async function doDeleteAccount() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadAccounts)
|
onMounted(async () => {
|
||||||
|
loadAccounts()
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get('/clients')
|
||||||
|
availableClients.value = res.data.clients
|
||||||
|
} catch { availableClients.value = [] }
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -346,4 +377,12 @@ onMounted(loadAccounts)
|
|||||||
.field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
|
.field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
|
||||||
.field-row { display: flex; gap: 0.75rem; align-items: flex-end; }
|
.field-row { display: flex; gap: 0.75rem; align-items: flex-end; }
|
||||||
.flex-grow { flex: 1; }
|
.flex-grow { flex: 1; }
|
||||||
|
.client-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
.client-item {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 0.75rem; border: 1px solid var(--p-surface-200); border-radius: 8px;
|
||||||
|
}
|
||||||
|
.client-info { display: flex; align-items: center; gap: 0.75rem; }
|
||||||
|
.client-info i { font-size: 1.25rem; color: var(--p-primary-color); }
|
||||||
|
.client-meta { display: block; font-size: 0.8rem; color: var(--p-text-muted-color); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user