From c1769e0d1afcbe828eb516a6d0348227c64ef9e0 Mon Sep 17 00:00:00 2001 From: "Rodrigo Rodriguez (Pragmatismo)" Date: Fri, 18 Apr 2025 23:11:10 -0300 Subject: [PATCH] feat: add new pages for music, video, and editor; implement data table with user navigation --- .../components/user-auth-form.tsx | 0 app/{authentication => auth}/page.tsx | 0 app/chat/providers/chat-provider.tsx | 69 ++- app/client-nav.tsx | 9 +- app/editor/page.tsx | 0 app/music/page.tsx | 0 .../components/DataTable.tsx | 0 .../components/DataTablePagination.tsx | 0 .../components/DataTableToolbar.tsx | 0 app/{tasks => tables}/components/UserNav.tsx | 0 app/{tasks => tables}/data/data.ts | 0 app/{tasks => tables}/data/schema.ts | 0 app/{tasks => tables}/page.tsx | 0 .../components/FileBrowser.tsx | 0 .../components/FileOperations.tsx | 0 app/{drive => tree}/components/FileTree.tsx | 0 app/tree/components/editors/tabular/README.md | 3 + app/{drive => tree}/page.tsx | 2 + app/video/page.tsx | 0 src-rust/src/local-sync.rs | 444 ++++++++++++++++++ src-rust/tauri.conf.json | 2 +- 21 files changed, 507 insertions(+), 22 deletions(-) rename app/{authentication => auth}/components/user-auth-form.tsx (100%) rename app/{authentication => auth}/page.tsx (100%) create mode 100644 app/editor/page.tsx create mode 100644 app/music/page.tsx rename app/{tasks => tables}/components/DataTable.tsx (100%) rename app/{tasks => tables}/components/DataTablePagination.tsx (100%) rename app/{tasks => tables}/components/DataTableToolbar.tsx (100%) rename app/{tasks => tables}/components/UserNav.tsx (100%) rename app/{tasks => tables}/data/data.ts (100%) rename app/{tasks => tables}/data/schema.ts (100%) rename app/{tasks => tables}/page.tsx (100%) rename app/{drive => tree}/components/FileBrowser.tsx (100%) rename app/{drive => tree}/components/FileOperations.tsx (100%) rename app/{drive => tree}/components/FileTree.tsx (100%) create mode 100644 app/tree/components/editors/tabular/README.md rename app/{drive => tree}/page.tsx (96%) create mode 100644 app/video/page.tsx create mode 100644 src-rust/src/local-sync.rs diff --git a/app/authentication/components/user-auth-form.tsx b/app/auth/components/user-auth-form.tsx similarity index 100% rename from app/authentication/components/user-auth-form.tsx rename to app/auth/components/user-auth-form.tsx diff --git a/app/authentication/page.tsx b/app/auth/page.tsx similarity index 100% rename from app/authentication/page.tsx rename to app/auth/page.tsx diff --git a/app/chat/providers/chat-provider.tsx b/app/chat/providers/chat-provider.tsx index 263cd2b..cd40089 100644 --- a/app/chat/providers/chat-provider.tsx +++ b/app/chat/providers/chat-provider.tsx @@ -1,19 +1,32 @@ "use client"; import React, { createContext, useContext, useState, useEffect } from 'react'; -import { core } from '@tauri-apps/api'; import { User, ChatInstance } from '../types'; interface ChatContextType { line: any; user: User; instance: ChatInstance | null; - sendActivity: (activity: any) => void; + sendActivity: (activity: any) => Promise; selectedVoice: any; setVoice: (voice: any) => void; } const ChatContext = createContext(undefined); +// Unified API caller that works in both environments +async function apiFetch(endpoint: string, options?: RequestInit) { + const baseUrl = 'http://localhost:4242/'; //typeof window !== 'undefined' ? window.location.origin : ''; + + if (typeof window !== 'undefined' && '__TAURI__' in window) { + // Tauri environment - use HTTP module for better security + const { http } = await import('@tauri-apps/api'); + return http.fetch(`${baseUrl}${endpoint}`, options); + } + + // Web environment - standard fetch + return fetch(`${baseUrl}${endpoint}`, options); +} + export function ChatProvider({ children }: { children: React.ReactNode }) { const [line, setLine] = useState(null); const [instance, setInstance] = useState(null); @@ -22,20 +35,29 @@ export function ChatProvider({ children }: { children: React.ReactNode }) { id: `user_${Math.random().toString(36).slice(2)}`, name: 'You' }); - + useEffect(() => { const initializeChat = async () => { try { - const botId = window.location.pathname.split('/')[1] || 'default'; - const instanceData = await core.invoke('get_chat_instance', { botId }); - setInstance(instanceData as ChatInstance); + const botId = 'doula'; // window.location.pathname.split('/')[1] || 'default'; - // Initialize DirectLine or other chat service - const directLine = { + // Get instance from REST API + const response = await apiFetch(`/instances/${botId}`); + if (!response.ok) throw new Error('Failed to get chat instance'); + const instanceData = await response.json(); + setInstance(instanceData); + + // Initialize chat service + const chatService = { activity$: { subscribe: () => {} }, - postActivity: () => ({ subscribe: () => {} }) + postActivity: (activity: any) => ({ + subscribe: (observer: any) => { + // Handle real-time updates if needed + return { unsubscribe: () => {} }; + } + }) }; - setLine(directLine); + setLine(chatService); } catch (error) { console.error('Failed to initialize chat:', error); } @@ -46,14 +68,25 @@ export function ChatProvider({ children }: { children: React.ReactNode }) { const sendActivity = async (activity: any) => { try { - await core.invoke('send_chat_activity', { - activity: { - ...activity, - from: user, - timestamp: new Date().toISOString() - } + const fullActivity = { + ...activity, + from: user, + timestamp: new Date().toISOString() + }; + + // Send activity via REST API + const response = await apiFetch('/activities', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(fullActivity) }); - line?.postActivity(activity).subscribe(); + + if (!response.ok) throw new Error('Failed to send activity'); + + // Notify local chat service + line?.postActivity(fullActivity).subscribe(); } catch (error) { console.error('Failed to send activity:', error); } @@ -76,4 +109,4 @@ export function useChat() { throw new Error('useChat must be used within ChatProvider'); } return context; -} +} \ No newline at end of file diff --git a/app/client-nav.tsx b/app/client-nav.tsx index 8f61d59..7e1c5f3 100644 --- a/app/client-nav.tsx +++ b/app/client-nav.tsx @@ -4,12 +4,15 @@ import { usePathname, useRouter } from 'next/navigation'; import { Button } from '../src/components/ui/button'; const examples = [ - { name: "Home", href: "/authentication" }, + { name: "Home", href: "/auth" }, { name: "Dashboard", href: "/dashboard" }, { name: "Chat", href: "/chat" }, { name: "Mail", href: "/mail" }, - { name: "Drive", href: "/drive" }, - { name: "Tasks", href: "/tasks" }, + { name: "Tree", href: "/tree" }, + { name: "Editor", href: "/editor" }, + { name: "Tables", href: "/table" }, + { name: "Video", href: "/video" }, + { name: "Music", href: "/music" }, { name: "Templates", href: "/templates" }, { name: "Settings", href: "/sync" }, ]; diff --git a/app/editor/page.tsx b/app/editor/page.tsx new file mode 100644 index 0000000..e69de29 diff --git a/app/music/page.tsx b/app/music/page.tsx new file mode 100644 index 0000000..e69de29 diff --git a/app/tasks/components/DataTable.tsx b/app/tables/components/DataTable.tsx similarity index 100% rename from app/tasks/components/DataTable.tsx rename to app/tables/components/DataTable.tsx diff --git a/app/tasks/components/DataTablePagination.tsx b/app/tables/components/DataTablePagination.tsx similarity index 100% rename from app/tasks/components/DataTablePagination.tsx rename to app/tables/components/DataTablePagination.tsx diff --git a/app/tasks/components/DataTableToolbar.tsx b/app/tables/components/DataTableToolbar.tsx similarity index 100% rename from app/tasks/components/DataTableToolbar.tsx rename to app/tables/components/DataTableToolbar.tsx diff --git a/app/tasks/components/UserNav.tsx b/app/tables/components/UserNav.tsx similarity index 100% rename from app/tasks/components/UserNav.tsx rename to app/tables/components/UserNav.tsx diff --git a/app/tasks/data/data.ts b/app/tables/data/data.ts similarity index 100% rename from app/tasks/data/data.ts rename to app/tables/data/data.ts diff --git a/app/tasks/data/schema.ts b/app/tables/data/schema.ts similarity index 100% rename from app/tasks/data/schema.ts rename to app/tables/data/schema.ts diff --git a/app/tasks/page.tsx b/app/tables/page.tsx similarity index 100% rename from app/tasks/page.tsx rename to app/tables/page.tsx diff --git a/app/drive/components/FileBrowser.tsx b/app/tree/components/FileBrowser.tsx similarity index 100% rename from app/drive/components/FileBrowser.tsx rename to app/tree/components/FileBrowser.tsx diff --git a/app/drive/components/FileOperations.tsx b/app/tree/components/FileOperations.tsx similarity index 100% rename from app/drive/components/FileOperations.tsx rename to app/tree/components/FileOperations.tsx diff --git a/app/drive/components/FileTree.tsx b/app/tree/components/FileTree.tsx similarity index 100% rename from app/drive/components/FileTree.tsx rename to app/tree/components/FileTree.tsx diff --git a/app/tree/components/editors/tabular/README.md b/app/tree/components/editors/tabular/README.md new file mode 100644 index 0000000..9221f85 --- /dev/null +++ b/app/tree/components/editors/tabular/README.md @@ -0,0 +1,3 @@ + +Lotus 123. +https://docs.sheetjs.com/docs/demos/desktop/tauri \ No newline at end of file diff --git a/app/drive/page.tsx b/app/tree/page.tsx similarity index 96% rename from app/drive/page.tsx rename to app/tree/page.tsx index 0a7b8d0..829c4c8 100644 --- a/app/drive/page.tsx +++ b/app/tree/page.tsx @@ -4,6 +4,8 @@ import { FileTree } from './components/FileTree'; import { FileBrowser } from './components/FileBrowser'; import { FileOperations } from './components/FileOperations'; +// TODO: XTREE like keyword. + export default function DriveScreen() { const [currentPath, setCurrentPath] = useState(''); const [refreshKey, setRefreshKey] = useState(0); diff --git a/app/video/page.tsx b/app/video/page.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src-rust/src/local-sync.rs b/src-rust/src/local-sync.rs new file mode 100644 index 0000000..dd2e6f7 --- /dev/null +++ b/src-rust/src/local-sync.rs @@ -0,0 +1,444 @@ +use dioxus::prelude::*; +use dioxus_desktop::{use_window, LogicalSize}; +use std::env; +use std::fs::{File, OpenOptions, create_dir_all}; +use std::io::{BufRead, BufReader, Write}; +use std::path::Path; +use std::process::{Command as ProcCommand, Child, Stdio}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; +use notify_rust::Notification; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +// App state +#[derive(Debug, Clone)] +struct AppState { + name: String, + access_key: String, + secret_key: String, + status_text: String, + sync_processes: Arc>>, + sync_active: Arc>, + sync_statuses: Arc>>, + show_config_dialog: bool, + show_about_dialog: bool, + current_screen: Screen, +} + +#[derive(Debug, Clone)] +enum Screen { + Main, + Status, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct RcloneConfig { + name: String, + remote_path: String, + local_path: String, + access_key: String, + secret_key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct SyncStatus { + name: String, + status: String, + transferred: String, + bytes: String, + errors: usize, + last_updated: String, +} + +#[derive(Debug, Clone)] +enum Message { + NameChanged(String), + AccessKeyChanged(String), + SecretKeyChanged(String), + SaveConfig, + StartSync, + StopSync, + UpdateStatus(Vec), + ShowConfigDialog(bool), + ShowAboutDialog(bool), + ShowStatusScreen, + BackToMain, + None, +} + +fn main() { + dioxus_desktop::launch(app); +} + +fn app(cx: Scope) -> Element { + let window = use_window(); + window.set_inner_size(LogicalSize::new(800, 600)); + + let state = use_ref(cx, || AppState { + name: String::new(), + access_key: String::new(), + secret_key: String::new(), + status_text: "Enter credentials to set up sync".to_string(), + sync_processes: Arc::new(Mutex::new(Vec::new())), + sync_active: Arc::new(Mutex::new(false)), + sync_statuses: Arc::new(Mutex::new(Vec::new())), + show_config_dialog: false, + show_about_dialog: false, + current_screen: Screen::Main, + }); + + // Monitor sync status + use_future( async move { + let state = state.clone(); + async move { + let mut last_check = Instant::now(); + let check_interval = Duration::from_secs(5); + + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + + if !*state.read().sync_active.lock().unwrap() { + continue; + } + + if last_check.elapsed() < check_interval { + continue; + } + + last_check = Instant::now(); + + match read_rclone_configs() { + Ok(configs) => { + let mut new_statuses = Vec::new(); + for config in configs { + match get_rclone_status(&config.name) { + Ok(status) => new_statuses.push(status), + Err(e) => eprintln!("Failed to get status: {}", e), + } + } + *state.write().sync_statuses.lock().unwrap() = new_statuses.clone(); + state.write().status_text = format!("Syncing {} repositories...", new_statuses.len()); + } + Err(e) => eprintln!("Failed to read configs: {}", e), + } + } + } + }); + + cx.render(rsx! { + div { + class: "app", + // Main menu bar + div { + class: "menu-bar", + button { + onclick: move |_| state.write().show_config_dialog = true, + "Add Sync Configuration" + } + button { + onclick: move |_| state.write().show_about_dialog = true, + "About" + } + } + + // Main content + {match state.read().current_screen { + Screen::Main => rsx! { + div { + class: "main-screen", + h1 { "General Bots" } + p { "{state.read().status_text}" } + button { + onclick: move |_| start_sync(&state), + "Start Sync" + } + button { + onclick: move |_| stop_sync(&state), + "Stop Sync" + } + button { + onclick: move |_| state.write().current_screen = Screen::Status, + "Show Status" + } + } + }, + Screen::Status => rsx! { + div { + class: "status-screen", + h1 { "Sync Status" } + div { + class: "status-list", + for status in state.read().sync_statuses.lock().unwrap().iter() { + div { + class: "status-item", + h2 { "{status.name}" } + p { "Status: {status.status}" } + p { "Transferred: {status.transferred}" } + p { "Bytes: {status.bytes}" } + p { "Errors: {status.errors}" } + p { "Last Updated: {status.last_updated}" } + } + } + } + button { + onclick: move |_| state.write().current_screen = Screen::Main, + "Back" + } + } + } + }} + + // Config dialog + if state.read().show_config_dialog { + div { + class: "dialog", + h2 { "Add Sync Configuration" } + input { + value: "{state.read().name}", + oninput: move |e| state.write().name = e.value.clone(), + placeholder: "Enter sync name", + } + input { + value: "{state.read().access_key}", + oninput: move |e| state.write().access_key = e.value.clone(), + placeholder: "Enter access key", + } + input { + value: "{state.read().secret_key}", + oninput: move |e| state.write().secret_key = e.value.clone(), + placeholder: "Enter secret key", + } + button { + onclick: move |_| { + save_config(&state); + state.write().show_config_dialog = false; + }, + "Save" + } + button { + onclick: move |_| state.write().show_config_dialog = false, + "Cancel" + } + } + } + + // About dialog + if state.read().show_about_dialog { + div { + class: "dialog", + h2 { "About General Bots" } + p { "Version: 1.0.0" } + p { "A professional-grade sync tool for OneDrive/Dropbox-like functionality." } + button { + onclick: move |_| state.write().show_about_dialog = false, + "Close" + } + } + } + } + }) +} + +// Save sync configuration +fn save_config(state: &UseRef) { + if state.read().name.is_empty() || state.read().access_key.is_empty() || state.read().secret_key.is_empty() { + state.write_with(|state| state.status_text = "All fields are required!".to_string()); + return; + } + + let new_config = RcloneConfig { + name: state.read().name.clone(), + remote_path: format!("s3://{}", state.read().name), + local_path: Path::new(&env::var("HOME").unwrap()).join("General Bots").join(&state.read().name).to_string_lossy().to_string(), + access_key: state.read().access_key.clone(), + secret_key: state.read().secret_key.clone(), + }; + + if let Err(e) = save_rclone_config(&new_config) { + state.write_with(|state| state.status_text = format!("Failed to save config: {}", e)); + } else { + state.write_with(|state| state.status_text = "New sync saved!".to_string()); + } +} + +// Start sync process +fn start_sync(state: &UseRef) { + let mut processes = state.write_with(|state| state.sync_processes.lock().unwrap()); + processes.clear(); + + match read_rclone_configs() { + Ok(configs) => { + for config in configs { + match run_sync(&config) { + Ok(child) => processes.push(child), + Err(e) => eprintln!("Failed to start sync: {}", e), + } + } + state.write_with(|state| *state.sync_active.lock().unwrap() = true); + state.write_with(|state| state.status_text = format!("Syncing with {} configurations.", processes.len())); + } + Err(e) => state.write_with(|state| state.status_text = format!("Failed to read configurations: {}", e)), + } +} + +// Stop sync process +fn stop_sync(state: &UseRef) { + let mut processes = state.write_with(|state| state.sync_processes.lock().unwrap()); + for child in processes.iter_mut() { + let _ = child.kill(); + } + processes.clear(); + state.write_with(|state| *state.sync_active.lock().unwrap() = false); + state.write_with(|state| state.status_text = "Sync stopped.".to_string()); +} + +// Utility functions (rclone, notifications, etc.) +fn save_rclone_config(config: &RcloneConfig) -> Result<(), String> { + let home_dir = env::var("HOME").map_err(|_| "HOME environment variable not set".to_string())?; + let config_path = Path::new(&home_dir).join(".config/rclone/rclone.conf"); + + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&config_path) + .map_err(|e| format!("Failed to open config file: {}", e))?; + + writeln!(file, "[{}]", config.name) + .and_then(|_| writeln!(file, "type = s3")) + .and_then(|_| writeln!(file, "provider = Other")) + .and_then(|_| writeln!(file, "access_key_id = {}", config.access_key)) + .and_then(|_| writeln!(file, "secret_access_key = {}", config.secret_key)) + .and_then(|_| writeln!(file, "endpoint = https://drive-api.pragmatismo.com.br")) + .and_then(|_| writeln!(file, "acl = private")) + .map_err(|e| format!("Failed to write config: {}", e)) +} + +fn read_rclone_configs() -> Result, String> { + let home_dir = env::var("HOME").map_err(|_| "HOME environment variable not set".to_string())?; + let config_path = Path::new(&home_dir).join(".config/rclone/rclone.conf"); + + if !config_path.exists() { + return Ok(Vec::new()); + } + + let file = File::open(&config_path).map_err(|e| format!("Failed to open config file: {}", e))?; + let reader = BufReader::new(file); + let mut configs = Vec::new(); + let mut current_config: Option = None; + + for line in reader.lines() { + let line = line.map_err(|e| format!("Failed to read line: {}", e))?; + if line.is_empty() || line.starts_with('#') { + continue; + } + + if line.starts_with('[') && line.ends_with(']') { + if let Some(config) = current_config.take() { + configs.push(config); + } + let name = line[1..line.len()-1].to_string(); + current_config = Some(RcloneConfig { + name: name.clone(), + remote_path: format!("s3://{}", name), + local_path: Path::new(&home_dir).join("General Bots").join(&name).to_string_lossy().to_string(), + access_key: String::new(), + secret_key: String::new(), + }); + } else if let Some(ref mut config) = current_config { + if let Some(pos) = line.find('=') { + let key = line[..pos].trim().to_string(); + let value = line[pos+1..].trim().to_string(); + match key.as_str() { + "access_key_id" => config.access_key = value, + "secret_access_key" => config.secret_key = value, + _ => {} + } + } + } + } + + if let Some(config) = current_config { + configs.push(config); + } + + Ok(configs) +} + +fn run_sync(config: &RcloneConfig) -> Result { + let local_path = Path::new(&config.local_path); + if !local_path.exists() { + create_dir_all(local_path)?; + } + + ProcCommand::new("rclone") + .arg("sync") + .arg(&config.remote_path) + .arg(&config.local_path) + .arg("--no-check-certificate") + .arg("--verbose") + .arg("--rc") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() +} + +fn get_rclone_status(remote_name: &str) -> Result { + let output = ProcCommand::new("rclone") + .arg("rc") + .arg("core/stats") + .arg("--json") + .output() + .map_err(|e| format!("Failed to execute rclone rc: {}", e))?; + + if !output.status.success() { + return Err(format!("rclone rc failed: {}", String::from_utf8_lossy(&output.stderr))); + } + + let json = String::from_utf8_lossy(&output.stdout); + let parsed: Result = serde_json::from_str(&json); + match parsed { + Ok(value) => { + let transferred = value.get("bytes").and_then(|v| v.as_u64()).unwrap_or(0); + let errors = value.get("errors").and_then(|v| v.as_u64()).unwrap_or(0); + let speed = value.get("speed").and_then(|v| v.as_f64()).unwrap_or(0.0); + + let status = if errors > 0 { + "Error occurred".to_string() + } else if speed > 0.0 { + "Transferring".to_string() + } else if transferred > 0 { + "Completed".to_string() + } else { + "Initializing".to_string() + }; + + Ok(SyncStatus { + name: remote_name.to_string(), + status, + transferred: format_bytes(transferred), + bytes: format!("{}/s", format_bytes(speed as u64)), + errors: errors as usize, + last_updated: chrono::Local::now().format("%H:%M:%S").to_string(), + }) + } + Err(e) => Err(format!("Failed to parse rclone status: {}", e)), + } +} + +fn format_bytes(bytes: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = KB * 1024; + const GB: u64 = MB * 1024; + + if bytes >= GB { + format!("{:.2} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.2} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{:.2} KB", bytes as f64 / KB as f64) + } else { + format!("{} B", bytes) + } +} \ No newline at end of file diff --git a/src-rust/tauri.conf.json b/src-rust/tauri.conf.json index 4a19f60..1671e04 100644 --- a/src-rust/tauri.conf.json +++ b/src-rust/tauri.conf.json @@ -5,7 +5,7 @@ "identifier": "online.generalbots", "build": { "beforeDevCommand": "npm run dev", - "devUrl": "http://localhost:1420", + "devUrl": "http://localhost:3001", "beforeBuildCommand": "npm run build", "frontendDist": "../out" },