Add About and Login pages with responsive design and user authentication
- Created a new About page (index.html) detailing the BotServer platform, its features, and technology stack. - Developed a Login page (login.html) with sign-in and sign-up functionality, including form validation and user feedback messages. - Removed the empty style.css file as it is no longer needed.
This commit is contained in:
parent
32feb58b00
commit
dfe7e4e4b6
70 changed files with 17866 additions and 72 deletions
3190
Cargo.lock
generated
3190
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
20
Cargo.toml
20
Cargo.toml
|
|
@ -11,7 +11,6 @@ authors = [
|
|||
"Arenas.io",
|
||||
"Atylla L",
|
||||
"Christopher de Castilho",
|
||||
"Dario Lima",
|
||||
"Dario Junior",
|
||||
"David Lerner",
|
||||
"Experimentation Garage",
|
||||
|
|
@ -38,10 +37,12 @@ license = "AGPL-3.0"
|
|||
repository = "https://github.com/GeneralBots/BotServer"
|
||||
|
||||
[features]
|
||||
default = ["vectordb"]
|
||||
desktop = ["tauri", "tauri-plugin-opener", "tauri-plugin-dialog"]
|
||||
default = [ "vectordb"]
|
||||
vectordb = ["qdrant-client"]
|
||||
email = ["imap"]
|
||||
web_automation = ["headless_chrome"]
|
||||
webapp = ["tauri", "tauri-plugin-opener", "tauri-plugin-dialog"]
|
||||
|
||||
[dependencies]
|
||||
actix-cors = "0.7"
|
||||
|
|
@ -70,7 +71,6 @@ log = "0.4"
|
|||
mailparse = "0.15"
|
||||
native-tls = "0.2"
|
||||
|
||||
|
||||
num-format = "0.4"
|
||||
qdrant-client = { version = "1.12", optional = true }
|
||||
rhai = { git = "https://github.com/therealprof/rhai.git", branch = "features/use-web-time" }
|
||||
|
|
@ -97,3 +97,17 @@ scraper = "0.20"
|
|||
sha2 = "0.10.9"
|
||||
ureq = "3.1.2"
|
||||
indicatif = "0.18.0"
|
||||
|
||||
tauri = { version = "2", features = ["unstable"], optional = true }
|
||||
tauri-plugin-opener = { version = "2", optional = true }
|
||||
tauri-plugin-dialog = { version = "2", optional = true }
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[profile.release]
|
||||
lto = true # Enables Link-Time Optimization
|
||||
opt-level = "z" # Optimizes for size instead of speed
|
||||
strip = true # Strips debug symbols
|
||||
panic = "abort" # Reduces size by removing panic unwinding
|
||||
codegen-units = 1 # More aggressive optimization
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
#![allow(dead_code)]
|
||||
#![cfg_attr(feature = "desktop", windows_subsystem = "windows")]
|
||||
use actix_cors::Cors;
|
||||
use actix_web::middleware::Logger;
|
||||
use actix_web::{web, App, HttpServer};
|
||||
|
|
@ -18,6 +19,10 @@ mod context;
|
|||
mod drive_monitor;
|
||||
#[cfg(feature = "email")]
|
||||
mod email;
|
||||
|
||||
#[cfg(feature = "desktop")]
|
||||
mod ui;
|
||||
|
||||
mod file;
|
||||
mod kb;
|
||||
mod llm;
|
||||
|
|
@ -56,6 +61,7 @@ use crate::web_server::{bot_index, index, static_files};
|
|||
use crate::whatsapp::whatsapp_webhook_verify;
|
||||
use crate::whatsapp::WhatsAppAdapter;
|
||||
|
||||
#[cfg(not(feature = "desktop"))]
|
||||
#[tokio::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
|
|
|||
39
src/riot_compiler/mod.rs
Normal file
39
src/riot_compiler/mod.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
use boa_engine::{Context, JsValue, Source};
|
||||
|
||||
fn compile_riot_component(riot_code: &str) -> Result<JsValue, Box<dyn std::error::Error>> {
|
||||
let mut context = Context::default();
|
||||
|
||||
let compiler = include_str!("riot_compiler.js"); // Your Riot compiler logic
|
||||
|
||||
context.eval(Source::from_bytes(compiler))?;
|
||||
|
||||
let result = context.eval(Source::from_bytes(&format!(
|
||||
"compileRiot(`{}`)",
|
||||
riot_code.replace('`', "\\`")
|
||||
)))?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let riot_component = r#"
|
||||
<todo-item>
|
||||
<h3>{ props.title }</h3>
|
||||
<input if="{ !props.done }" type="checkbox" onclick="{ toggle }">
|
||||
<span if="{ props.done }">✓ Done</span>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
toggle() {
|
||||
this.props.done = !this.props.done
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</todo-item>
|
||||
"#;
|
||||
|
||||
match compile_riot_component(riot_component) {
|
||||
Ok(compiled) => println!("Compiled: {:?}", compiled),
|
||||
Err(e) => eprintln!("Compilation failed: {}", e),
|
||||
}
|
||||
}
|
||||
101
src/ui/drive.rs
Normal file
101
src/ui/drive.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::{Emitter, Window};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct FileItem {
|
||||
name: String,
|
||||
path: String,
|
||||
is_dir: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_files(path: &str) -> Result<Vec<FileItem>, String> {
|
||||
let base_path = Path::new(path);
|
||||
let mut files = Vec::new();
|
||||
|
||||
if !base_path.exists() {
|
||||
return Err("Path does not exist".into());
|
||||
}
|
||||
|
||||
for entry in fs::read_dir(base_path).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let path = entry.path();
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
files.push(FileItem {
|
||||
name,
|
||||
path: path.to_str().unwrap_or("").to_string(),
|
||||
is_dir: path.is_dir(),
|
||||
});
|
||||
}
|
||||
|
||||
// Sort directories first, then files
|
||||
files.sort_by(|a, b| {
|
||||
if a.is_dir && !b.is_dir {
|
||||
std::cmp::Ordering::Less
|
||||
} else if !a.is_dir && b.is_dir {
|
||||
std::cmp::Ordering::Greater
|
||||
} else {
|
||||
a.name.cmp(&b.name)
|
||||
}
|
||||
});
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn upload_file(window: Window, src_path: String, dest_path: String) -> Result<(), String> {
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
let src = PathBuf::from(&src_path);
|
||||
let dest_dir = PathBuf::from(&dest_path);
|
||||
let dest = dest_dir.join(src.file_name().ok_or("Invalid source file")?);
|
||||
|
||||
// Create destination directory if it doesn't exist
|
||||
if !dest_dir.exists() {
|
||||
fs::create_dir_all(&dest_dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let mut source_file = File::open(&src).map_err(|e| e.to_string())?;
|
||||
let mut dest_file = File::create(&dest).map_err(|e| e.to_string())?;
|
||||
|
||||
let file_size = source_file.metadata().map_err(|e| e.to_string())?.len();
|
||||
let mut buffer = [0; 8192];
|
||||
let mut total_read = 0;
|
||||
|
||||
loop {
|
||||
let bytes_read = source_file.read(&mut buffer).map_err(|e| e.to_string())?;
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
dest_file
|
||||
.write_all(&buffer[..bytes_read])
|
||||
.map_err(|e| e.to_string())?;
|
||||
total_read += bytes_read as u64;
|
||||
|
||||
let progress = (total_read as f64 / file_size as f64) * 100.0;
|
||||
window
|
||||
.emit("upload_progress", progress)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_folder(path: String, name: String) -> Result<(), String> {
|
||||
let full_path = Path::new(&path).join(&name);
|
||||
if full_path.exists() {
|
||||
return Err("Folder already exists".into());
|
||||
}
|
||||
fs::create_dir(full_path).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
444
src/ui/local-sync.rs
Normal file
444
src/ui/local-sync.rs
Normal file
|
|
@ -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<Mutex<Vec<Child>>>,
|
||||
sync_active: Arc<Mutex<bool>>,
|
||||
sync_statuses: Arc<Mutex<Vec<SyncStatus>>>,
|
||||
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<SyncStatus>),
|
||||
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<AppState>) {
|
||||
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<AppState>) {
|
||||
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<AppState>) {
|
||||
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<Vec<RcloneConfig>, 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<RcloneConfig> = 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<Child, std::io::Error> {
|
||||
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<SyncStatus, String> {
|
||||
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<Value, _> = 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)
|
||||
}
|
||||
}
|
||||
4
src/ui/mod.rs
Normal file
4
src/ui/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
pub mod drive;
|
||||
pub mod sync;
|
||||
145
src/ui/sync.rs
Normal file
145
src/ui/sync.rs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Mutex;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::path::Path;
|
||||
use std::fs::{OpenOptions, create_dir_all};
|
||||
use std::io::Write;
|
||||
use std::env;
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RcloneConfig {
|
||||
name: String,
|
||||
remote_path: String,
|
||||
local_path: String,
|
||||
access_key: String,
|
||||
secret_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SyncStatus {
|
||||
name: String,
|
||||
status: String,
|
||||
transferred: String,
|
||||
bytes: String,
|
||||
errors: usize,
|
||||
last_updated: String,
|
||||
}
|
||||
|
||||
pub(crate) struct AppState {
|
||||
pub sync_processes: Mutex<Vec<std::process::Child>>,
|
||||
pub sync_active: Mutex<bool>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_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))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn start_sync(config: RcloneConfig, state: tauri::State<AppState>) -> Result<(), String> {
|
||||
let local_path = Path::new(&config.local_path);
|
||||
if !local_path.exists() {
|
||||
create_dir_all(local_path).map_err(|e| format!("Failed to create local path: {}", e))?;
|
||||
}
|
||||
|
||||
let child = Command::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()
|
||||
.map_err(|e| format!("Failed to start rclone: {}", e))?;
|
||||
|
||||
state.sync_processes.lock().unwrap().push(child);
|
||||
*state.sync_active.lock().unwrap() = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn stop_sync(state: tauri::State<AppState>) -> Result<(), String> {
|
||||
let mut processes = state.sync_processes.lock().unwrap();
|
||||
for child in processes.iter_mut() {
|
||||
child.kill().map_err(|e| format!("Failed to kill process: {}", e))?;
|
||||
}
|
||||
processes.clear();
|
||||
*state.sync_active.lock().unwrap() = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_status(remote_name: String) -> Result<SyncStatus, String> {
|
||||
let output = Command::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 value: serde_json::Value = serde_json::from_str(&json)
|
||||
.map_err(|e| format!("Failed to parse rclone status: {}", e))?;
|
||||
|
||||
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,
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
pub 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ use std::fs;
|
|||
|
||||
#[actix_web::get("/")]
|
||||
async fn index() -> Result<HttpResponse> {
|
||||
match fs::read_to_string("web/index.html") {
|
||||
match fs::read_to_string("web/app/index.html") {
|
||||
Ok(html) => Ok(HttpResponse::Ok().content_type("text/html").body(html)),
|
||||
Err(e) => {
|
||||
error!("Failed to load index page: {}", e);
|
||||
|
|
@ -26,10 +26,10 @@ async fn bot_index(req: HttpRequest) -> Result<HttpResponse> {
|
|||
}
|
||||
}
|
||||
|
||||
#[actix_web::get("/static/{filename:.*}")]
|
||||
#[actix_web::get("/{filename:.*}")]
|
||||
async fn static_files(req: HttpRequest) -> Result<HttpResponse> {
|
||||
let filename = req.match_info().query("filename");
|
||||
let path = format!("web/static/{}", filename);
|
||||
let path = format!("web/app/{}", filename);
|
||||
match fs::read(&path) {
|
||||
Ok(content) => {
|
||||
debug!(
|
||||
|
|
@ -39,6 +39,8 @@ async fn static_files(req: HttpRequest) -> Result<HttpResponse> {
|
|||
);
|
||||
let content_type = match filename {
|
||||
f if f.ends_with(".js") => "application/javascript",
|
||||
f if f.ends_with(".riot") => "application/javascript",
|
||||
f if f.ends_with(".html") => "application/javascript",
|
||||
f if f.ends_with(".css") => "text/css",
|
||||
f if f.ends_with(".png") => "image/png",
|
||||
f if f.ends_with(".jpg") | f.ends_with(".jpeg") => "image/jpeg",
|
||||
|
|
|
|||
450
web/app/app.html
Normal file
450
web/app/app.html
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
<app>
|
||||
<script>
|
||||
export default {
|
||||
// Component state
|
||||
data() {
|
||||
return {
|
||||
email: '',
|
||||
password: '',
|
||||
isLoading: false,
|
||||
error: '',
|
||||
};
|
||||
},
|
||||
|
||||
// Configuration (ZITADEL)
|
||||
onBeforeMount() {
|
||||
// this.zitadelConfig = {
|
||||
// authority: 'https://your-zitadel-instance.com',
|
||||
// clientId: 'your-client-id',
|
||||
// redirectUri: typeof window !== 'undefined' ? window.location.origin : '',
|
||||
// scopes: ['openid', 'profile', 'email'],
|
||||
// };
|
||||
},
|
||||
|
||||
// Methods
|
||||
methods: {
|
||||
handleSocialLogin(provider) {
|
||||
this.isLoading = true;
|
||||
this.error = '';
|
||||
try {
|
||||
const authUrl = `${this.zitadelConfig.authority}/oauth/v2/authorize?` +
|
||||
`client_id=${this.zitadelConfig.clientId}&` +
|
||||
`redirect_uri=${encodeURIComponent(this.zitadelConfig.redirectUri)}&` +
|
||||
`response_type=code&` +
|
||||
`scope=${encodeURIComponent(this.zitadelConfig.scopes.join(' '))}&` +
|
||||
`provider=${provider}`;
|
||||
window.location.href = authUrl;
|
||||
} catch (err) {
|
||||
this.error = 'Failed to initiate login';
|
||||
console.error('Login error:', err);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
handleEmailLogin(e) {
|
||||
e.preventDefault();
|
||||
this.isLoading = true;
|
||||
this.error = '';
|
||||
try {
|
||||
// Mock implementation – store dummy token
|
||||
localStorage.setItem('authToken', 'dummy-token');
|
||||
// Navigate to dashboard (adjust path as needed)
|
||||
window.location.href = '/dashboard';
|
||||
} catch (err) {
|
||||
this.error = 'Login failed. Please check your credentials.';
|
||||
console.error('Login error:', err);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="auth-screen">
|
||||
<div class="auth-content">
|
||||
<div class="auth-left-panel">
|
||||
<div class="auth-logo">
|
||||
<h1>Welcome to General Bots Online</h1>
|
||||
</div>
|
||||
<div class="auth-quote">
|
||||
<p>"Errar é Humano."</p>
|
||||
<p>General Bots</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="auth-form-container">
|
||||
<div class="auth-form-header">
|
||||
<h2>Sign in to your account</h2>
|
||||
<p>Choose your preferred login method</p>
|
||||
</div>
|
||||
|
||||
<div class="auth-error" if={error}>{error}</div>
|
||||
|
||||
<div class="auth-social-buttons">
|
||||
<button class="auth-social-button google" @click={()=> handleSocialLogin('google')}
|
||||
disabled={isLoading}>
|
||||
<svg class="auth-social-icon" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
fill="#4285F4" />
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853" />
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05" />
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335" />
|
||||
</svg>
|
||||
Continue with Google
|
||||
</button>
|
||||
|
||||
<button class="auth-social-button microsoft" @click={()=> handleSocialLogin('microsoft')}
|
||||
disabled={isLoading}>
|
||||
<svg class="auth-social-icon" viewBox="0 0 23 23">
|
||||
<path d="M0 0h11v11H0zM12 0h11v11H12zM0 12h11v11H0zM12 12h11v11H12z" fill="#F25022" />
|
||||
<path d="M12 0h11v11H12z" fill="#7FBA00" />
|
||||
<path d="M0 12h11v11H0z" fill="#00A4EF" />
|
||||
<path d="M12 12h11v11H12z" fill="#FFB900" />
|
||||
</svg>
|
||||
Continue with Microsoft
|
||||
</button>
|
||||
|
||||
<button class="auth-social-button facebook" @click={()=> handleSocialLogin('facebook')}
|
||||
disabled={isLoading}>
|
||||
<svg class="auth-social-icon" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22 12c0-5.52-4.48-10-10-10S2 6.48 2 12c0 4.84 3.44 8.87 8 9.8V15H8v-3h2V9.5C10 7.57 11.57 6 13.5 6H16v3h-2c-.55 0-1 .45-1 1v2h3v3h-3v6.95c5.05-.5 9-4.76 9-9.95z"
|
||||
fill="#1877F2" />
|
||||
</svg>
|
||||
Continue with Facebook
|
||||
</button>
|
||||
|
||||
<button class="auth-social-button pragmatismo" @click={()=> handleSocialLogin('pragmatismo')}
|
||||
disabled={isLoading}>
|
||||
<svg class="auth-social-icon" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z"
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
Continue with Pragmatismo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="auth-divider">
|
||||
<span>OR</span>
|
||||
</div>
|
||||
|
||||
<form class="auth-form" @submit={handleEmailLogin}>
|
||||
<div class="auth-form-group">
|
||||
<label for="email">Email</label>
|
||||
<input id="email" type="email" value={email} oninput={e=> this.email = e.target.value}
|
||||
placeholder="your@email.com" required />
|
||||
</div>
|
||||
|
||||
<div class="auth-form-group">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" type="password" value={password} oninput={e=> this.password = e.target.value}
|
||||
placeholder="••••••••" required />
|
||||
</div>
|
||||
|
||||
<div class="auth-form-options">
|
||||
<div class="auth-remember-me">
|
||||
<input type="checkbox" id="remember" />
|
||||
<label for="remember">Remember me</label>
|
||||
</div>
|
||||
<a href="#" class="auth-forgot-password">Forgot password?</a>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="auth-submit-button" disabled={isLoading}>
|
||||
{isLoading ? 'Signing in...' : 'Sign in with Email'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-signup-link">
|
||||
Don't have an account? <a href="#">Sign up</a>
|
||||
</div>
|
||||
|
||||
<p class="auth-terms">
|
||||
By continuing, you agree to our <a href="#">Terms of Service</a> and <a href="#">Privacy Policy</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.auth-screen {
|
||||
--background: hsl(var(--background));
|
||||
--foreground: hsl(var(--foreground));
|
||||
--card: hsl(var(--card));
|
||||
--card-foreground: hsl(var(--card-foreground));
|
||||
--primary: hsl(var(--primary));
|
||||
--primary-foreground: hsl(var(--primary-foreground));
|
||||
--secondary: hsl(var(--secondary));
|
||||
--secondary-foreground: hsl(var(--secondary-foreground));
|
||||
--muted: hsl(var(--muted));
|
||||
--muted-foreground: hsl(var(--muted-foreground));
|
||||
--accent: hsl(var(--accent));
|
||||
--accent-foreground: hsl(var(--accent-foreground));
|
||||
--destructive: hsl(var(--destructive));
|
||||
--destructive-foreground: hsl(var(--destructive-foreground));
|
||||
--border: hsl(var(--border));
|
||||
--input: hsl(var(--input));
|
||||
--ring: hsl(var(--ring));
|
||||
--radius: var(--radius);
|
||||
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.auth-content {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
background-color: var(--card);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.auth-left-panel {
|
||||
flex: 1;
|
||||
padding: 4rem;
|
||||
background: linear-gradient(135deg, var(--primary), var(--accent));
|
||||
color: var(--primary-foreground);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.auth-logo h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.auth-quote {
|
||||
font-style: italic;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.auth-quote p:last-child {
|
||||
text-align: right;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.auth-form-container {
|
||||
flex: 1;
|
||||
padding: 4rem;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.auth-form-header {
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-form-header h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.auth-form-header p {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.auth-error {
|
||||
background-color: var(--destructive);
|
||||
color: var(--destructive-foreground);
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-social-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.auth-social-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--radius);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid var(--border);
|
||||
background-color: var(--secondary);
|
||||
color: var(--secondary-foreground);
|
||||
}
|
||||
|
||||
.auth-social-button:hover {
|
||||
background-color: var(--muted);
|
||||
}
|
||||
|
||||
.auth-social-button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.auth-social-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.auth-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 1.5rem 0;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.auth-divider::before,
|
||||
.auth-divider::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.auth-divider span {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.auth-form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.auth-form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.auth-form-group input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background-color: var(--input);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.auth-form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--ring);
|
||||
box-shadow: 0 0 0 2px var(--ring);
|
||||
}
|
||||
|
||||
.auth-form-options {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.auth-remember-me {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.auth-remember-me input {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.auth-forgot-password {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.auth-forgot-password:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.auth-submit-button {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--radius);
|
||||
background-color: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.auth-submit-button:hover {
|
||||
background-color: color-mix(in srgb, var(--primary), black 10%);
|
||||
}
|
||||
|
||||
.auth-submit-button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.auth-signup-link {
|
||||
text-align: center;
|
||||
margin: 1.5rem 0;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.auth-signup-link a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.auth-signup-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.auth-terms {
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.auth-terms a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.auth-terms a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.auth-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.auth-left-panel {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.auth-form-container {
|
||||
padding: 2rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.auth-social-buttons {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
</app>
|
||||
298
web/app/chat/chat.page.html
Normal file
298
web/app/chat/chat.page.html
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
<!-- Riot.js component for the chat page (converted from app/chat/page.tsx) -->
|
||||
<template>
|
||||
<div class="flex min-h-[calc(100vh-43px)] bg-background text-foreground">
|
||||
<!-- Sidebar -->
|
||||
<div class="{sidebarOpen ? 'w-80' : 'w-0'} transition-all duration-300 ease-in-out bg-card border-r border-border flex flex-col overflow-hidden">
|
||||
{sidebarOpen && (
|
||||
<>
|
||||
<!-- Sidebar Header -->
|
||||
<div class="p-4 border-b border-border flex-shrink-0">
|
||||
<button @click={newChat} class="flex items-center gap-3 w-full p-3 rounded-xl border-2 border-dashed border-muted hover:border-accent hover:bg-accent/10 transition-all duration-200 group">
|
||||
<Plus class="w-5 h-5 text-muted-foreground group-hover:text-accent" />
|
||||
<span class="font-medium text-foreground group-hover:text-accent">New Chat</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Conversations List -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<div class="h-full overflow-y-auto px-3 py-2 space-y-1">
|
||||
{conversations.map(conv => (
|
||||
<div
|
||||
key={conv.id}
|
||||
class="group relative flex items-center gap-3 p-3 rounded-xl cursor-pointer transition-all duration-200 {conv.active ? 'bg-primary/10 border border-primary' : 'hover:bg-secondary'}"
|
||||
@click={() => setActiveConversation(conv.id)}
|
||||
>
|
||||
<MessageSquare class="w-4 h-4 flex-shrink-0 {conv.active ? 'text-primary' : 'text-muted-foreground'}" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium truncate {conv.active ? 'text-primary' : 'text-foreground'}">{conv.title}</div>
|
||||
<div class="text-xs text-muted-foreground truncate">{formatTimestamp(conv.timestamp)}</div>
|
||||
</div>
|
||||
{conv.active && <div class="w-2 h-2 rounded-full bg-primary flex-shrink-0"></div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar Footer -->
|
||||
<div class="p-4 border-t border-border flex-shrink-0">
|
||||
<div class="flex items-center gap-3 p-3 rounded-xl hover:bg-secondary cursor-pointer transition-colors duration-200">
|
||||
<User class="w-5 h-5 text-muted-foreground" />
|
||||
<User class="w-5 h-5 text-muted-foreground" />
|
||||
<User class="w-5 h-5 text-muted-foreground" />
|
||||
<User class="w-5 h-5 text-muted-foreground" />
|
||||
<User class="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<!-- Main Chat Area -->
|
||||
<div class="flex-1 flex flex-col min-w-0 bg-background">
|
||||
<!-- Header -->
|
||||
<div class="flex-shrink-0 p-4 border-b border-border bg-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<button @click={() => sidebarOpen = !sidebarOpen} class="p-2 hover:bg-secondary rounded-lg transition-colors duration-200">
|
||||
<Menu class="w-5 h-5" />
|
||||
</button>
|
||||
<h1 class="text-xl font-semibold text-foreground">{activeConversation?.title || 'New Chat'}</h1>
|
||||
</div>
|
||||
<button class="p-2 hover:bg-secondary rounded-lg transition-colors duration-200">
|
||||
<Search class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages Container -->
|
||||
<div class="flex-1 overflow-hidden flex flex-col">
|
||||
<div class="flex-1 overflow-y-auto px-4 py-6 space-y-6">
|
||||
{messages.map(message => (
|
||||
<div key={message.id} class="group flex {message.type === 'user' ? 'justify-end' : 'justify-start'}">
|
||||
<div class="max-w-[85%] md:max-w-[75%] {message.type === 'user' ? 'bg-primary text-primary-foreground' : 'bg-secondary text-secondary-foreground'} rounded-2xl px-4 py-3 shadow-sm">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 mt-0.5">
|
||||
{message.type === 'user' ? <User class="w-4 h-4" /> : <Bot class="w-4 h-4" />}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="whitespace-pre-wrap break-words leading-relaxed">{message.content}</div>
|
||||
<div class="mt-3 flex items-center justify-between text-xs {message.type === 'user' ? 'text-primary-foreground/80' : 'text-muted-foreground'}">
|
||||
<span>{formatTimestamp(message.timestamp)}</span>
|
||||
{message.type === 'assistant' && (
|
||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<button class="p-1.5 hover:bg-secondary rounded-md transition-colors">
|
||||
<Copy class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
<button class="p-1.5 hover:bg-secondary rounded-md transition-colors">
|
||||
<ThumbsUp class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
<button class="p-1.5 hover:bg-secondary rounded-md transition-colors">
|
||||
<ThumbsDown class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
<button class="p-1.5 hover:bg-secondary rounded-md transition-colors">
|
||||
<Share class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isTyping && (
|
||||
<div class="flex justify-start">
|
||||
<div class="max-w-[85%] md:max-w-[75%] bg-secondary rounded-2xl px-4 py-3 shadow-sm">
|
||||
<div class="flex items-center gap-3">
|
||||
<Bot class="w-4 h-4 text-muted-foreground" />
|
||||
<div class="flex gap-1">
|
||||
<div class="w-2 h-2 rounded-full bg-muted-foreground animate-bounce"></div>
|
||||
<div class="w-2 h-2 rounded-full bg-muted-foreground animate-bounce" style="animation-delay:0.2s"></div>
|
||||
<div class="w-2 h-2 rounded-full bg-muted-foreground animate-bounce" style="animation-delay:0.4s"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mode Carousel -->
|
||||
<div class="flex-shrink-0 border-t border-border bg-card">
|
||||
<div class="overflow-x-auto px-4 py-3">
|
||||
<div class="flex gap-2 min-w-max">
|
||||
{modeButtons.map(button => (
|
||||
<button
|
||||
key={button.id}
|
||||
@click={() => activeMode = button.id}
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 whitespace-nowrap {activeMode === button.id ? 'bg-primary/10 text-primary border border-primary' : 'bg-secondary text-secondary-foreground hover:bg-secondary/80'}"
|
||||
>
|
||||
{button.icon}
|
||||
<span>{button.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input Area -->
|
||||
<div class="flex-shrink-0 p-4 border-t border-border bg-card">
|
||||
<div class="relative max-w-4xl mx-auto">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
@input={e => input = e.target.value}
|
||||
@keydown={handleKeyDown}
|
||||
placeholder="Type your message..."
|
||||
class="w-full p-4 pr-14 rounded-2xl border border-input bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent resize-none transition-all duration-200"
|
||||
rows="1"
|
||||
style="min-height:56px;max-height:120px"
|
||||
></textarea>
|
||||
<button
|
||||
@click={handleSubmit}
|
||||
disabled={!input.trim()}
|
||||
class="absolute right-6 bottom-3 p-2.5 rounded-xl transition-all duration-200 {input.trim() ? 'bg-primary hover:bg-primary/90 text-primary-foreground shadow-lg hover:shadow-xl transform hover:scale-105' : 'bg-muted text-muted-foreground cursor-not-allowed'}"
|
||||
>
|
||||
<Send class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script type="module">
|
||||
import { useState, useRef } from 'riot';
|
||||
import {
|
||||
Send, Plus, Menu, Search,
|
||||
MessageSquare, User, Bot, Copy, ThumbsUp, ThumbsDown,
|
||||
Share, Image, Video,
|
||||
Brain, Globe
|
||||
} from 'lucide-react';
|
||||
import './style.css';
|
||||
|
||||
export default {
|
||||
// Reactive state
|
||||
messages: [],
|
||||
input: '',
|
||||
isTyping: false,
|
||||
sidebarOpen: true,
|
||||
conversations: [],
|
||||
activeMode: 'assistant',
|
||||
modeButtons: [],
|
||||
activeConversation: null,
|
||||
textareaRef: null,
|
||||
messagesEndRef: null,
|
||||
|
||||
// Lifecycle
|
||||
async mounted() {
|
||||
// Initialize state (mirroring the original React defaults)
|
||||
this.messages = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'assistant',
|
||||
content: "Hello! I'm General Bots, a large language model by Pragmatismo. How can I help you today?",
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
];
|
||||
this.input = '';
|
||||
this.isTyping = false;
|
||||
this.sidebarOpen = true;
|
||||
this.conversations = [
|
||||
{ id: 1, title: 'Current Chat', timestamp: new Date(), active: true },
|
||||
{ id: 2, title: 'Previous Conversation', timestamp: new Date(Date.now() - 86400000), active: false },
|
||||
{ id: 3, title: 'Code Review Discussion', timestamp: new Date(Date.now() - 172800000), active: false },
|
||||
{ id: 4, title: 'Project Planning', timestamp: new Date(Date.now() - 259200000), active: false },
|
||||
];
|
||||
this.activeMode = 'assistant';
|
||||
this.modeButtons = [
|
||||
{ id: 'deep-think', icon: <Brain size={16} />, label: 'Deep Think' },
|
||||
{ id: 'web', icon: <Globe size={16} />, label: 'Web' },
|
||||
{ id: 'image', icon: <Image size={16} />, label: 'Image' },
|
||||
{ id: 'video', icon: <Video size={16} />, label: 'Video' },
|
||||
];
|
||||
this.setActiveConversation(1);
|
||||
this.scrollToBottom();
|
||||
},
|
||||
|
||||
// Helpers
|
||||
formatTimestamp(timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffInHours = (now - date) / (1000 * 60 * 60);
|
||||
if (diffInHours < 24) {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (diffInHours < 48) {
|
||||
return 'Yesterday';
|
||||
} else {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
},
|
||||
|
||||
scrollToBottom() {
|
||||
this.messagesEndRef?.scrollIntoView({ behavior: 'smooth' });
|
||||
},
|
||||
|
||||
// Event handlers
|
||||
async handleSubmit() {
|
||||
if (!this.input.trim()) return;
|
||||
const userMessage = {
|
||||
id: Date.now(),
|
||||
type: 'user',
|
||||
content: this.input.trim(),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
this.messages = [...this.messages, userMessage];
|
||||
this.input = '';
|
||||
this.isTyping = true;
|
||||
this.update();
|
||||
|
||||
// Simulate assistant response
|
||||
setTimeout(() => {
|
||||
const assistantMessage = {
|
||||
id: Date.now() + 1,
|
||||
type: 'assistant',
|
||||
content: `I understand you're asking about "${userMessage.content}". This is a simulated response to demonstrate the chat interface. The actual implementation would connect to your chat provider and send real responses.`,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
this.messages = [...this.messages, assistantMessage];
|
||||
this.isTyping = false;
|
||||
this.update();
|
||||
this.scrollToBottom();
|
||||
}, 1500);
|
||||
},
|
||||
|
||||
handleKeyDown(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this.handleSubmit();
|
||||
}
|
||||
},
|
||||
|
||||
newChat() {
|
||||
const newConv = {
|
||||
id: Date.now(),
|
||||
title: 'New Chat',
|
||||
timestamp: new Date(),
|
||||
active: true
|
||||
};
|
||||
this.conversations = [newConv, ...this.conversations.map(c => ({ ...c, active: false }))];
|
||||
this.messages = [
|
||||
{
|
||||
id: Date.now(),
|
||||
type: 'assistant',
|
||||
content: "Hello! I'm General Bots, a large language model by Pragmatismo. How can I help you today?",
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
];
|
||||
this.setActiveConversation(newConv.id);
|
||||
},
|
||||
|
||||
setActiveConversation(id) {
|
||||
this.conversations = this.conversations.map(c => ({ ...c, active: c.id === id }));
|
||||
this.activeConversation = this.conversations.find(c => c.id === id);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
414
web/app/client-nav.css
Normal file
414
web/app/client-nav.css
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
.nav-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
background: hsl(var(--background));
|
||||
height: auto;
|
||||
min-height: 40px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.nav-inner {
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 16px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nav-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.auth-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.login-container,
|
||||
.theme-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.login-button,
|
||||
.theme-toggle {
|
||||
background: hsl(var(--accent));
|
||||
border: 1px solid hsl(var(--border));
|
||||
color: hsl(var(--accent-foreground));
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.login-button:hover,
|
||||
.theme-toggle:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 0 10px hsla(var(--primary), 0.5);
|
||||
}
|
||||
|
||||
.login-menu,
|
||||
.theme-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
background: hsl(var(--popover));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 6px;
|
||||
min-width: 120px;
|
||||
z-index: 100;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.menu-item,
|
||||
.theme-menu-item {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: left;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.menu-item:hover,
|
||||
.theme-menu-item:hover {
|
||||
background: hsl(var(--accent));
|
||||
color: hsl(var(--accent-foreground));
|
||||
}
|
||||
|
||||
.active-theme {
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
}
|
||||
|
||||
.scroll-btn {
|
||||
background: hsl(var(--accent));
|
||||
border: 1px solid hsl(var(--border));
|
||||
color: hsl(var(--accent-foreground));
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
transition: all 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.scroll-btn:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 0 10px hsla(var(--primary), 0.5);
|
||||
}
|
||||
|
||||
.scroll-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.nav-scroll {
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
height: 100%;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
scroll-behavior: smooth;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-items {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
white-space: nowrap;
|
||||
gap: 3px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 6px 14px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.nav-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(var(--neon-color-rgb, 0, 255, 255), 0.2), transparent);
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.nav-item:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
border-color: var(--neon-color, hsl(var(--primary)));
|
||||
color: var(--neon-color, hsl(var(--primary)));
|
||||
box-shadow: 0 0 15px rgba(var(--neon-color-rgb, 0, 255, 255), 0.3);
|
||||
text-shadow: 0 0 6px rgba(var(--neon-color-rgb, 0, 255, 255), 0.4);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
border-color: var(--neon-color, hsl(var(--primary)));
|
||||
color: var(--neon-color, hsl(var(--primary)));
|
||||
box-shadow: 0 0 20px rgba(var(--neon-color-rgb, 0, 255, 255), 0.4);
|
||||
text-shadow: 0 0 8px rgba(var(--neon-color-rgb, 0, 255, 255), 0.6);
|
||||
}
|
||||
|
||||
.nav-item.active:hover {
|
||||
box-shadow: 0 0 25px rgba(var(--neon-color-rgb, 0, 255, 255), 0.6);
|
||||
}
|
||||
|
||||
.neon-glow {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
background: linear-gradient(45deg, transparent, rgba(var(--neon-color-rgb, 0, 255, 255), 0.3), transparent);
|
||||
border-radius: 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.nav-item:hover .neon-glow,
|
||||
.nav-item.active .neon-glow {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.nav-spacer {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
/* Set CSS custom properties for each neon color */
|
||||
.nav-item[style*="--neon-color: #25D366"] {
|
||||
--neon-color-rgb: 37, 211, 102;
|
||||
}
|
||||
|
||||
.nav-item[style*="--neon-color: #6366F1"] {
|
||||
--neon-color-rgb: 99, 102, 241;
|
||||
}
|
||||
|
||||
.nav-item[style*="--neon-color: #FFD700"] {
|
||||
--neon-color-rgb: 255, 215, 0;
|
||||
}
|
||||
|
||||
.nav-item[style*="--neon-color: #10B981"] {
|
||||
--neon-color-rgb: 16, 185, 129;
|
||||
}
|
||||
|
||||
.nav-item[style*="--neon-color: #2563EB"] {
|
||||
--neon-color-rgb: 37, 99, 235;
|
||||
}
|
||||
|
||||
.nav-item[style*="--neon-color: #8B5CF6"] {
|
||||
--neon-color-rgb: 139, 92, 246;
|
||||
}
|
||||
|
||||
.nav-item[style*="--neon-color: #059669"] {
|
||||
--neon-color-rgb: 5, 150, 105;
|
||||
}
|
||||
|
||||
.nav-item[style*="--neon-color: #DC2626"] {
|
||||
--neon-color-rgb: 220, 38, 38;
|
||||
}
|
||||
|
||||
.nav-item[style*="--neon-color: #1DB954"] {
|
||||
--neon-color-rgb: 29, 185, 84;
|
||||
}
|
||||
|
||||
.nav-item[style*="--neon-color: #F59E0B"] {
|
||||
--neon-color-rgb: 245, 158, 11;
|
||||
}
|
||||
|
||||
.nav-item[style*="--neon-color: #6B7280"] {
|
||||
--neon-color-rgb: 107, 114, 128;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.nav-container {
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.nav-spacer {
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.nav-inner {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.nav-content {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.scroll-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.theme-toggle,
|
||||
.login-button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
font-size: 13px;
|
||||
padding: 8px 16px;
|
||||
height: 36px;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.nav-items {
|
||||
gap: 6px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.auth-controls {
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.nav-container {
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.nav-spacer {
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.nav-inner {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.nav-content {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.scroll-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.theme-toggle,
|
||||
.login-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
font-size: 12px;
|
||||
padding: 10px 14px;
|
||||
height: 34px;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.nav-items {
|
||||
gap: 4px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.auth-controls {
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 320px) {
|
||||
.nav-inner {
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.nav-content {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 8px 12px;
|
||||
height: 32px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.nav-items {
|
||||
gap: 3px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.theme-toggle,
|
||||
.login-button {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.scroll-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch-friendly scrolling for mobile */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.nav-scroll {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-snap-type: x mandatory;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
}
|
||||
340
web/app/client-nav.html
Normal file
340
web/app/client-nav.html
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
|
||||
<script type="module">
|
||||
// Import icons from Lucide (using CDN)
|
||||
import { HardDrive, Terminal, ChevronLeft, ChevronRight } from 'https://cdn.jsdelivr.net/npm/lucide@0.331.0/+esm';
|
||||
|
||||
export default {
|
||||
// Component state
|
||||
data() {
|
||||
return {
|
||||
examples: [
|
||||
{ name: "Chat", href: "/chat", color: "#25D366" },
|
||||
{ name: "Paper", href: "/paper", color: "#6366F1" },
|
||||
{ name: "Mail", href: "/mail", color: "#FFD700" },
|
||||
{ name: "Calendar", href: "/calendar", color: "#1DB954" },
|
||||
{ name: "Meet", href: "/meet", color: "#059669" },
|
||||
{ name: "Drive", href: "/drive", color: "#10B981" },
|
||||
{ name: "Editor", href: "/editor", color: "#2563EB" },
|
||||
{ name: "Player", href: "/player", color: "Yellow" },
|
||||
{ name: "Tables", href: "/tables", color: "#8B5CF6" },
|
||||
{ name: "Dashboard", href: "/dashboard", color: "#6366F1" },
|
||||
{ name: "Sources", href: "/sources", color: "#F59E0B" },
|
||||
{ name: "Settings", href: "/settings", color: "#6B7280" },
|
||||
],
|
||||
pathname: window.location.pathname,
|
||||
scrollContainer: null,
|
||||
navItems: [],
|
||||
isLoggedIn: false,
|
||||
showLoginMenu: false,
|
||||
showScrollButtons: false,
|
||||
loginMenu: null,
|
||||
showThemeMenu: false,
|
||||
themeMenu: null,
|
||||
currentTime: new Date(),
|
||||
theme: { name: "default", label: "Default", icon: "🎨" },
|
||||
themes: [
|
||||
{ name: "retrowave", label: "Retrowave", icon: "🌌" },
|
||||
{ name: "vapordream", label: "Vapordream", icon: "🌀" },
|
||||
{ name: "y2kglow", label: "Y2K Glow", icon: "💿" },
|
||||
{ name: "mellowgold", label: "Mellow Gold", icon: "☮️" },
|
||||
{ name: "arcadeflash", label: "Arcade Flash", icon: "🕹️" },
|
||||
{ name: "polaroidmemories", label: "Polaroid Memories", icon: "📸" },
|
||||
{ name: "midcenturymod", label: "Mid‑Century Mod", icon: "🪑" },
|
||||
{ name: "grungeera", label: "Grunge Era", icon: "🎸" },
|
||||
{ name: "discofever", label: "Disco Fever", icon: "🪩" },
|
||||
{ name: "saturdaycartoons", label: "Saturday Cartoons", icon: "📺" },
|
||||
{ name: "oldhollywood", label: "Old Hollywood", icon: "🎬" },
|
||||
{ name: "cyberpunk", label: "Cyberpunk", icon: "🤖" },
|
||||
{ name: "seasidepostcard", label: "Seaside Postcard", icon: "🏖️" },
|
||||
{ name: "typewriter", label: "Typewriter", icon: "⌨️" },
|
||||
{ name: "jazzage", label: "Jazz Age", icon: "🎷" },
|
||||
{ name: "xtreegold", label: "X‑Tree Gold", icon: "X" },
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
// Lifecycle: component mounted
|
||||
mounted() {
|
||||
// References
|
||||
this.scrollContainer = this.root.querySelector('.nav-scroll');
|
||||
this.loginMenu = this.root.querySelector('.login-menu');
|
||||
this.themeMenu = this.root.querySelector('.theme-menu');
|
||||
|
||||
// Initialize nav item refs
|
||||
this.navItems = Array.from(this.root.querySelectorAll('.nav-item'));
|
||||
|
||||
// Time update interval
|
||||
this.timeInterval = setInterval(() => {
|
||||
this.currentTime = new Date();
|
||||
this.update();
|
||||
}, 1000);
|
||||
|
||||
// Scroll button visibility
|
||||
this.checkScrollNeeded();
|
||||
|
||||
// Resize listener
|
||||
window.addEventListener('resize', this.checkScrollNeeded);
|
||||
|
||||
// Click‑outside handling
|
||||
document.addEventListener('mousedown', this.handleClickOutside);
|
||||
},
|
||||
|
||||
// Cleanup
|
||||
unmounted() {
|
||||
clearInterval(this.timeInterval);
|
||||
window.removeEventListener('resize', this.checkScrollNeeded);
|
||||
document.removeEventListener('mousedown', this.handleClickOutside);
|
||||
},
|
||||
|
||||
// Methods
|
||||
formatTime(date) {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
},
|
||||
|
||||
formatDate(date) {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
},
|
||||
|
||||
isActive(href) {
|
||||
if (href === '/') return this.pathname === href;
|
||||
return this.pathname.startsWith(href);
|
||||
},
|
||||
|
||||
handleLogin() {
|
||||
this.isLoggedIn = true;
|
||||
this.showLoginMenu = false;
|
||||
this.update();
|
||||
},
|
||||
|
||||
handleLogout() {
|
||||
this.isLoggedIn = false;
|
||||
this.showLoginMenu = false;
|
||||
this.update();
|
||||
},
|
||||
|
||||
checkScrollNeeded() {
|
||||
if (this.scrollContainer) {
|
||||
const container = this.scrollContainer;
|
||||
const isScrollable = container.scrollWidth > container.clientWidth;
|
||||
this.showScrollButtons = isScrollable;
|
||||
this.update();
|
||||
}
|
||||
},
|
||||
|
||||
handleClickOutside(event) {
|
||||
if (this.loginMenu && !this.loginMenu.contains(event.target)) {
|
||||
this.showLoginMenu = false;
|
||||
}
|
||||
if (this.themeMenu && !this.themeMenu.contains(event.target)) {
|
||||
this.showThemeMenu = false;
|
||||
}
|
||||
this.update();
|
||||
},
|
||||
|
||||
scrollLeft() {
|
||||
if (this.scrollContainer) {
|
||||
this.scrollContainer.scrollBy({ left: -150, behavior: 'smooth' });
|
||||
}
|
||||
},
|
||||
|
||||
scrollRight() {
|
||||
if (this.scrollContainer) {
|
||||
this.scrollContainer.scrollBy({ left: 150, behavior: 'smooth' });
|
||||
}
|
||||
},
|
||||
|
||||
getThemeIcon() {
|
||||
const found = this.themes.find(t => t.name === this.theme.name);
|
||||
return found ? found.icon : '🎨';
|
||||
},
|
||||
|
||||
setTheme(name) {
|
||||
const found = this.themes.find(t => t.name === name);
|
||||
if (found) {
|
||||
this.theme = found;
|
||||
this.showThemeMenu = false;
|
||||
this.update();
|
||||
}
|
||||
},
|
||||
|
||||
navigate(href) {
|
||||
window.location.href = href;
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Basic styles - the original Tailwind classes are kept as comments for reference */
|
||||
.fixed { position: fixed; }
|
||||
.top-0 { top: 0; }
|
||||
.left-0 { left: 0; }
|
||||
.right-0 { right: 0; }
|
||||
.z-50 { z-index: 50; }
|
||||
.bg-gray-800 { background-color: #2d3748; }
|
||||
.text-green-400 { color: #68d391; }
|
||||
.font-mono { font-family: monospace; }
|
||||
.border-b { border-bottom: 1px solid transparent; }
|
||||
.border-green-600 { border-color: #38a169; }
|
||||
.text-xs { font-size: .75rem; }
|
||||
.flex { display: flex; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.px-4 { padding-left: 1rem; padding-right: 1rem; }
|
||||
.py-1 { padding-top: .25rem; padding-bottom: .25rem; }
|
||||
.gap-4 > * + * { margin-left: 1rem; }
|
||||
.gap-2 > * + * { margin-left: .5rem; }
|
||||
.w-3 { width: .75rem; }
|
||||
.h-3 { height: .75rem; }
|
||||
.text-green-300 { color: #9ae6b4; }
|
||||
.w-2 { width: .5rem; }
|
||||
.h-2 { height: .5rem; }
|
||||
.bg-green-500 { background-color: #48bb78; }
|
||||
.rounded-full { border-radius: 9999px; }
|
||||
.animate-pulse { animation: pulse 2s infinite; }
|
||||
.nav-container { position: relative; }
|
||||
.nav-inner { overflow: hidden; }
|
||||
.nav-content { display: flex; align-items: center; gap: .5rem; }
|
||||
.logo-container img { display: block; }
|
||||
.nav-scroll { overflow-x: auto; scrollbar-width: none; -ms-overflow-style: none; }
|
||||
.nav-scroll::-webkit-scrollbar { display: none; }
|
||||
.nav-items { display: flex; gap: .25rem; }
|
||||
.nav-item { padding: .25rem .5rem; border: 1px solid transparent; border-radius: .25rem; cursor: pointer; transition: all .2s; }
|
||||
.nav-item.active { background-color: #2d3748; border-color: #68d391; color: #68d391; }
|
||||
.nav-item:hover { border-color: currentColor; }
|
||||
.auth-controls { display: flex; gap: .5rem; }
|
||||
.login-button, .theme-toggle { background: none; border: none; color: inherit; cursor: pointer; }
|
||||
.login-menu, .theme-menu { position: absolute; background: #1a202c; border: 1px solid #4a5568; padding: .5rem; margin-top: .25rem; border-radius: .25rem; }
|
||||
.menu-item { display: block; width: 100%; text-align: left; padding: .25rem; background: none; border: none; color: #a0aec0; cursor: pointer; }
|
||||
.menu-item:hover { background: #2d3748; }
|
||||
.active-theme { font-weight: bold; }
|
||||
</style>
|
||||
|
||||
<!-- Markup -->
|
||||
<div class="fixed top-0 left-0 right-0 z-50 bg-gray-800 text-green-400 font-mono border-b border-green-600 text-xs">
|
||||
<div class="flex items-center justify-between px-4 py-1">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<HardDrive class="w-3 h-3 text-green-400" />
|
||||
<span class="text-green-300">RETRO NAVIGATOR v4.0</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span class="text-green-400">READY</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-green-300">THEME:</span>
|
||||
<span class="text-yellow-400">{theme.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-green-300">{formatDate(currentTime)}</span>
|
||||
<span class="text-green-300">{formatTime(currentTime)}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<Terminal class="w-3 h-3 text-green-400" />
|
||||
<span class="text-green-400">SYS</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-container" style="top:24px;">
|
||||
<div class="nav-inner">
|
||||
<div class="nav-content">
|
||||
<div class="logo-container">
|
||||
<img src="/images/generalbots-logo.svg" alt="Logo" width="64" height="24" />
|
||||
</div>
|
||||
|
||||
{#if showScrollButtons}
|
||||
<button class="w-8 h-8 bg-gray-800 border border-green-600 text-green-400 rounded hover:bg-green-900/30 hover:border-green-500 hover:text-green-300 transition-all flex items-center justify-center flex-shrink-0 mx-1"
|
||||
@click="scrollLeft" aria-label="Scroll left">
|
||||
<ChevronLeft class="w-4 h-4" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="nav-scroll">
|
||||
<div class="nav-items">
|
||||
{#each examples as example, index}
|
||||
<button class="nav-item {isActive(example.href) ? 'active' : ''}"
|
||||
@click="{() => navigate(example.href)}"
|
||||
style="--neon-color:{example.color}"
|
||||
@mouseenter="{(e) => {
|
||||
e.target.style.boxShadow = `0 0 15px ${example.color}60`;
|
||||
e.target.style.borderColor = example.color;
|
||||
e.target.style.color = example.color;
|
||||
e.target.style.textShadow = `0 0 8px ${example.color}80`;
|
||||
}}"
|
||||
@mouseleave="{(e) => {
|
||||
if (!isActive(example.href)) {
|
||||
e.target.style.boxShadow = 'none';
|
||||
e.target.style.borderColor = '';
|
||||
e.target.style.color = '';
|
||||
e.target.style.textShadow = 'none';
|
||||
}
|
||||
}}">
|
||||
{example.name}
|
||||
<div class="neon-glow"></div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showScrollButtons}
|
||||
<button class="w-8 h-8 bg-gray-800 border border-green-600 text-green-400 rounded hover:bg-green-900/30 hover:border-green-500 hover:text-green-300 transition-all flex items-center justify-center flex-shrink-0 mx-1"
|
||||
@click="scrollRight" aria-label="Scroll right">
|
||||
<ChevronRight class="w-4 h-4" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="auth-controls">
|
||||
<div class="login-container" bind:this="{loginMenu}">
|
||||
<button @click="{() => showLoginMenu = !showLoginMenu}"
|
||||
class="login-button"
|
||||
aria-label="{isLoggedIn ? 'User menu' : 'Login'}">
|
||||
{isLoggedIn ? '👤' : '🔐'}
|
||||
</button>
|
||||
|
||||
{#if showLoginMenu}
|
||||
<div class="login-menu">
|
||||
{#if !isLoggedIn}
|
||||
<button @click="handleLogin" class="menu-item">Login</button>
|
||||
{:else}
|
||||
<button @click="handleLogout" class="menu-item">Logout</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="theme-container" bind:this="{themeMenu}">
|
||||
<button @click="{() => showThemeMenu = !showThemeMenu}"
|
||||
class="theme-toggle"
|
||||
aria-label="Change theme">
|
||||
{getThemeIcon()}
|
||||
</button>
|
||||
|
||||
{#if showThemeMenu}
|
||||
<div class="theme-menu">
|
||||
{#each themes as t}
|
||||
<button @click="{() => setTheme(t.name)}"
|
||||
class="theme-menu-item {theme.name === t.name ? 'active-theme' : ''}">
|
||||
{t.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-spacer" style="height:88px;"></div>
|
||||
</div>
|
||||
145
web/app/dashboard/dashboard.page.html
Normal file
145
web/app/dashboard/dashboard.page.html
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
<!-- Riot.js component for the dashboard page (converted from app/dashboard/page.tsx) -->
|
||||
<template>
|
||||
<div class="min-h-screen bg-background text-foreground">
|
||||
<main class="container p-4 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-foreground">Dashboard</h1>
|
||||
<div class="flex items-center space-x-2">
|
||||
<calendar-date-range-picker />
|
||||
<button class="px-4 py-2 bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity">
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="p-6 bg-card border rounded-lg border-border" each={card in cards}>
|
||||
<h3 class="text-sm font-medium text-muted-foreground">{card.title}</h3>
|
||||
<p class="text-2xl font-bold mt-1 text-card-foreground">{card.value}</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">{card.subtext}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="p-6 bg-card border rounded-lg border-border">
|
||||
<h3 class="text-lg font-medium mb-4 text-card-foreground">Overview</h3>
|
||||
<overview />
|
||||
</div>
|
||||
<div class="p-6 bg-card border rounded-lg space-y-4 border-border">
|
||||
<h3 class="text-lg font-medium text-card-foreground">Recent Sales</h3>
|
||||
<p class="text-card-foreground">You made 265 sales this month.</p>
|
||||
<recent-sales />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script type="module">
|
||||
import { useState } from 'riot';
|
||||
import './style.css';
|
||||
|
||||
export default {
|
||||
// Reactive state
|
||||
dateRange: { startDate: new Date(), endDate: new Date() },
|
||||
salesData: [
|
||||
{ name: "Olivia Martin", email: "olivia.martin@email.com", amount: "+$1,999.00" },
|
||||
{ name: "Jackson Lee", email: "jackson.lee@email.com", amount: "+$39.00" },
|
||||
{ name: "Isabella Nguyen", email: "isabella.nguyen@email.com", amount: "+$299.00" },
|
||||
{ name: "William Kim", email: "will@email.com", amount: "+$99.00" },
|
||||
{ name: "Sofia Davis", email: "sofia.davis@email.com", amount: "+$39.00" },
|
||||
],
|
||||
cards: [
|
||||
{ title: "Total Revenue", value: "$45,231.89", subtext: "+20.1% from last month" },
|
||||
{ title: "Subscriptions", value: "+2350", subtext: "+180.1% from last month" },
|
||||
{ title: "Sales", value: "+12,234", subtext: "+19% from last month" },
|
||||
{ title: "Active Now", value: "+573", subtext: "+201 since last hour" },
|
||||
],
|
||||
|
||||
// Helpers
|
||||
formatDate(date) {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
},
|
||||
|
||||
// Lifecycle
|
||||
async mounted() {
|
||||
// No additional setup needed
|
||||
},
|
||||
|
||||
// Sub‑components
|
||||
// CalendarDateRangePicker
|
||||
components: {
|
||||
'calendar-date-range-picker': {
|
||||
template: `
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="px-3 py-1 border rounded text-foreground border-border hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
@click={setStart}>
|
||||
Start: {formatDate(parent.dateRange.startDate)}
|
||||
</button>
|
||||
<span class="text-foreground">to</span>
|
||||
<button class="px-3 py-1 border rounded text-foreground border-border hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
@click={setEnd}>
|
||||
End: {formatDate(parent.dateRange.endDate)}
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
methods: {
|
||||
setStart() {
|
||||
const input = prompt("Enter start date (YYYY-MM-DD)");
|
||||
if (input) {
|
||||
parent.dateRange.startDate = new Date(input);
|
||||
}
|
||||
},
|
||||
setEnd() {
|
||||
const input = prompt("Enter end date (YYYY-MM-DD)");
|
||||
if (input) {
|
||||
parent.dateRange.endDate = new Date(input);
|
||||
}
|
||||
},
|
||||
formatDate(date) {
|
||||
return parent.formatDate(date);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Overview
|
||||
overview: {
|
||||
template: `
|
||||
<div class="p-4 border rounded-lg border-border">
|
||||
<div class="flex justify-between items-end h-40">
|
||||
<div each={h, i in [100,80,60,40,20]}
|
||||
class="w-8 opacity-60"
|
||||
style="height:{h}px;background-color:hsl(var(--chart-{(i%5)+1}))">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
|
||||
// RecentSales
|
||||
'recent-sales': {
|
||||
template: `
|
||||
<div class="space-y-4">
|
||||
<div each={item, i in parent.salesData}
|
||||
class="flex items-center justify-between p-2 border-b border-border">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 rounded-full bg-secondary flex items-center justify-center text-secondary-foreground">
|
||||
{item.name[0]}
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-foreground">{item.name}</p>
|
||||
<p class="text-sm text-muted-foreground">{item.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="font-medium text-foreground">{item.amount}</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
227
web/app/drive/drive.page.html
Normal file
227
web/app/drive/drive.page.html
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
<!-- Riot.js component for the drive page (converted from app/drive/page.tsx) -->
|
||||
<template>
|
||||
<div class="flex flex-col h-[calc(100vh-40px)] bg-background">
|
||||
<resizable-panel-group direction="horizontal" class="flex-1 min-h-0">
|
||||
<!-- Folder Tree Panel -->
|
||||
<resizable-panel
|
||||
default-size="20"
|
||||
collapsed-size="4"
|
||||
collapsible="true"
|
||||
min-size="15"
|
||||
max-size="30"
|
||||
@collapse="{() => isCollapsed = true}"
|
||||
@resize="{() => isCollapsed = false}"
|
||||
class="{isCollapsed && 'min-w-[50px] transition-all duration-300'}"
|
||||
>
|
||||
<folder-tree
|
||||
on-select="{setCurrentPath}"
|
||||
selected-path="{currentPath}"
|
||||
is-collapsed="{isCollapsed}"
|
||||
/>
|
||||
</resizable-panel>
|
||||
|
||||
<resizable-handle with-handle class="bg-border" />
|
||||
|
||||
<!-- File List Panel -->
|
||||
<resizable-panel default-size="50" min-size="30" class="bg-background border-r border-border">
|
||||
<tabs default-value="all" class="flex flex-col h-full">
|
||||
<div class="flex items-center px-4 py-2 bg-secondary border-b border-border">
|
||||
<h1 class="text-xl font-bold">{currentItem?.name || 'My Drive'}</h1>
|
||||
<tabs-list class="ml-auto bg-background">
|
||||
<tabs-trigger value="all" class="data-[state=active]:bg-accent">All</tabs-trigger>
|
||||
<tabs-trigger value="starred" class="data-[state=active]:bg-accent">Starred</tabs-trigger>
|
||||
</tabs-list>
|
||||
</div>
|
||||
<separator class="bg-border" />
|
||||
<div class="bg-secondary p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative flex-1">
|
||||
<search class="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<input placeholder="Search files"
|
||||
class="pl-8 bg-background border border-border"
|
||||
bind="{searchTerm}"
|
||||
@input="{e => searchTerm = e.target.value}" />
|
||||
</div>
|
||||
<select value="{filterType}" @change="{e => filterType = e.target.value}">
|
||||
<option value="all">All items</option>
|
||||
<option value="folders">Folders</option>
|
||||
<option value="files">Files</option>
|
||||
<option value="starred">Starred</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<tabs-content value="all" class="m-0 flex-1">
|
||||
<file-list
|
||||
path="{currentPath}"
|
||||
search-term="{searchTerm}"
|
||||
filter-type="{filterType}"
|
||||
selected-file="{selectedFile}"
|
||||
set-selected-file="{file => selectedFile = file}"
|
||||
on-context-action="{handleContextAction}"
|
||||
/>
|
||||
</tabs-content>
|
||||
<tabs-content value="starred" class="m-0 flex-1">
|
||||
<file-list
|
||||
path="{currentPath}"
|
||||
search-term="{searchTerm}"
|
||||
filter-type="starred"
|
||||
selected-file="{selectedFile}"
|
||||
set-selected-file="{file => selectedFile = file}"
|
||||
on-context-action="{handleContextAction}"
|
||||
/>
|
||||
</tabs-content>
|
||||
</tabs>
|
||||
</resizable-panel>
|
||||
|
||||
<resizable-handle with-handle class="bg-border" />
|
||||
|
||||
<!-- File Details Panel -->
|
||||
<resizable-panel default-size="30" min-size="25" class="bg-background">
|
||||
<file-display file="{selectedFile}" />
|
||||
</resizable-panel>
|
||||
</resizable-panel-group>
|
||||
<footer-component shortcuts="{shortcuts}" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script type="module">
|
||||
import { useState, useEffect } from 'riot';
|
||||
import {
|
||||
Search, Download, Trash2, Share, Star,
|
||||
MoreVertical, Home, ChevronRight, ChevronLeft,
|
||||
Folder, File, Image, Video, Music, FileText, Code, Database,
|
||||
Clock, Users, Eye, Edit3, Copy, Scissors,
|
||||
FolderPlus, Info, Lock, Menu,
|
||||
ExternalLink, History, X
|
||||
} from 'lucide-react';
|
||||
import { cn } from "@/lib/utils";
|
||||
import Footer from '../footer';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger } from '@/components/ui/context-menu';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
import './style.css';
|
||||
|
||||
export default {
|
||||
// Reactive state
|
||||
isCollapsed: false,
|
||||
currentPath: '',
|
||||
searchTerm: '',
|
||||
filterType: 'all',
|
||||
selectedFile: null,
|
||||
isMobile: false,
|
||||
showMobileMenu: false,
|
||||
activePanel: 'files',
|
||||
shortcuts: [],
|
||||
|
||||
// Lifecycle
|
||||
async mounted() {
|
||||
// Initialize shortcuts (same as original component)
|
||||
this.shortcuts = [
|
||||
[
|
||||
{ key: 'Q', label: 'Rename', action: () => console.log('Rename') },
|
||||
{ key: 'W', label: 'View', action: () => console.log('View') },
|
||||
{ key: 'E', label: 'Edit', action: () => console.log('Edit') },
|
||||
{ key: 'R', label: 'Move', action: () => console.log('Move') },
|
||||
{ key: 'T', label: 'MkDir', action: () => console.log('Make Directory') },
|
||||
{ key: 'Y', label: 'Delete', action: () => console.log('Delete') },
|
||||
{ key: 'U', label: 'Copy', action: () => console.log('Copy') },
|
||||
{ key: 'I', label: 'Cut', action: () => console.log('Cut') },
|
||||
{ key: 'O', label: 'Paste', action: () => console.log('Paste') },
|
||||
{ key: 'P', label: 'Duplicate', action: () => console.log('Duplicate') },
|
||||
],
|
||||
[
|
||||
{ key: 'A', label: 'Select', action: () => console.log('Select') },
|
||||
{ key: 'S', label: 'Select All', action: () => console.log('Select All') },
|
||||
{ key: 'D', label: 'Deselect', action: () => console.log('Deselect') },
|
||||
{ key: 'G', label: 'Details', action: () => console.log('Details') },
|
||||
{ key: 'H', label: 'History', action: () => console.log('History') },
|
||||
{ key: 'J', label: 'Share', action: () => console.log('Share') },
|
||||
{ key: 'K', label: 'Star', action: () => console.log('Star') },
|
||||
{ key: 'L', label: 'Download', action: () => console.log('Download') },
|
||||
{ key: 'Z', label: 'Upload', action: () => console.log('Upload') },
|
||||
{ key: 'X', label: 'Refresh', action: () => console.log('Refresh') },
|
||||
]
|
||||
];
|
||||
},
|
||||
|
||||
// Helpers
|
||||
formatDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
},
|
||||
|
||||
formatDateTime(dateString) {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
},
|
||||
|
||||
formatDistanceToNow(date) {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - new Date(date).getTime();
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffMinutes < 1) return 'now';
|
||||
if (diffMinutes < 60) return `${diffMinutes}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return this.formatDate(date);
|
||||
},
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (!bytes) return '';
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
|
||||
},
|
||||
|
||||
getFileIcon(item) {
|
||||
if (item.is_dir) {
|
||||
return <Folder className="w-4 h-4 text-yellow-500" />;
|
||||
}
|
||||
const iconMap = {
|
||||
pdf: <FileText className="w-4 h-4 text-red-500" />,
|
||||
xlsx: <Database className="w-4 h-4 text-green-600" />,
|
||||
json: <Code className="w-4 h-4 text-yellow-600" />,
|
||||
markdown: <Edit3 className="w-4 h-4 text-purple-500" />,
|
||||
md: <Edit3 className="w-4 h-4 text-purple-500" />,
|
||||
jpg: <Image className="w-4 h-4 text-pink-500" />,
|
||||
jpeg: <Image className="w-4 h-4 text-pink-500" />,
|
||||
png: <Image className="w-4 h-4 text-pink-500" />,
|
||||
mp4: <Video className="w-4 h-4 text-red-600" />,
|
||||
mp3: <Music className="w-4 h-4 text-green-600" />
|
||||
};
|
||||
return iconMap[item.type] || <File className="w-4 h-4 text-muted-foreground" />;
|
||||
},
|
||||
|
||||
// Context actions
|
||||
handleContextAction(action, file) {
|
||||
console.log(`Context action: ${action}`, file);
|
||||
// Implement actions as needed
|
||||
}
|
||||
};
|
||||
</script>
|
||||
1
web/app/drive/prompt.md
Normal file
1
web/app/drive/prompt.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
- The UI shoule look exactly xtree gold but using shadcn with keyborad shortcut well explicit.
|
||||
340
web/app/editor/editor.page.html
Normal file
340
web/app/editor/editor.page.html
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
<!-- Riot.js component for the editor page (converted from app/editor/page.tsx) -->
|
||||
<template>
|
||||
<div class="word-clone">
|
||||
<!-- Quick Access Toolbar -->
|
||||
<div class="quick-access">
|
||||
<button class="quick-access-btn" @click={undo}>
|
||||
<svg><!-- Undo icon (use same SVG as in React) --></svg>
|
||||
</button>
|
||||
<button class="quick-access-btn" @click={redo}>
|
||||
<svg><!-- Redo icon --></svg>
|
||||
</button>
|
||||
<div class="title-controls">
|
||||
<input
|
||||
type="text"
|
||||
class="title-input"
|
||||
placeholder="Document name"
|
||||
value={fileName}
|
||||
@input={e => fileName = e.target.value}
|
||||
/>
|
||||
<button class="quick-access-btn" @click={saveDocument}>
|
||||
<svg><!-- Save icon --></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ribbon -->
|
||||
<div class="ribbon">
|
||||
<div class="ribbon-tabs">
|
||||
<button class="ribbon-tab-button {activeTab === 'home' ? 'active' : ''}" @click={() => activeTab = 'home'}>Home</button>
|
||||
<button class="ribbon-tab-button {activeTab === 'insert' ? 'active' : ''}" @click={() => activeTab = 'insert'}>Insert</button>
|
||||
<button class="ribbon-tab-button {activeTab === 'layout' ? 'active' : ''}" @click={() => activeTab = 'layout'}>Layout</button>
|
||||
<button class="ribbon-tab-button {activeTab === 'view' ? 'active' : ''}" @click={() => activeTab = 'view'}>View</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'home' && (
|
||||
<div class="ribbon-content">
|
||||
<ribbon-group title="Clipboard">
|
||||
<ribbon-button icon="copy" label="Copy" size="large" @click={copy} />
|
||||
<ribbon-button icon="paste" label="Paste" size="large" @click={paste} />
|
||||
<ribbon-button icon="cut" label="Cut" size="medium" @click={cut} />
|
||||
</ribbon-group>
|
||||
|
||||
<ribbon-group title="Font">
|
||||
<div style="display:flex;flex-direction:column;gap:4px">
|
||||
<div style="display:flex;gap:4px">
|
||||
<select bind={fontFamily} @change={e => setFontFamily(e.target.value)} class="format-select" style="width:120px">
|
||||
<option value="Calibri">Calibri</option>
|
||||
<option value="Arial">Arial</option>
|
||||
<option value="Times New Roman">Times New Roman</option>
|
||||
<option value="Georgia">Georgia</option>
|
||||
<option value="Verdana">Verdana</option>
|
||||
</select>
|
||||
<select bind={fontSize} @change={e => setFontSize(e.target.value)} class="format-select" style="width:60px">
|
||||
<option value="8">8</option>
|
||||
<option value="9">9</option>
|
||||
<option value="10">10</option>
|
||||
<option value="11">11</option>
|
||||
<option value="12">12</option>
|
||||
<option value="14">14</option>
|
||||
<option value="16">16</option>
|
||||
<option value="18">18</option>
|
||||
<option value="20">20</option>
|
||||
<option value="24">24</option>
|
||||
<option value="28">28</option>
|
||||
<option value="36">36</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="display:flex;gap:2px">
|
||||
<ribbon-button icon="bold" label="Bold" @click={toggleBold} active={editor?.isActive('bold')} />
|
||||
<ribbon-button icon="italic" label="Italic" @click={toggleItalic} active={editor?.isActive('italic')} />
|
||||
<ribbon-button icon="underline" label="Underline" @click={toggleUnderline} active={editor?.isActive('underline')} />
|
||||
<div class="color-picker-wrapper">
|
||||
<ribbon-button icon="type" label="Text Color" />
|
||||
<input type="color" bind={textColor} @input={e => setTextColor(e.target.value)} class="color-picker" />
|
||||
<div class="color-indicator" style="background-color:{textColor}"></div>
|
||||
</div>
|
||||
<div class="color-picker-wrapper">
|
||||
<ribbon-button icon="highlighter" label="Highlight" />
|
||||
<input type="color" bind={highlightColor} @input={e => setHighlightColor(e.target.value)} class="color-picker" />
|
||||
<div class="color-indicator" style="background-color:{highlightColor}"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ribbon-group>
|
||||
|
||||
<ribbon-group title="Paragraph">
|
||||
<div style="display:flex;flex-direction:column;gap:4px">
|
||||
<div style="display:flex;gap:2px">
|
||||
<ribbon-button icon="align-left" label="Align Left" @click={alignLeft} active={editor?.isActive({textAlign:'left'})} />
|
||||
<ribbon-button icon="align-center" label="Center" @click={alignCenter} active={editor?.isActive({textAlign:'center'})} />
|
||||
<ribbon-button icon="align-right" label="Align Right" @click={alignRight} active={editor?.isActive({textAlign:'right'})} />
|
||||
<ribbon-button icon="align-justify" label="Justify" @click={alignJustify} active={editor?.isActive({textAlign:'justify'})} />
|
||||
</div>
|
||||
</div>
|
||||
</ribbon-group>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'insert' && (
|
||||
<div class="ribbon-content">
|
||||
<ribbon-group title="Illustrations">
|
||||
<ribbon-button icon="image" label="Picture" size="large" @click={addImage} />
|
||||
</ribbon-group>
|
||||
|
||||
<ribbon-group title="Tables">
|
||||
<ribbon-button icon="table" label="Table" size="large" @click={insertTable} />
|
||||
</ribbon-group>
|
||||
|
||||
<ribbon-group title="Links">
|
||||
<ribbon-button icon="link" label="Link" size="large" @click={addLink} active={editor?.isActive('link')} />
|
||||
</ribbon-group>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'view' && (
|
||||
<div class="ribbon-content">
|
||||
<ribbon-group title="Zoom">
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:8px">
|
||||
<div style="font-size:14px;font-weight:600">{zoom}%</div>
|
||||
<input type="range" min="50" max="200" bind={zoom} @input={e => zoom = parseInt(e.target.value)} class="zoom-slider" />
|
||||
</div>
|
||||
</ribbon-group>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<!-- Editor Area -->
|
||||
<div class="editor-container">
|
||||
<div class="editor-main">
|
||||
<div class="pages-container">
|
||||
<div each={pageNum in pages} class="page">
|
||||
<div class="page-number">Page {pageNum}</div>
|
||||
<div class="page-content">
|
||||
{pageNum === 1 && <editor-content bind={editor} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bubble Menu -->
|
||||
{editor && (
|
||||
<bubble-menu bind={editor}>
|
||||
<ribbon-button icon="bold" label="Bold" @click={toggleBold} active={editor.isActive('bold')} />
|
||||
<ribbon-button icon="italic" label="Italic" @click={toggleItalic} active={editor.isActive('italic')} />
|
||||
<ribbon-button icon="underline" label="Underline" @click={toggleUnderline} active={editor.isActive('underline')} />
|
||||
<ribbon-button icon="link" label="Link" @click={addLink} active={editor.isActive('link')} />
|
||||
</bubble-menu>
|
||||
)}
|
||||
|
||||
<!-- Status Bar -->
|
||||
<div class="status-bar">
|
||||
<div>Page {pages.length} of {pages.length} | Words: {editor?.storage?.characterCount?.words() || 0}</div>
|
||||
<div class="zoom-controls">
|
||||
<button @click={() => zoom = Math.max(50, zoom - 10)}>-</button>
|
||||
<span>{zoom}%</span>
|
||||
<button @click={() => zoom = Math.min(200, zoom + 10)}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden file input -->
|
||||
<input type="file" bind={fileInputRef} @change={handleImageUpload} accept="image/*" style="display:none" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script type="module">
|
||||
<!-- Removed unused Riot imports -->
|
||||
import { useEditor, EditorContent, BubbleMenu, AnyExtension } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Bold from '@tiptap/extension-bold';
|
||||
import Italic from '@tiptap/extension-italic';
|
||||
import History from '@tiptap/extension-history';
|
||||
import TextStyle from '@tiptap/extension-text-style';
|
||||
import FontFamily from '@tiptap/extension-font-family';
|
||||
import Color from '@tiptap/extension-color';
|
||||
import Highlight from '@tiptap/extension-highlight';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import Table from '@tiptap/extension-table';
|
||||
import TableCell from '@tiptap/extension-table-cell';
|
||||
import TableHeader from '@tiptap/extension-table-header';
|
||||
import TableRow from '@tiptap/extension-table-row';
|
||||
import './style.css';
|
||||
|
||||
export default {
|
||||
// Reactive state
|
||||
fileName: 'Document 1',
|
||||
fontSize: '12',
|
||||
fontFamily: 'Calibri',
|
||||
textColor: '#000000',
|
||||
highlightColor: '#ffff00',
|
||||
activeTab: 'home',
|
||||
zoom: 100,
|
||||
pages: [1],
|
||||
editor: null,
|
||||
fileInputRef: null,
|
||||
|
||||
// Lifecycle
|
||||
async mounted() {
|
||||
this.editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({ history: false }),
|
||||
Bold,
|
||||
Italic,
|
||||
History,
|
||||
TextStyle,
|
||||
FontFamily,
|
||||
Color,
|
||||
Highlight.configure({ multicolor: true }),
|
||||
TextAlign.configure({ types: ['heading', 'paragraph', 'tableCell'] }),
|
||||
Link.configure({ openOnClick: false }),
|
||||
Image,
|
||||
Underline,
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
HTMLAttributes: { class: 'editor-table' },
|
||||
}),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
],
|
||||
content: `
|
||||
<h1 style="text-align: center; font-size: 24px; margin-bottom: 20px;">${this.fileName}</h1>
|
||||
<p><br></p>
|
||||
<p>Start typing your document here...</p>
|
||||
<p><br></p>
|
||||
`,
|
||||
onUpdate: ({ editor }) => {
|
||||
const element = document.querySelector('.ProseMirror');
|
||||
if (element) {
|
||||
const contentHeight = element.scrollHeight;
|
||||
const pageHeight = 1123; // A4 height in pixels at 96 DPI
|
||||
const neededPages = Math.max(1, Math.ceil(contentHeight / pageHeight));
|
||||
if (neededPages !== this.pages.length) {
|
||||
this.pages = Array.from({ length: neededPages }, (_, i) => i + 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
// Methods
|
||||
undo() {
|
||||
this.editor?.chain().focus().undo().run();
|
||||
},
|
||||
redo() {
|
||||
this.editor?.chain().focus().redo().run();
|
||||
},
|
||||
saveDocument() {
|
||||
if (!this.editor) return;
|
||||
const content = this.editor.getHTML();
|
||||
const blob = new Blob([content], { type: 'text/html' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${this.fileName}.html`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
setFontFamily(value) {
|
||||
this.fontFamily = value;
|
||||
this.editor?.chain().focus().setFontFamily(value).run();
|
||||
},
|
||||
setFontSize(value) {
|
||||
this.fontSize = value;
|
||||
// TipTap does not have a built‑in font‑size extension; you may need a custom one.
|
||||
},
|
||||
setTextColor(value) {
|
||||
this.textColor = value;
|
||||
this.editor?.chain().focus().setColor(value).run();
|
||||
},
|
||||
setHighlightColor(value) {
|
||||
this.highlightColor = value;
|
||||
this.editor?.chain().focus().setHighlight({ color: value }).run();
|
||||
},
|
||||
toggleBold() {
|
||||
this.editor?.chain().focus().toggleBold().run();
|
||||
},
|
||||
toggleItalic() {
|
||||
this.editor?.chain().focus().toggleItalic().run();
|
||||
},
|
||||
toggleUnderline() {
|
||||
this.editor?.chain().focus().toggleUnderline().run();
|
||||
},
|
||||
alignLeft() {
|
||||
this.editor?.chain().focus().setTextAlign('left').run();
|
||||
},
|
||||
alignCenter() {
|
||||
this.editor?.chain().focus().setTextAlign('center').run();
|
||||
},
|
||||
alignRight() {
|
||||
this.editor?.chain().focus().setTextAlign('right').run();
|
||||
},
|
||||
alignJustify() {
|
||||
this.editor?.chain().focus().setTextAlign('justify').run();
|
||||
},
|
||||
addImage() {
|
||||
if (this.fileInputRef) this.fileInputRef.click();
|
||||
},
|
||||
handleImageUpload(e) {
|
||||
const file = e.target.files?.[0];
|
||||
if (file && this.editor) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const imageUrl = event.target?.result;
|
||||
this.editor.chain().focus().setImage({ src: imageUrl }).run();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
},
|
||||
insertTable() {
|
||||
this.editor?.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
||||
},
|
||||
addLink() {
|
||||
const previousUrl = this.editor?.getAttributes('link').href;
|
||||
const url = window.prompt('URL', previousUrl);
|
||||
if (url === null) return;
|
||||
if (url === '') {
|
||||
this.editor?.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
return;
|
||||
}
|
||||
this.editor?.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
||||
},
|
||||
copy() {
|
||||
document.execCommand('copy');
|
||||
},
|
||||
paste() {
|
||||
document.execCommand('paste');
|
||||
},
|
||||
cut() {
|
||||
document.execCommand('cut');
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Original styles moved to global CSS */
|
||||
</style>
|
||||
423
web/app/editor/style.css
Normal file
423
web/app/editor/style.css
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
:root {
|
||||
/* 3DBevel Theme */
|
||||
--background: 0 0% 80%;
|
||||
--foreground: 0 0% 10%;
|
||||
--card: 0 0% 75%;
|
||||
--card-foreground: 0 0% 10%;
|
||||
--popover: 0 0% 80%;
|
||||
--popover-foreground: 0 0% 10%;
|
||||
--primary: 210 80% 40%;
|
||||
--primary-foreground: 0 0% 80%;
|
||||
--secondary: 0 0% 70%;
|
||||
--secondary-foreground: 0 0% 10%;
|
||||
--muted: 0 0% 65%;
|
||||
--muted-foreground: 0 0% 30%;
|
||||
--accent: 30 80% 40%;
|
||||
--accent-foreground: 0 0% 80%;
|
||||
--destructive: 0 85% 60%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 70%;
|
||||
--input: 0 0% 70%;
|
||||
--ring: 210 80% 40%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.word-clone {
|
||||
min-height: 100vh;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
/* Title Bar */
|
||||
.title-bar {
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 2px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.title-bar h1 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.title-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.title-input {
|
||||
background: hsl(var(--input));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius);
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* Quick Access Toolbar */
|
||||
.quick-access {
|
||||
background: hsl(var(--card));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
padding: 4px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.quick-access-btn {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--foreground));
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.quick-access-btn:hover {
|
||||
background: hsl(var(--muted));
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
/* Ribbon */
|
||||
.ribbon {
|
||||
background: hsl(var(--card));
|
||||
border-bottom: 2px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.ribbon-tabs {
|
||||
display: flex;
|
||||
background: hsl(var(--muted));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.ribbon-tab-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.ribbon-tab-button:hover {
|
||||
background: hsl(var(--secondary));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.ribbon-tab-button.active {
|
||||
background: hsl(var(--card));
|
||||
color: hsl(var(--foreground));
|
||||
border-bottom-color: hsl(var(--primary));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ribbon-content {
|
||||
display: flex;
|
||||
padding: 8px;
|
||||
gap: 2px;
|
||||
min-height: 80px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.ribbon-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
padding-right: 8px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.ribbon-group:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.ribbon-group-content {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
align-items: flex-start;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.ribbon-group-title {
|
||||
font-size: 10px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-align: center;
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.ribbon-button {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--foreground));
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ribbon-button:hover {
|
||||
background: hsl(var(--muted));
|
||||
border-color: hsl(var(--border));
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.ribbon-button.active {
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.ribbon-button.medium {
|
||||
padding: 6px;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.ribbon-button.large {
|
||||
padding: 8px;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ribbon-button-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ribbon-button-label {
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
}
|
||||
|
||||
/* Format Controls */
|
||||
.format-select {
|
||||
background: hsl(var(--input));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 3px;
|
||||
padding: 4px 6px;
|
||||
font-size: 11px;
|
||||
color: hsl(var(--foreground));
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.color-picker-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.color-indicator {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 16px;
|
||||
height: 3px;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* Editor Area */
|
||||
.editor-container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
background: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.editor-sidebar {
|
||||
width: 200px;
|
||||
background: hsl(var(--card));
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.editor-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
max-height: calc(100vh - 200px);
|
||||
}
|
||||
|
||||
.pages-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
|
||||
/* Example: Use a CSS variable for zoom, set --zoom: 1 for 100% */
|
||||
transform: scale(var(--zoom, 1));
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.page {
|
||||
width: 210mm;
|
||||
min-height: 297mm;
|
||||
background: white;
|
||||
box-shadow:
|
||||
0 0 0 1px hsl(var(--border)),
|
||||
0 4px 8px rgba(0, 0, 0, 0.1),
|
||||
0 8px 16px rgba(0, 0, 0, 0.05);
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-number {
|
||||
position: absolute;
|
||||
top: -30px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: hsl(var(--background));
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 25mm;
|
||||
min-height: 247mm;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
outline: none;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.ProseMirror img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ProseMirror a {
|
||||
color: hsl(var(--primary));
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Table styles */
|
||||
.editor-table {
|
||||
border-collapse: collapse;
|
||||
margin: 16px 0;
|
||||
width: 100%;
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.editor-table td,
|
||||
.editor-table th {
|
||||
border: 1px solid hsl(var(--border));
|
||||
padding: 8px 12px;
|
||||
min-width: 50px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editor-table th {
|
||||
background: hsl(var(--muted));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Bubble Menu */
|
||||
.bubble-menu {
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
display: flex;
|
||||
padding: 4px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.bubble-menu .ribbon-button {
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* Status Bar */
|
||||
.status-bar {
|
||||
background: hsl(var(--card));
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
padding: 4px 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.zoom-slider {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
@media print {
|
||||
|
||||
.title-bar,
|
||||
.quick-access,
|
||||
.ribbon,
|
||||
.editor-sidebar,
|
||||
.status-bar {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.editor-main {
|
||||
padding: 0;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.pages-container {
|
||||
transform: none;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.page {
|
||||
box-shadow: none;
|
||||
margin: 0;
|
||||
break-after: page;
|
||||
}
|
||||
|
||||
.page-number {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
24
web/app/index.html
Normal file
24
web/app/index.html
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>General Bots</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<link rel="stylesheet" href="public/output.css" />
|
||||
<link rel="stylesheet" href="client-nav.css" />
|
||||
<app></app>
|
||||
<script src="app.html" type="riot"></script>
|
||||
|
||||
<script src="https://unpkg.com/riot@10/riot+compiler.min.js"></script>
|
||||
<script>
|
||||
riot.compile().then(() => {
|
||||
riot.mount('app', {
|
||||
title: 'General Bots',
|
||||
})
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
15
web/app/mail/use-mail.ts
Normal file
15
web/app/mail/use-mail.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { atom, useAtom } from "jotai"
|
||||
|
||||
import { Mail, mails } from "./data"
|
||||
|
||||
type Config = {
|
||||
selected: Mail["id"] | null
|
||||
}
|
||||
|
||||
const configAtom = atom<Config>({
|
||||
selected: mails[0].id,
|
||||
})
|
||||
|
||||
export function useMail() {
|
||||
return useAtom(configAtom)
|
||||
}
|
||||
14
web/app/news/news.page.html
Normal file
14
web/app/news/news.page.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<!-- Riot.js component for the news page (converted from app/news/page.tsx) -->
|
||||
<template>
|
||||
<div class="flex flex-col h-screen bg-gray-50">
|
||||
<!-- Add news page content here -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script type="module">
|
||||
import './style.css';
|
||||
|
||||
export default {
|
||||
// No additional state needed for this simple page
|
||||
};
|
||||
</script>
|
||||
179
web/app/paper/paper.page.html
Normal file
179
web/app/paper/paper.page.html
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
<!-- Riot.js component for the paper page (converted from app/paper/page.tsx) -->
|
||||
<template>
|
||||
<div class="min-h-screen bg-background text-foreground">
|
||||
<div>
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<!-- Paper Shadow Effect -->
|
||||
<div class="mx-4 my-8 bg-card rounded-lg shadow-2xl shadow-black/20 border border-border">
|
||||
<editor-content bind={editor} class="min-h-[calc(100vh-12rem)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating Selection Toolbar -->
|
||||
{editor && (
|
||||
<bubble-menu bind={editor}>
|
||||
<div class="flex items-center bg-card border border-border rounded-lg shadow-lg p-1">
|
||||
<!-- Text Formatting -->
|
||||
<button @click={() => editor.chain().focus().toggleBold().run()}
|
||||
class="p-2 rounded hover:bg-accent transition-colors {editor.isActive('bold') ? 'bg-primary text-primary-foreground' : 'text-foreground'}"
|
||||
title="Bold">
|
||||
<BoldIcon class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button @click={() => editor.chain().focus().toggleItalic().run()}
|
||||
class="p-2 rounded hover:bg-accent transition-colors {editor.isActive('italic') ? 'bg-primary text-primary-foreground' : 'text-foreground'}"
|
||||
title="Italic">
|
||||
<ItalicIcon class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button @click={() => editor.chain().focus().toggleUnderline().run()}
|
||||
class="p-2 rounded hover:bg-accent transition-colors {editor.isActive('underline') ? 'bg-primary text-primary-foreground' : 'text-foreground'}"
|
||||
title="Underline">
|
||||
<Underline class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div class="w-px h-6 bg-border mx-1"></div>
|
||||
|
||||
<!-- Text Alignment -->
|
||||
<button @click={() => editor.chain().focus().setTextAlign('left').run()}
|
||||
class="p-2 rounded hover:bg-accent transition-colors {editor.isActive({textAlign:'left'}) ? 'bg-primary text-primary-foreground' : 'text-foreground'}"
|
||||
title="Align Left">
|
||||
<AlignLeft class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button @click={() => editor.chain().focus().setTextAlign('center').run()}
|
||||
class="p-2 rounded hover:bg-accent transition-colors {editor.isActive({textAlign:'center'}) ? 'bg-primary text-primary-foreground' : 'text-foreground'}"
|
||||
title="Align Center">
|
||||
<AlignCenter class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button @click={() => editor.chain().focus().setTextAlign('right').run()}
|
||||
class="p-2 rounded hover:bg-accent transition-colors {editor.isActive({textAlign:'right'}) ? 'bg-primary text-primary-foreground' : 'text-foreground'}"
|
||||
title="Align Right">
|
||||
<AlignRight class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div class="w-px h-6 bg-border mx-1"></div>
|
||||
|
||||
<!-- Highlight -->
|
||||
<button @click={() => editor.chain().focus().toggleHighlight({color:'#ffff00'}).run()}
|
||||
class="p-2 rounded hover:bg-accent transition-colors {editor.isActive('highlight') ? 'bg-primary text-primary-foreground' : 'text-foreground'}"
|
||||
title="Highlight">
|
||||
<Highlighter class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<!-- Link -->
|
||||
<button @click={addLink}
|
||||
class="p-2 rounded hover:bg-accent transition-colors {editor.isActive('link') ? 'bg-primary text-primary-foreground' : 'text-foreground'}"
|
||||
title="Add Link">
|
||||
<Link class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div class="w-px h-6 bg-border mx-1"></div>
|
||||
|
||||
<!-- Heading -->
|
||||
<button @click={() => {
|
||||
if (editor.isActive('heading')) {
|
||||
editor.chain().focus().setNode('paragraph').run();
|
||||
} else {
|
||||
editor.chain().focus().setNode('heading', {level:2}).run();
|
||||
}
|
||||
}}
|
||||
class="p-2 rounded hover:bg-accent transition-colors {editor.isActive('heading') ? 'bg-primary text-primary-foreground' : 'text-foreground'}"
|
||||
title="Heading">
|
||||
<Type class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</bubble-menu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer-component shortcuts="{shortcuts}" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script type="module">
|
||||
import { useState, useRef, useEffect } from 'riot';
|
||||
import { useEditor, EditorContent, BubbleMenu, AnyExtension } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Bold from '@tiptap/extension-bold';
|
||||
import Italic from '@tiptap/extension-italic';
|
||||
import TextStyle from '@tiptap/extension-text-style';
|
||||
import Color from '@tiptap/extension-color';
|
||||
import Highlight from '@tiptap/extension-highlight';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import { AlignLeft, AlignCenter, AlignRight, BoldIcon, ItalicIcon, Highlighter, Type, Link as LinkIcon, Underline as UnderlineIcon } from 'lucide-react';
|
||||
import Footer from '../footer';
|
||||
import './style.css';
|
||||
|
||||
export default {
|
||||
// Reactive state
|
||||
editor: null,
|
||||
shortcuts: [],
|
||||
|
||||
// Lifecycle
|
||||
async mounted() {
|
||||
this.editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure(),
|
||||
Bold,
|
||||
Italic,
|
||||
TextStyle,
|
||||
Color,
|
||||
Highlight.configure({ multicolor: true }),
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||
Link,
|
||||
Underline,
|
||||
],
|
||||
content: `<p>Start writing your thoughts here...</p>`,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'prose prose-invert max-w-none focus:outline-none min-h-[calc(100vh-8rem)] p-8 text-foreground',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Initialize shortcuts (same as original component)
|
||||
this.shortcuts = [
|
||||
[
|
||||
{ key: 'Q', label: 'Resume', action: () => {} },
|
||||
{ key: 'W', label: 'Write', action: () => {} },
|
||||
{ key: 'E', label: 'Expand', action: () => {} },
|
||||
{ key: 'R', label: 'One Word', action: () => {} },
|
||||
{ key: 'T', label: 'As List', action: () => {} },
|
||||
{ key: 'Y', label: 'As Mail', action: () => {} },
|
||||
{ key: 'U', label: 'Copy', action: () => document.execCommand('copy') },
|
||||
{ key: 'I', label: 'Paste', action: () => document.execCommand('paste') },
|
||||
{ key: 'O', label: 'Undo', action: () => this.editor?.chain().focus().undo().run() },
|
||||
{ key: 'P', label: 'Redo', action: () => this.editor?.chain().focus().redo().run() },
|
||||
],
|
||||
[
|
||||
{ key: 'A', label: 'Select', action: () => {} },
|
||||
{ key: 'S', label: 'Select All', action: () => this.editor?.chain().focus().selectAll().run() },
|
||||
{ key: 'D', label: 'Deselect', action: () => {} },
|
||||
{ key: 'G', label: 'Random', action: () => {} },
|
||||
{ key: 'H', label: 'Idea', action: () => {} },
|
||||
{ key: 'J', label: 'Insert Link', action: this.addLink },
|
||||
{ key: 'K', label: 'Highlight', action: () => this.editor?.chain().focus().toggleHighlight({color:'#ffff00'}).run() },
|
||||
{ key: 'L', label: 'To-Do', action: () => {} },
|
||||
{ key: 'Z', label: 'Zoom In', action: () => {} },
|
||||
{ key: 'X', label: 'Zoom Out', action: () => {} },
|
||||
]
|
||||
];
|
||||
},
|
||||
|
||||
// Methods
|
||||
addLink() {
|
||||
const previousUrl = this.editor?.getAttributes('link').href;
|
||||
const url = window.prompt('Enter URL:', previousUrl);
|
||||
if (url === null) return;
|
||||
if (url === '') {
|
||||
this.editor?.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
return;
|
||||
}
|
||||
this.editor?.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
103
web/app/paper/style.css
Normal file
103
web/app/paper/style.css
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
.ProseMirror {
|
||||
outline: none;
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
color: hsl(var(--foreground));
|
||||
padding: 3rem;
|
||||
|
||||
min-height: calc(100vh - 12rem);
|
||||
}
|
||||
|
||||
.ProseMirror h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin: 2rem 0 1rem 0;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.ProseMirror h2 {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
margin: 1.5rem 0 0.75rem 0;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.ProseMirror h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 1.25rem 0 0.5rem 0;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.ProseMirror p {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.ProseMirror a {
|
||||
color: hsl(var(--accent));
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.ProseMirror a:hover {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.ProseMirror mark {
|
||||
background-color: #ffff0040;
|
||||
border-radius: 2px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.ProseMirror ul, .ProseMirror ol {
|
||||
margin: 1rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.ProseMirror li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.ProseMirror blockquote {
|
||||
border-left: 4px solid hsl(var(--primary));
|
||||
padding-left: 1rem;
|
||||
margin: 1rem 0;
|
||||
font-style: italic;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.ProseMirror code {
|
||||
background-color: hsl(var(--muted));
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.ProseMirror pre {
|
||||
background-color: hsl(var(--muted));
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.ProseMirror pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Selection highlighting */
|
||||
.ProseMirror ::selection {
|
||||
background-color: hsl(var(--primary) / 0.2);
|
||||
}
|
||||
|
||||
/* Placeholder styling */
|
||||
.ProseMirror p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: hsl(var(--muted-foreground));
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
32
web/app/player/style.css
Normal file
32
web/app/player/style.css
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
.slider::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--primary));
|
||||
cursor: pointer;
|
||||
border: 2px solid hsl(var(--primary-foreground));
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--primary));
|
||||
cursor: pointer;
|
||||
border: 2px solid hsl(var(--primary-foreground));
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-track {
|
||||
height: 4px;
|
||||
cursor: pointer;
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.slider::-moz-range-track {
|
||||
height: 4px;
|
||||
cursor: pointer;
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 2px;
|
||||
}
|
||||
BIN
web/app/public/images/generalbots-192x192.png
Normal file
BIN
web/app/public/images/generalbots-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
4751
web/app/public/output.css
Normal file
4751
web/app/public/output.css
Normal file
File diff suppressed because it is too large
Load diff
46
web/app/public/sounds/click.mp3
Normal file
46
web/app/public/sounds/click.mp3
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Example Domain</title>
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type="text/css">
|
||||
body {
|
||||
background-color: #f0f0f2;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
|
||||
}
|
||||
div {
|
||||
width: 600px;
|
||||
margin: 5em auto;
|
||||
padding: 2em;
|
||||
background-color: #fdfdff;
|
||||
border-radius: 0.5em;
|
||||
box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
|
||||
}
|
||||
a:link, a:visited {
|
||||
color: #38488f;
|
||||
text-decoration: none;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
div {
|
||||
margin: 0 auto;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div>
|
||||
<h1>Example Domain</h1>
|
||||
<p>This domain is for use in illustrative examples in documents. You may use this
|
||||
domain in literature without prior coordination or asking for permission.</p>
|
||||
<p><a href="https://www.iana.org/domains/example">More information...</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
46
web/app/public/sounds/error.mp3
Normal file
46
web/app/public/sounds/error.mp3
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Example Domain</title>
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type="text/css">
|
||||
body {
|
||||
background-color: #f0f0f2;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
|
||||
}
|
||||
div {
|
||||
width: 600px;
|
||||
margin: 5em auto;
|
||||
padding: 2em;
|
||||
background-color: #fdfdff;
|
||||
border-radius: 0.5em;
|
||||
box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
|
||||
}
|
||||
a:link, a:visited {
|
||||
color: #38488f;
|
||||
text-decoration: none;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
div {
|
||||
margin: 0 auto;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div>
|
||||
<h1>Example Domain</h1>
|
||||
<p>This domain is for use in illustrative examples in documents. You may use this
|
||||
domain in literature without prior coordination or asking for permission.</p>
|
||||
<p><a href="https://www.iana.org/domains/example">More information...</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
46
web/app/public/sounds/hover.mp3
Normal file
46
web/app/public/sounds/hover.mp3
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Example Domain</title>
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type="text/css">
|
||||
body {
|
||||
background-color: #f0f0f2;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
|
||||
}
|
||||
div {
|
||||
width: 600px;
|
||||
margin: 5em auto;
|
||||
padding: 2em;
|
||||
background-color: #fdfdff;
|
||||
border-radius: 0.5em;
|
||||
box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
|
||||
}
|
||||
a:link, a:visited {
|
||||
color: #38488f;
|
||||
text-decoration: none;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
div {
|
||||
margin: 0 auto;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div>
|
||||
<h1>Example Domain</h1>
|
||||
<p>This domain is for use in illustrative examples in documents. You may use this
|
||||
domain in literature without prior coordination or asking for permission.</p>
|
||||
<p><a href="https://www.iana.org/domains/example">More information...</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
13
web/app/public/sounds/manifest.ts
Normal file
13
web/app/public/sounds/manifest.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export const soundAssets = {
|
||||
send: '/assets/sounds/send.mp3',
|
||||
receive: '/assets/sounds/receive.mp3',
|
||||
typing: '/assets/sounds/typing.mp3',
|
||||
notification: '/assets/sounds/notification.mp3',
|
||||
click: '/assets/sounds/click.mp3',
|
||||
hover: '/assets/sounds/hover.mp3',
|
||||
success: '/assets/sounds/success.mp3',
|
||||
error: '/assets/sounds/error.mp3'
|
||||
} as const;
|
||||
|
||||
// Type for sound names
|
||||
export type SoundName = keyof typeof soundAssets;
|
||||
46
web/app/public/sounds/notification.mp3
Normal file
46
web/app/public/sounds/notification.mp3
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Example Domain</title>
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type="text/css">
|
||||
body {
|
||||
background-color: #f0f0f2;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
|
||||
}
|
||||
div {
|
||||
width: 600px;
|
||||
margin: 5em auto;
|
||||
padding: 2em;
|
||||
background-color: #fdfdff;
|
||||
border-radius: 0.5em;
|
||||
box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
|
||||
}
|
||||
a:link, a:visited {
|
||||
color: #38488f;
|
||||
text-decoration: none;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
div {
|
||||
margin: 0 auto;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div>
|
||||
<h1>Example Domain</h1>
|
||||
<p>This domain is for use in illustrative examples in documents. You may use this
|
||||
domain in literature without prior coordination or asking for permission.</p>
|
||||
<p><a href="https://www.iana.org/domains/example">More information...</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
46
web/app/public/sounds/receive.mp3
Normal file
46
web/app/public/sounds/receive.mp3
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Example Domain</title>
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type="text/css">
|
||||
body {
|
||||
background-color: #f0f0f2;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
|
||||
}
|
||||
div {
|
||||
width: 600px;
|
||||
margin: 5em auto;
|
||||
padding: 2em;
|
||||
background-color: #fdfdff;
|
||||
border-radius: 0.5em;
|
||||
box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
|
||||
}
|
||||
a:link, a:visited {
|
||||
color: #38488f;
|
||||
text-decoration: none;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
div {
|
||||
margin: 0 auto;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div>
|
||||
<h1>Example Domain</h1>
|
||||
<p>This domain is for use in illustrative examples in documents. You may use this
|
||||
domain in literature without prior coordination or asking for permission.</p>
|
||||
<p><a href="https://www.iana.org/domains/example">More information...</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
46
web/app/public/sounds/send.mp3
Normal file
46
web/app/public/sounds/send.mp3
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Example Domain</title>
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type="text/css">
|
||||
body {
|
||||
background-color: #f0f0f2;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
|
||||
}
|
||||
div {
|
||||
width: 600px;
|
||||
margin: 5em auto;
|
||||
padding: 2em;
|
||||
background-color: #fdfdff;
|
||||
border-radius: 0.5em;
|
||||
box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
|
||||
}
|
||||
a:link, a:visited {
|
||||
color: #38488f;
|
||||
text-decoration: none;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
div {
|
||||
margin: 0 auto;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div>
|
||||
<h1>Example Domain</h1>
|
||||
<p>This domain is for use in illustrative examples in documents. You may use this
|
||||
domain in literature without prior coordination or asking for permission.</p>
|
||||
<p><a href="https://www.iana.org/domains/example">More information...</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
46
web/app/public/sounds/success.mp3
Normal file
46
web/app/public/sounds/success.mp3
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Example Domain</title>
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type="text/css">
|
||||
body {
|
||||
background-color: #f0f0f2;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
|
||||
}
|
||||
div {
|
||||
width: 600px;
|
||||
margin: 5em auto;
|
||||
padding: 2em;
|
||||
background-color: #fdfdff;
|
||||
border-radius: 0.5em;
|
||||
box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
|
||||
}
|
||||
a:link, a:visited {
|
||||
color: #38488f;
|
||||
text-decoration: none;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
div {
|
||||
margin: 0 auto;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div>
|
||||
<h1>Example Domain</h1>
|
||||
<p>This domain is for use in illustrative examples in documents. You may use this
|
||||
domain in literature without prior coordination or asking for permission.</p>
|
||||
<p><a href="https://www.iana.org/domains/example">More information...</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
46
web/app/public/sounds/typing.mp3
Normal file
46
web/app/public/sounds/typing.mp3
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Example Domain</title>
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type="text/css">
|
||||
body {
|
||||
background-color: #f0f0f2;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
|
||||
}
|
||||
div {
|
||||
width: 600px;
|
||||
margin: 5em auto;
|
||||
padding: 2em;
|
||||
background-color: #fdfdff;
|
||||
border-radius: 0.5em;
|
||||
box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
|
||||
}
|
||||
a:link, a:visited {
|
||||
color: #38488f;
|
||||
text-decoration: none;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
div {
|
||||
margin: 0 auto;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div>
|
||||
<h1>Example Domain</h1>
|
||||
<p>This domain is for use in illustrative examples in documents. You may use this
|
||||
domain in literature without prior coordination or asking for permission.</p>
|
||||
<p><a href="https://www.iana.org/domains/example">More information...</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
2801
web/app/public/styles/output.css
Normal file
2801
web/app/public/styles/output.css
Normal file
File diff suppressed because it is too large
Load diff
66
web/app/public/themes/3dbevel.css
Normal file
66
web/app/public/themes/3dbevel.css
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
body, .card, .popover, .input, .button, .menu, .dialog {
|
||||
font-family: 'IBM Plex Mono', 'Courier New', monospace !important;
|
||||
background: #c0c0c0 !important;
|
||||
color: #000 !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.card, .popover, .menu, .dialog {
|
||||
border: 2px solid #fff !important;
|
||||
border-bottom: 2px solid #404040 !important;
|
||||
border-right: 2px solid #404040 !important;
|
||||
padding: 8px !important;
|
||||
background: #e0e0e0 !important;
|
||||
}
|
||||
|
||||
.button, button, input[type="button"], input[type="submit"] {
|
||||
background: #e0e0e0 !important;
|
||||
color: #000 !important;
|
||||
border: 2px solid #fff !important;
|
||||
border-bottom: 2px solid #404040 !important;
|
||||
border-right: 2px solid #404040 !important;
|
||||
padding: 4px 12px !important;
|
||||
font-weight: bold !important;
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
background: #fff !important;
|
||||
color: #000 !important;
|
||||
border: 2px solid #fff !important;
|
||||
border-bottom: 2px solid #404040 !important;
|
||||
border-right: 2px solid #404040 !important;
|
||||
font-family: inherit !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.menu {
|
||||
background: #d0d0d0 !important;
|
||||
border: 2px solid #fff !important;
|
||||
border-bottom: 2px solid #404040 !important;
|
||||
border-right: 2px solid #404040 !important;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 16px !important;
|
||||
background: #c0c0c0 !important;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #404040 !important;
|
||||
border: 2px solid #fff !important;
|
||||
border-bottom: 2px solid #404040 !important;
|
||||
border-right: 2px solid #404040 !important;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0000aa !important;
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none !important;
|
||||
border-top: 2px solid #404040 !important;
|
||||
margin: 8px 0 !important;
|
||||
}
|
||||
28
web/app/public/themes/arcadeflash.css
Normal file
28
web/app/public/themes/arcadeflash.css
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
:root {
|
||||
/* ArcadeFlash Theme */
|
||||
--background: 0 0% 5%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 8%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 5%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 120 100% 50%;
|
||||
--primary-foreground: 0 0% 5%;
|
||||
--secondary: 0 0% 15%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 10%;
|
||||
--muted-foreground: 0 0% 60%;
|
||||
--accent: 240 100% 50%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 100% 50%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 15%;
|
||||
--input: 0 0% 15%;
|
||||
--ring: 120 100% 50%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 120 100% 50%;
|
||||
--chart-2: 240 100% 50%;
|
||||
--chart-3: 60 100% 50%;
|
||||
--chart-4: 0 100% 50%;
|
||||
--chart-5: 300 100% 50%;
|
||||
}
|
||||
28
web/app/public/themes/cyberpunk.css
Normal file
28
web/app/public/themes/cyberpunk.css
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
:root {
|
||||
/* CyberPunk Theme */
|
||||
--background: 240 30% 5%;
|
||||
--foreground: 60 100% 80%;
|
||||
--card: 240 30% 8%;
|
||||
--card-foreground: 60 100% 80%;
|
||||
--popover: 240 30% 5%;
|
||||
--popover-foreground: 60 100% 80%;
|
||||
--primary: 330 100% 60%;
|
||||
--primary-foreground: 240 30% 5%;
|
||||
--secondary: 240 30% 15%;
|
||||
--secondary-foreground: 60 100% 80%;
|
||||
--muted: 240 30% 10%;
|
||||
--muted-foreground: 60 100% 60%;
|
||||
--accent: 180 100% 60%;
|
||||
--accent-foreground: 240 30% 5%;
|
||||
--destructive: 0 85% 60%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 30% 15%;
|
||||
--input: 240 30% 15%;
|
||||
--ring: 330 100% 60%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 330 100% 60%;
|
||||
--chart-2: 180 100% 60%;
|
||||
--chart-3: 60 100% 60%;
|
||||
--chart-4: 0 100% 60%;
|
||||
--chart-5: 270 100% 60%;
|
||||
}
|
||||
28
web/app/public/themes/discofever.css
Normal file
28
web/app/public/themes/discofever.css
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
:root {
|
||||
/* DiscoFever Theme */
|
||||
--background: 270 20% 10%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 270 20% 15%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 270 20% 10%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 330 100% 60%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 270 20% 20%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 270 20% 25%;
|
||||
--muted-foreground: 270 10% 60%;
|
||||
--accent: 60 100% 60%;
|
||||
--accent-foreground: 270 20% 10%;
|
||||
--destructive: 0 85% 60%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 270 20% 20%;
|
||||
--input: 270 20% 20%;
|
||||
--ring: 330 100% 60%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 330 100% 60%;
|
||||
--chart-2: 60 100% 60%;
|
||||
--chart-3: 120 100% 60%;
|
||||
--chart-4: 240 100% 60%;
|
||||
--chart-5: 0 100% 60%;
|
||||
}
|
||||
28
web/app/public/themes/grungeera.css
Normal file
28
web/app/public/themes/grungeera.css
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
:root {
|
||||
/* GrungeEra Theme */
|
||||
--background: 30 10% 10%;
|
||||
--foreground: 30 30% 80%;
|
||||
--card: 30 10% 15%;
|
||||
--card-foreground: 30 30% 80%;
|
||||
--popover: 30 10% 10%;
|
||||
--popover-foreground: 30 30% 80%;
|
||||
--primary: 10 70% 50%;
|
||||
--primary-foreground: 30 30% 80%;
|
||||
--secondary: 30 10% 20%;
|
||||
--secondary-foreground: 30 30% 80%;
|
||||
--muted: 30 10% 25%;
|
||||
--muted-foreground: 30 30% 60%;
|
||||
--accent: 200 70% 50%;
|
||||
--accent-foreground: 30 30% 80%;
|
||||
--destructive: 0 85% 60%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 30 10% 20%;
|
||||
--input: 30 10% 20%;
|
||||
--ring: 10 70% 50%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 10 70% 50%;
|
||||
--chart-2: 200 70% 50%;
|
||||
--chart-3: 90 70% 50%;
|
||||
--chart-4: 300 70% 50%;
|
||||
--chart-5: 30 70% 50%;
|
||||
}
|
||||
28
web/app/public/themes/jazzage.css
Normal file
28
web/app/public/themes/jazzage.css
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
:root {
|
||||
/* JazzAge Theme */
|
||||
--background: 30 20% 10%;
|
||||
--foreground: 40 30% 85%;
|
||||
--card: 30 20% 15%;
|
||||
--card-foreground: 40 30% 85%;
|
||||
--popover: 30 20% 10%;
|
||||
--popover-foreground: 40 30% 85%;
|
||||
--primary: 20 80% 50%;
|
||||
--primary-foreground: 40 30% 85%;
|
||||
--secondary: 30 20% 20%;
|
||||
--secondary-foreground: 40 30% 85%;
|
||||
--muted: 30 20% 25%;
|
||||
--muted-foreground: 40 30% 60%;
|
||||
--accent: 200 80% 50%;
|
||||
--accent-foreground: 40 30% 85%;
|
||||
--destructive: 0 85% 60%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 30 20% 20%;
|
||||
--input: 30 20% 20%;
|
||||
--ring: 20 80% 50%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 20 80% 50%;
|
||||
--chart-2: 200 80% 50%;
|
||||
--chart-3: 350 80% 50%;
|
||||
--chart-4: 140 80% 50%;
|
||||
--chart-5: 260 80% 50%;
|
||||
}
|
||||
28
web/app/public/themes/mellowgold.css
Normal file
28
web/app/public/themes/mellowgold.css
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
:root {
|
||||
/* MellowGold Theme */
|
||||
--background: 45 30% 90%;
|
||||
--foreground: 30 20% 20%;
|
||||
--card: 45 30% 85%;
|
||||
--card-foreground: 30 20% 20%;
|
||||
--popover: 45 30% 90%;
|
||||
--popover-foreground: 30 20% 20%;
|
||||
--primary: 35 80% 50%;
|
||||
--primary-foreground: 45 30% 90%;
|
||||
--secondary: 45 30% 80%;
|
||||
--secondary-foreground: 30 20% 20%;
|
||||
--muted: 45 30% 75%;
|
||||
--muted-foreground: 30 20% 40%;
|
||||
--accent: 25 80% 50%;
|
||||
--accent-foreground: 45 30% 90%;
|
||||
--destructive: 0 85% 60%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 45 30% 80%;
|
||||
--input: 45 30% 80%;
|
||||
--ring: 35 80% 50%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 35 80% 50%;
|
||||
--chart-2: 25 80% 50%;
|
||||
--chart-3: 15 80% 50%;
|
||||
--chart-4: 5 80% 50%;
|
||||
--chart-5: 55 80% 50%;
|
||||
}
|
||||
28
web/app/public/themes/midcenturymod.css
Normal file
28
web/app/public/themes/midcenturymod.css
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
:root {
|
||||
/* MidCenturyMod Theme */
|
||||
--background: 40 30% 95%;
|
||||
--foreground: 30 20% 20%;
|
||||
--card: 40 30% 90%;
|
||||
--card-foreground: 30 20% 20%;
|
||||
--popover: 40 30% 95%;
|
||||
--popover-foreground: 30 20% 20%;
|
||||
--primary: 180 60% 40%;
|
||||
--primary-foreground: 40 30% 95%;
|
||||
--secondary: 40 30% 85%;
|
||||
--secondary-foreground: 30 20% 20%;
|
||||
--muted: 40 30% 80%;
|
||||
--muted-foreground: 30 20% 40%;
|
||||
--accent: 350 60% 40%;
|
||||
--accent-foreground: 40 30% 95%;
|
||||
--destructive: 0 85% 60%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 40 30% 85%;
|
||||
--input: 40 30% 85%;
|
||||
--ring: 180 60% 40%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 180 60% 40%;
|
||||
--chart-2: 350 60% 40%;
|
||||
--chart-3: 40 60% 40%;
|
||||
--chart-4: 220 60% 40%;
|
||||
--chart-5: 300 60% 40%;
|
||||
}
|
||||
27
web/app/public/themes/orange.css
Normal file
27
web/app/public/themes/orange.css
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
:root {
|
||||
--background: 0 0% 100%; /* White */
|
||||
--foreground: 0 0% 13%; /* #212121 - near black */
|
||||
--card: 0 0% 98%; /* #faf9f8 - light gray */
|
||||
--card-foreground: 0 0% 13%; /* #212121 */
|
||||
--popover: 0 0% 100%; /* White */
|
||||
--popover-foreground: 0 0% 13%; /* #212121 */
|
||||
--primary: 24 90% 54%; /* #d83b01 - Office orange */
|
||||
--primary-foreground: 0 0% 100%; /* White */
|
||||
--secondary: 210 36% 96%; /* #f3f2f1 - light blue-gray */
|
||||
--secondary-foreground: 0 0% 13%; /* #212121 */
|
||||
--muted: 0 0% 90%; /* #e1dfdd - muted gray */
|
||||
--muted-foreground: 0 0% 40%; /* #666666 */
|
||||
--accent: 207 90% 54%; /* #0078d4 - Office blue */
|
||||
--accent-foreground: 0 0% 100%; /* White */
|
||||
--destructive: 0 85% 60%; /* #e81123 - Office red */
|
||||
--destructive-foreground: 0 0% 100%; /* White */
|
||||
--border: 0 0% 85%; /* #d2d0ce - light border */
|
||||
--input: 0 0% 100%; /* White */
|
||||
--ring: 207 90% 54%; /* #0078d4 */
|
||||
--radius: 0.25rem; /* Slightly less rounded */
|
||||
--chart-1: 24 90% 54%; /* Office orange */
|
||||
--chart-2: 207 90% 54%; /* Office blue */
|
||||
--chart-3: 120 60% 40%; /* Office green */
|
||||
--chart-4: 340 82% 52%; /* Office magenta */
|
||||
--chart-5: 44 100% 50%; /* Office yellow */
|
||||
}
|
||||
28
web/app/public/themes/polaroidmemories.css
Normal file
28
web/app/public/themes/polaroidmemories.css
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
:root {
|
||||
/* PolaroidMemories Theme */
|
||||
--background: 50 30% 95%;
|
||||
--foreground: 30 20% 20%;
|
||||
--card: 50 30% 90%;
|
||||
--card-foreground: 30 20% 20%;
|
||||
--popover: 50 30% 95%;
|
||||
--popover-foreground: 30 20% 20%;
|
||||
--primary: 200 80% 50%;
|
||||
--primary-foreground: 50 30% 95%;
|
||||
--secondary: 50 30% 85%;
|
||||
--secondary-foreground: 30 20% 20%;
|
||||
--muted: 50 30% 80%;
|
||||
--muted-foreground: 30 20% 40%;
|
||||
--accent: 350 80% 50%;
|
||||
--accent-foreground: 50 30% 95%;
|
||||
--destructive: 0 85% 60%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 50 30% 85%;
|
||||
--input: 50 30% 85%;
|
||||
--ring: 200 80% 50%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 200 80% 50%;
|
||||
--chart-2: 350 80% 50%;
|
||||
--chart-3: 50 80% 50%;
|
||||
--chart-4: 140 80% 50%;
|
||||
--chart-5: 260 80% 50%;
|
||||
}
|
||||
28
web/app/public/themes/retrowave.css
Normal file
28
web/app/public/themes/retrowave.css
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
:root {
|
||||
/* RetroWave Theme */
|
||||
--background: 240 21% 15%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 21% 18%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 21% 15%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 334 89% 62%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 240 21% 25%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 21% 20%;
|
||||
--muted-foreground: 240 5% 65%;
|
||||
--accent: 41 99% 60%;
|
||||
--accent-foreground: 240 21% 15%;
|
||||
--destructive: 0 85% 60%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 21% 25%;
|
||||
--input: 240 21% 25%;
|
||||
--ring: 334 89% 62%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 334 89% 62%;
|
||||
--chart-2: 41 99% 60%;
|
||||
--chart-3: 190 90% 50%;
|
||||
--chart-4: 280 89% 65%;
|
||||
--chart-5: 80 75% 55%;
|
||||
}
|
||||
28
web/app/public/themes/saturdaycartoons.css
Normal file
28
web/app/public/themes/saturdaycartoons.css
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
:root {
|
||||
/* SaturdayCartoons Theme */
|
||||
--background: 220 50% 95%;
|
||||
--foreground: 220 50% 20%;
|
||||
--card: 220 50% 90%;
|
||||
--card-foreground: 220 50% 20%;
|
||||
--popover: 220 50% 95%;
|
||||
--popover-foreground: 220 50% 20%;
|
||||
--primary: 30 100% 55%;
|
||||
--primary-foreground: 220 50% 95%;
|
||||
--secondary: 220 50% 85%;
|
||||
--secondary-foreground: 220 50% 20%;
|
||||
--muted: 220 50% 80%;
|
||||
--muted-foreground: 220 50% 40%;
|
||||
--accent: 120 100% 55%;
|
||||
--accent-foreground: 220 50% 95%;
|
||||
--destructive: 0 85% 60%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 220 50% 85%;
|
||||
--input: 220 50% 85%;
|
||||
--ring: 30 100% 55%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 30 100% 55%;
|
||||
--chart-2: 120 100% 55%;
|
||||
--chart-3: 240 100% 55%;
|
||||
--chart-4: 330 100% 55%;
|
||||
--chart-5: 60 100% 55%;
|
||||
}
|
||||
28
web/app/public/themes/seasidepostcard.css
Normal file
28
web/app/public/themes/seasidepostcard.css
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
:root {
|
||||
/* SeasidePostcard Theme */
|
||||
--background: 200 50% 95%;
|
||||
--foreground: 200 50% 20%;
|
||||
--card: 200 50% 90%;
|
||||
--card-foreground: 200 50% 20%;
|
||||
--popover: 200 50% 95%;
|
||||
--popover-foreground: 200 50% 20%;
|
||||
--primary: 30 100% 55%;
|
||||
--primary-foreground: 200 50% 95%;
|
||||
--secondary: 200 50% 85%;
|
||||
--secondary-foreground: 200 50% 20%;
|
||||
--muted: 200 50% 80%;
|
||||
--muted-foreground: 200 50% 40%;
|
||||
--accent: 350 100% 55%;
|
||||
--accent-foreground: 200 50% 95%;
|
||||
--destructive: 0 85% 60%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 200 50% 85%;
|
||||
--input: 200 50% 85%;
|
||||
--ring: 30 100% 55%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 30 100% 55%;
|
||||
--chart-2: 350 100% 55%;
|
||||
--chart-3: 200 100% 55%;
|
||||
--chart-4: 140 100% 55%;
|
||||
--chart-5: 260 100% 55%;
|
||||
}
|
||||
28
web/app/public/themes/typewriter.css
Normal file
28
web/app/public/themes/typewriter.css
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
:root {
|
||||
/* Typewriter Theme */
|
||||
--background: 0 0% 95%;
|
||||
--foreground: 0 0% 10%;
|
||||
--card: 0 0% 90%;
|
||||
--card-foreground: 0 0% 10%;
|
||||
--popover: 0 0% 95%;
|
||||
--popover-foreground: 0 0% 10%;
|
||||
--primary: 0 0% 20%;
|
||||
--primary-foreground: 0 0% 95%;
|
||||
--secondary: 0 0% 85%;
|
||||
--secondary-foreground: 0 0% 10%;
|
||||
--muted: 0 0% 80%;
|
||||
--muted-foreground: 0 0% 40%;
|
||||
--accent: 0 0% 70%;
|
||||
--accent-foreground: 0 0% 10%;
|
||||
--destructive: 0 85% 60%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 85%;
|
||||
--input: 0 0% 85%;
|
||||
--ring: 0 0% 20%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 0 0% 20%;
|
||||
--chart-2: 0 0% 40%;
|
||||
--chart-3: 0 0% 60%;
|
||||
--chart-4: 0 0% 30%;
|
||||
--chart-5: 0 0% 50%;
|
||||
}
|
||||
28
web/app/public/themes/vapordream.css
Normal file
28
web/app/public/themes/vapordream.css
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
:root {
|
||||
/* VaporDream Theme */
|
||||
--background: 260 20% 10%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 260 20% 13%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 260 20% 10%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 300 100% 70%;
|
||||
--primary-foreground: 260 20% 10%;
|
||||
--secondary: 260 20% 20%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 260 20% 15%;
|
||||
--muted-foreground: 260 10% 60%;
|
||||
--accent: 200 100% 70%;
|
||||
--accent-foreground: 260 20% 10%;
|
||||
--destructive: 0 85% 60%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 260 20% 20%;
|
||||
--input: 260 20% 20%;
|
||||
--ring: 300 100% 70%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 300 100% 70%;
|
||||
--chart-2: 200 100% 70%;
|
||||
--chart-3: 50 100% 60%;
|
||||
--chart-4: 330 100% 70%;
|
||||
--chart-5: 150 100% 60%;
|
||||
}
|
||||
71
web/app/public/themes/xeroxui.css
Normal file
71
web/app/public/themes/xeroxui.css
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
:root {
|
||||
/* Windows 3.1 White & Blue Theme */
|
||||
--background: 0 0% 100%; /* Pure white */
|
||||
--foreground: 0 0% 0%; /* Black text */
|
||||
--card: 0 0% 98%; /* Slightly off-white for cards */
|
||||
--card-foreground: 0 0% 0%; /* Black text */
|
||||
--popover: 0 0% 100%; /* White */
|
||||
--popover-foreground: 0 0% 0%; /* Black */
|
||||
--primary: 240 100% 27%; /* Windows blue */
|
||||
--primary-foreground: 0 0% 100%; /* White text on blue */
|
||||
--secondary: 0 0% 90%; /* Light gray for secondary */
|
||||
--secondary-foreground: 0 0% 0%; /* Black text */
|
||||
--muted: 0 0% 85%; /* Muted gray */
|
||||
--muted-foreground: 240 10% 40%; /* Muted blue-gray */
|
||||
--accent: 60 100% 50%; /* Classic yellow accent */
|
||||
--accent-foreground: 240 100% 27%; /* Blue */
|
||||
--destructive: 0 100% 50%; /* Red for destructive */
|
||||
--destructive-foreground: 0 0% 100%; /* White */
|
||||
--border: 240 100% 27%; /* Blue borders */
|
||||
--input: 0 0% 100%; /* White input */
|
||||
--ring: 240 100% 27%; /* Blue ring/focus */
|
||||
--radius: 0.125rem; /* Small radius, almost square */
|
||||
--chart-1: 240 100% 27%; /* Blue */
|
||||
--chart-2: 0 0% 60%; /* Gray */
|
||||
--chart-3: 60 100% 50%; /* Yellow */
|
||||
--chart-4: 0 100% 50%; /* Red */
|
||||
--chart-5: 120 100% 25%; /* Green */
|
||||
--border-light: 0 0% 100%; /* White for top/left border */
|
||||
--border-dark: 240 100% 20%; /* Dark blue for bottom/right border */
|
||||
}
|
||||
|
||||
/* Windows 3.11 style border */
|
||||
.win311-border {
|
||||
border-top: 2px solid hsl(var(--border-light));
|
||||
border-left: 2px solid hsl(var(--border-light));
|
||||
border-bottom: 2px solid hsl(var(--border-dark));
|
||||
border-right: 2px solid hsl(var(--border-dark));
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
/* Titles */
|
||||
.win311-title {
|
||||
color: hsl(var(--primary));
|
||||
border-bottom: 2px solid hsl(var(--primary));
|
||||
font-weight: bold;
|
||||
padding: 0.25em 0.5em;
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
/* General text */
|
||||
body, .filemanager, .filemanager * {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
button, .win311-button {
|
||||
font-family: inherit;
|
||||
font-size: 1em;
|
||||
padding: 0.25em 1.5em;
|
||||
background: #c0c0c0; /* classic light gray */
|
||||
color: #000;
|
||||
border-top: 2px solid #fff; /* light bevel */
|
||||
border-left: 2px solid #fff; /* light bevel */
|
||||
border-bottom: 2px solid #808080;/* dark bevel */
|
||||
border-right: 2px solid #808080; /* dark bevel */
|
||||
border-radius: 0;
|
||||
box-shadow: inset 1px 1px 0 #fff, inset -1px -1px 0 #808080 !important;
|
||||
outline: none !important;
|
||||
cursor: pointer !important;
|
||||
transition: none !important;
|
||||
}
|
||||
228
web/app/public/themes/xtreegold.css
Normal file
228
web/app/public/themes/xtreegold.css
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
:root {
|
||||
/* XTree Gold DOS File Manager Theme - Authentic 1980s Interface */
|
||||
|
||||
/* Core XTree Gold Palette - Exact Match */
|
||||
--background: 240 100% 16%; /* Classic XTree blue background */
|
||||
--foreground: 60 100% 88%; /* Bright yellow text */
|
||||
|
||||
/* Card Elements - File Panels */
|
||||
--card: 240 100% 16%; /* Same blue as main background */
|
||||
--card-foreground: 60 100% 88%; /* Bright yellow panel text */
|
||||
|
||||
/* Popover Elements - Context Menus */
|
||||
--popover: 240 100% 12%; /* Slightly darker blue for menus */
|
||||
--popover-foreground: 60 100% 90%; /* Bright yellow menu text */
|
||||
|
||||
/* Primary - XTree Gold Highlight (Cyan Selection) */
|
||||
--primary: 180 100% 70%; /* Bright cyan for selections */
|
||||
--primary-foreground: 240 100% 10%; /* Dark blue text on cyan */
|
||||
|
||||
/* Secondary - Directory Highlights */
|
||||
--secondary: 180 100% 50%; /* Pure cyan for directories */
|
||||
--secondary-foreground: 240 100% 10%; /* Dark blue on cyan */
|
||||
|
||||
/* Muted - Status Areas */
|
||||
--muted: 240 100% 14%; /* Slightly darker blue */
|
||||
--muted-foreground: 60 80% 75%; /* Dimmed yellow */
|
||||
|
||||
/* Accent - Function Keys & Highlights */
|
||||
--accent: 60 100% 50%; /* Pure yellow for F-keys */
|
||||
--accent-foreground: 240 100% 10%; /* Dark blue on yellow */
|
||||
|
||||
/* Destructive - Delete/Error */
|
||||
--destructive: 0 100% 60%; /* Bright red for warnings */
|
||||
--destructive-foreground: 60 90% 95%; /* Light yellow on red */
|
||||
|
||||
/* Interactive Elements */
|
||||
--border: 60 100% 70%; /* Yellow border lines */
|
||||
--input: 240 100% 14%; /* Dark blue input fields */
|
||||
--ring: 180 100% 70%; /* Cyan focus ring */
|
||||
|
||||
/* Border Radius - Sharp DOS aesthetic */
|
||||
--radius: 0rem; /* No rounding - pure DOS */
|
||||
|
||||
/* Chart Colors - Authentic DOS 16-color palette */
|
||||
--chart-1: 180 100% 70%; /* Bright cyan */
|
||||
--chart-2: 60 100% 50%; /* Yellow */
|
||||
--chart-3: 120 100% 50%; /* Green */
|
||||
--chart-4: 300 100% 50%; /* Magenta */
|
||||
--chart-5: 0 100% 60%; /* Red */
|
||||
|
||||
/* Authentic XTree Gold Colors */
|
||||
--xtree-blue: 240 100% 16%; /* Main background blue */
|
||||
--xtree-yellow: 60 100% 88%; /* Text yellow */
|
||||
--xtree-cyan: 180 100% 70%; /* Selection cyan */
|
||||
--xtree-white: 0 0% 100%; /* Pure white */
|
||||
--xtree-green: 120 100% 50%; /* DOS green */
|
||||
--xtree-magenta: 300 100% 50%; /* DOS magenta */
|
||||
--xtree-red: 0 100% 60%; /* DOS red */
|
||||
|
||||
/* File Type Colors - Authentic XTree */
|
||||
--executable-color: 0 0% 100%; /* White for executables */
|
||||
--directory-color: 180 100% 70%; /* Cyan for directories */
|
||||
--archive-color: 300 100% 50%; /* Magenta for archives */
|
||||
--text-color: 60 100% 88%; /* Yellow for text */
|
||||
--system-color: 0 100% 60%; /* Red for system files */
|
||||
|
||||
/* Menu Bar Colors */
|
||||
--menu-bar: 240 100% 8%; /* Dark blue menu bar */
|
||||
--menu-text: 60 100% 88%; /* Yellow menu text */
|
||||
--menu-highlight: 180 100% 50%; /* Cyan menu highlight */
|
||||
}
|
||||
|
||||
/* Authentic XTree Gold Enhancement Classes */
|
||||
.xtree-main-panel {
|
||||
background: hsl(var(--xtree-blue));
|
||||
color: hsl(var(--xtree-yellow));
|
||||
font-family: 'Perfect DOS VGA 437', 'Courier New', monospace;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.xtree-menu-bar {
|
||||
background: hsl(var(--menu-bar));
|
||||
color: hsl(var(--menu-text));
|
||||
padding: 0;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.xtree-menu-item {
|
||||
padding: 0 8px;
|
||||
color: hsl(var(--xtree-yellow));
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.xtree-menu-item:hover,
|
||||
.xtree-menu-item.active {
|
||||
background: hsl(var(--xtree-cyan));
|
||||
color: hsl(240 100% 10%);
|
||||
}
|
||||
|
||||
.xtree-dual-pane {
|
||||
display: flex;
|
||||
height: calc(100vh - 60px);
|
||||
}
|
||||
|
||||
.xtree-left-pane,
|
||||
.xtree-right-pane {
|
||||
flex: 1;
|
||||
background: hsl(var(--xtree-blue));
|
||||
color: hsl(var(--xtree-yellow));
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.xtree-directory-tree {
|
||||
color: hsl(var(--directory-color));
|
||||
background: hsl(var(--xtree-blue));
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.xtree-file-list {
|
||||
background: hsl(var(--xtree-blue));
|
||||
color: hsl(var(--xtree-yellow));
|
||||
font-family: 'Perfect DOS VGA 437', 'Courier New', monospace;
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.xtree-file-selected {
|
||||
background: hsl(var(--xtree-cyan));
|
||||
color: hsl(240 100% 10%);
|
||||
}
|
||||
|
||||
.xtree-directory {
|
||||
color: hsl(var(--directory-color));
|
||||
}
|
||||
|
||||
.xtree-executable {
|
||||
color: hsl(var(--executable-color));
|
||||
}
|
||||
|
||||
.xtree-archive {
|
||||
color: hsl(var(--archive-color));
|
||||
}
|
||||
|
||||
.xtree-text-file {
|
||||
color: hsl(var(--text-color));
|
||||
}
|
||||
|
||||
.xtree-system-file {
|
||||
color: hsl(var(--system-color));
|
||||
}
|
||||
|
||||
.xtree-status-line {
|
||||
background: hsl(var(--xtree-blue));
|
||||
color: hsl(var(--xtree-yellow));
|
||||
height: 20px;
|
||||
padding: 0 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.xtree-function-bar {
|
||||
background: hsl(var(--menu-bar));
|
||||
color: hsl(var(--xtree-yellow));
|
||||
height: 20px;
|
||||
display: flex;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.xtree-function-key {
|
||||
padding: 0 4px;
|
||||
color: hsl(var(--xtree-yellow));
|
||||
border-right: 1px solid hsl(var(--xtree-yellow));
|
||||
}
|
||||
|
||||
.xtree-function-key:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.xtree-path-bar {
|
||||
background: hsl(var(--xtree-blue));
|
||||
color: hsl(var(--xtree-yellow));
|
||||
padding: 2px 8px;
|
||||
border-bottom: 1px solid hsl(var(--xtree-yellow));
|
||||
}
|
||||
|
||||
.xtree-disk-info {
|
||||
background: hsl(var(--xtree-blue));
|
||||
color: hsl(var(--xtree-yellow));
|
||||
padding: 4px 8px;
|
||||
text-align: right;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Authentic DOS Box Drawing Characters */
|
||||
.xtree-box-char {
|
||||
font-family: 'Perfect DOS VGA 437', 'Courier New', monospace;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* Classic Text Mode Cursor */
|
||||
.xtree-cursor {
|
||||
background: hsl(var(--xtree-yellow));
|
||||
color: hsl(var(--xtree-blue));
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Authentic DOS Window Styling */
|
||||
.xtree-window {
|
||||
border: 2px outset hsl(var(--xtree-blue));
|
||||
background: hsl(var(--xtree-blue));
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
28
web/app/public/themes/y2kglow.css
Normal file
28
web/app/public/themes/y2kglow.css
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
:root {
|
||||
/* Y2KGlow Theme */
|
||||
--background: 240 10% 10%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 13%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 10%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 190 90% 50%;
|
||||
--primary-foreground: 240 10% 10%;
|
||||
--secondary: 240 10% 20%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 10% 15%;
|
||||
--muted-foreground: 240 5% 60%;
|
||||
--accent: 280 89% 65%;
|
||||
--accent-foreground: 240 10% 10%;
|
||||
--destructive: 0 85% 60%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 10% 20%;
|
||||
--input: 240 10% 20%;
|
||||
--ring: 190 90% 50%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 190 90% 50%;
|
||||
--chart-2: 280 89% 65%;
|
||||
--chart-3: 80 75% 55%;
|
||||
--chart-4: 334 89% 62%;
|
||||
--chart-5: 41 99% 60%;
|
||||
}
|
||||
14
web/app/settings/README.md
Normal file
14
web/app/settings/README.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
https://whoapi.com/domain-availability-api-pricing/
|
||||
|
||||
|
||||
- **Ports Used**:
|
||||
Main website: (https://www.pragmatismo.com.br).
|
||||
Webmail (Stalwart): (https://mail.pragmatismo.com.br).
|
||||
Database (PostgreSQL): .
|
||||
SSO (Zitadel): (https://sso.pragmatismo.com.br).
|
||||
Storage (MinIO): (https://drive.pragmatismo.com.br).
|
||||
ALM (Forgejo): (https://alm.pragmatismo.com.br).
|
||||
BotServer : (https://gb.pragmatismo.com.br).
|
||||
Meeting: (https://call.pragmatismo.com.br).
|
||||
IMAP: 993.
|
||||
SMTP: 465.
|
||||
30
web/app/settings/account/account-form.html
Normal file
30
web/app/settings/account/account-form.html
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<!-- Riot.js component for the account form (converted from app/settings/account/account-form.tsx) -->
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<controller name="name">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-1">Name</label>
|
||||
<input class="w-full p-2 border rounded"
|
||||
bind="{name}"
|
||||
placeholder="Your name" />
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
This is the name that will be displayed on your profile and in emails.
|
||||
</p>
|
||||
</div>
|
||||
</controller>
|
||||
|
||||
<controller name="dob">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-1">Date of birth</label>
|
||||
<button type="button"
|
||||
class="w-full p-2 border rounded text-left"
|
||||
@click="{() => showDatePicker = true}">
|
||||
{value.toDateString()}
|
||||
</button>
|
||||
{showDatePicker && (
|
||||
<input type="date"
|
||||
bind="{dob}"
|
||||
@change="{e => { showDatePicker = false; if (e.target.value) { dob = new Date(e.target.value); }}}"
|
||||
class="mt-1 p-1 border rounded" />
|
||||
)}
|
||||
<p class="text-sm text-gray-5
|
||||
1
web/app/settings/appearance/appearance-form.html
Normal file
1
web/app/settings/appearance/appearance-form.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<!-- Placeholder Riot component for appearance-form -->
|
||||
1
web/app/settings/chat/README.md
Normal file
1
web/app/settings/chat/README.md
Normal file
File diff suppressed because one or more lines are too long
38
web/app/settings/layout.html
Normal file
38
web/app/settings/layout.html
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<!-- Riot.js component for the settings layout (converted from app/settings/layout.tsx) -->
|
||||
<template>
|
||||
<div class="flex-1 overflow-auto">
|
||||
<div class="p-5">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold">Settings</h1>
|
||||
<p class="text-sm text-gray-500">
|
||||
Manage your account settings and set e-mail preferences.
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 my-6"></div>
|
||||
<div class="flex flex-col md:flex-row gap-6">
|
||||
<div class="w-full md:w-1/4">
|
||||
<sidebar-nav items="{sidebarNavItems}" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script type="module">
|
||||
import './style.css';
|
||||
import './components/sidebar-nav.html';
|
||||
|
||||
export default {
|
||||
// Reactive state
|
||||
sidebarNavItems: [
|
||||
{ title: "Profile", href: "/settings" },
|
||||
{ title: "Account", href: "/settings/account" },
|
||||
{ title: "Appearance", href: "/settings/appearance" },
|
||||
{ title: "Notifications", href: "/settings/notifications" },
|
||||
{ title: "Display", href: "/settings/display" },
|
||||
]
|
||||
};
|
||||
</script>
|
||||
20
web/app/settings/page.html
Normal file
20
web/app/settings/page.html
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<!-- Riot.js component for the settings page (converted from app/settings/page.tsx) -->
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h2 class="text-lg font-medium">Profile</h2>
|
||||
<p class="text-sm text-gray-500"></p>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 my-4"></div>
|
||||
<profile-form></profile-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script type="module">
|
||||
import './profile-form.html';
|
||||
import './style.css';
|
||||
|
||||
export default {
|
||||
// No additional state needed
|
||||
};
|
||||
</script>
|
||||
91
web/app/settings/profile-form.html
Normal file
91
web/app/settings/profile-form.html
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<!-- Riot.js component for the profile form (converted from app/settings/profile-form.tsx) -->
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<controller name="username">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-1">Username</label>
|
||||
<input class="w-full p-2 border rounded {error ? 'border-red-500' : 'border-gray-300'}"
|
||||
bind="{username}"
|
||||
placeholder="Enter username" />
|
||||
{error && <p class="text-red-500 text-xs mt-1">{error.message}</p>}
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
This is your public display name. It can be your real name or a pseudonym. You can only change this once every 30 days.
|
||||
</p>
|
||||
</div>
|
||||
</controller>
|
||||
|
||||
<controller name="email">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-1">Email</label>
|
||||
<input type="email"
|
||||
class="w-full p-2 border rounded {error ? 'border-red-500' : 'border-gray-300'}"
|
||||
bind="{email}"
|
||||
placeholder="Enter email" />
|
||||
{error && <p class="text-red-500 text-xs mt-1">{error.message}</p>}
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
You can manage verified email addresses in your email settings.
|
||||
</p>
|
||||
</div>
|
||||
</controller>
|
||||
|
||||
<controller name="bio">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-1">Bio</label>
|
||||
<textarea class="w-full p-2 border rounded {error ? 'border-red-500' : 'border-gray-300'}"
|
||||
bind="{bio}"
|
||||
rows="4"
|
||||
placeholder="Tell us a little bit about yourself"></textarea>
|
||||
{error && <p class="text-red-500 text-xs mt-1">{error.message}</p>}
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
You can @mention other users and organizations to link to them.
|
||||
</p>
|
||||
</div>
|
||||
</controller>
|
||||
|
||||
<button class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
|
||||
@click="{handleSubmit(onSubmit)}">
|
||||
Update profile
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script type="module">
|
||||
import { useState } from 'riot';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import './style.css';
|
||||
|
||||
export default {
|
||||
// Reactive state
|
||||
username: '',
|
||||
email: '',
|
||||
bio: '',
|
||||
error: null,
|
||||
|
||||
// Validation schema
|
||||
schema: z.object({
|
||||
username: z.string().min(2, { message: "Username must be at least 2 characters." }).max(30, { message: "Username must not be longer than 30 characters." }),
|
||||
email: z.string().email(),
|
||||
bio: z.string().min(4).max(160)
|
||||
}),
|
||||
|
||||
// Methods
|
||||
handleSubmit(callback) {
|
||||
const result = this.schema.safeParse({
|
||||
username: this.username,
|
||||
email: this.email,
|
||||
bio: this.bio
|
||||
});
|
||||
if (result.success) {
|
||||
callback(result.data);
|
||||
} else {
|
||||
this.error = result.error.errors[0];
|
||||
}
|
||||
},
|
||||
|
||||
onSubmit(data) {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
1
web/app/sources/README.md
Normal file
1
web/app/sources/README.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
Prompts come from: https://github.com/0xeb/TheBigPromptLibrary
|
||||
1567
web/app/sources/prompts.csv
Normal file
1567
web/app/sources/prompts.csv
Normal file
File diff suppressed because it is too large
Load diff
408
web/app/tables/styles.css
Normal file
408
web/app/tables/styles.css
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
` .excel-clone {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.quick-access {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
background: #f3f3f3;
|
||||
border-bottom: 1px solid #d9d9d9;
|
||||
height: 40px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.quick-access-btn {
|
||||
padding: 6px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.quick-access-btn:hover {
|
||||
background: #e5e5e5;
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
|
||||
.quick-access-btn:active {
|
||||
background: #d9d9d9;
|
||||
}
|
||||
|
||||
.quick-access-separator {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: #d9d9d9;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.title-input {
|
||||
margin-left: 8px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
width: 200px;
|
||||
height: 28px;
|
||||
font-family: inherit;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.title-input:focus {
|
||||
outline: none;
|
||||
border-color: #217346;
|
||||
box-shadow: 0 0 0 2px rgba(33, 115, 70, 0.2);
|
||||
}
|
||||
|
||||
.ribbon {
|
||||
background: #f3f3f3;
|
||||
border-bottom: 1px solid #d9d9d9;
|
||||
}
|
||||
|
||||
.ribbon-tabs {
|
||||
display: flex;
|
||||
background: #f3f3f3;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.ribbon-tab {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ribbon-tab-button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #333;
|
||||
position: relative;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.ribbon-tab-button:hover:not(.active) {
|
||||
background: #e5e5e5;
|
||||
}
|
||||
|
||||
.ribbon-tab-button.active {
|
||||
background: white;
|
||||
color: #217346;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ribbon-content {
|
||||
display: flex;
|
||||
padding: 8px;
|
||||
background: white;
|
||||
gap: 16px;
|
||||
min-height: 80px;
|
||||
align-items: flex-start;
|
||||
border-bottom: 1px solid #d9d9d9;
|
||||
}
|
||||
|
||||
.ribbon-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.ribbon-group:not(:last-child)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 1px;
|
||||
background: #e5e5e5;
|
||||
}
|
||||
|
||||
.ribbon-group-content {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ribbon-group-title {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ribbon-button {
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.1s ease;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.ribbon-button.medium {
|
||||
padding: 6px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.ribbon-button.large {
|
||||
flex-direction: column;
|
||||
padding: 8px;
|
||||
min-width: 56px;
|
||||
height: 56px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ribbon-button:hover {
|
||||
background: #e5e5e5;
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
|
||||
.ribbon-button:active {
|
||||
background: #d9d9d9;
|
||||
}
|
||||
|
||||
.ribbon-button.active {
|
||||
background: #e0f0e9;
|
||||
border-color: #217346;
|
||||
color: #217346;
|
||||
}
|
||||
|
||||
.ribbon-button-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ribbon-button-label {
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
font-weight: 400;
|
||||
max-width: 52px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
margin-left: 4px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ribbon-dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.ribbon-dropdown-content {
|
||||
display: none;
|
||||
position: absolute;
|
||||
background-color: white;
|
||||
min-width: 160px;
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
|
||||
z-index: 1;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #d9d9d9;
|
||||
padding: 8px;
|
||||
left: 0;
|
||||
top: 100%;
|
||||
}
|
||||
|
||||
.ribbon-dropdown:hover .ribbon-dropdown-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ribbon-split-button {
|
||||
display: flex;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.ribbon-split-button:hover {
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
|
||||
.ribbon-split-button .ribbon-button {
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.ribbon-split-button-arrow {
|
||||
padding: 0 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-left: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.ribbon-split-button-arrow:hover {
|
||||
background: #e5e5e5;
|
||||
}
|
||||
|
||||
.worksheet-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.formula-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid #d9d9d9;
|
||||
height: 32px;
|
||||
background: #f3f3f3;
|
||||
}
|
||||
|
||||
.cell-reference {
|
||||
font-family: 'Consolas', monospace;
|
||||
font-size: 14px;
|
||||
padding: 4px 8px;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
background: white;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.formula-input {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
font-family: 'Consolas', monospace;
|
||||
font-size: 14px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.formula-input:focus {
|
||||
outline: none;
|
||||
border-color: #217346;
|
||||
box-shadow: 0 0 0 2px rgba(33, 115, 70, 0.2);
|
||||
}
|
||||
|
||||
.command-palette {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
border: 1px solid #217346;
|
||||
border-top: none;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
z-index: 20;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.command-item {
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.command-item:hover {
|
||||
background: #e0f0e9;
|
||||
}
|
||||
|
||||
.univer-container {
|
||||
position: relative;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
background: #f3f3f3;
|
||||
border-top: 1px solid #d9d9d9;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
gap: 16px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.status-mode {
|
||||
font-family: 'Consolas', monospace;
|
||||
font-weight: bold;
|
||||
color: #217346;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.zoom-level {
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.zoom-btn {
|
||||
padding: 2px 4px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 2px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.zoom-btn:hover {
|
||||
background: #e5e5e5;
|
||||
}
|
||||
|
||||
.sample-data-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 12px 24px;
|
||||
background: #217346;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
z-index: 30;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.sample-data-btn:hover {
|
||||
background: #1a5c3a;
|
||||
}
|
||||
|
||||
88
web/app/theme-provider.html
Normal file
88
web/app/theme-provider.html
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<!-- Riot component: theme-provider.html (converted from app/theme-provider.tsx) -->
|
||||
<script type="module">
|
||||
// This component replicates the ThemeProvider logic without React.
|
||||
// It manages theme selection and persists the choice in localStorage.
|
||||
export default {
|
||||
// Component state
|
||||
data() {
|
||||
return {
|
||||
themes: [
|
||||
{ name: '3dbevel', label: '3dbevel', cssFile: '/themes/3dbevel.css' },
|
||||
{ name: 'arcadeflash', label: 'Arcadeflash', cssFile: '/themes/arcadeflash.css' },
|
||||
{ name: 'jazzage', label: 'Jazzage', cssFile: '/themes/jazzage.css' },
|
||||
{ name: 'mellowgold', label: 'Mellowgold', cssFile: '/themes/mellowgold.css' },
|
||||
{ name: 'midcenturymod', label: 'Midcenturymod', cssFile: '/themes/midcenturymod.css' },
|
||||
{ name: 'polaroidmemories', label: 'Polaroidmemories', cssFile: '/themes/polaroidmemories.css' },
|
||||
{ name: 'retrowave', label: 'Retrowave', cssFile: '/themes/retrowave.css' },
|
||||
{ name: 'saturdaycartoons', label: 'Saturdaycartoons', cssFile: '/themes/saturdaycartoons.css' },
|
||||
{ name: 'typewriter', label: 'Typewriter', cssFile: '/themes/typewriter.css' },
|
||||
{ name: 'vapordream', label: 'Vapordream', cssFile: '/themes/vapordream.css' },
|
||||
{ name: 'xeroxui', label: 'Xeroxui', cssFile: '/themes/xeroxui.css' },
|
||||
{ name: 'y2kglow', label: 'Y2kglow', cssFile: '/themes/y2kglow.css' },
|
||||
{ name: 'xtreegold', label: 'XTreeGold', cssFile: '/themes/xtreegold.css' },
|
||||
],
|
||||
currentTheme: null,
|
||||
isLoaded: false,
|
||||
};
|
||||
},
|
||||
|
||||
// Lifecycle: component mounted
|
||||
mounted() {
|
||||
// Load saved theme from localStorage or default to first theme
|
||||
const savedThemeName = localStorage.getItem('theme') || this.themes[0].name;
|
||||
const savedTheme = this.themes.find(t => t.name === savedThemeName) || this.themes[0];
|
||||
this.setTheme(savedTheme.name);
|
||||
this.isLoaded = true;
|
||||
},
|
||||
|
||||
// Methods
|
||||
setTheme(themeName) {
|
||||
const theme = this.themes.find(t => t.name === themeName);
|
||||
if (!theme) return;
|
||||
|
||||
// Remove any existing theme link
|
||||
const existingLink = document.getElementById('theme-stylesheet');
|
||||
if (existingLink) {
|
||||
document.head.removeChild(existingLink);
|
||||
}
|
||||
|
||||
// Create new link element
|
||||
const link = document.createElement('link');
|
||||
link.id = 'theme-stylesheet';
|
||||
link.rel = 'stylesheet';
|
||||
link.href = theme.cssFile;
|
||||
link.onload = () => {
|
||||
this.currentTheme = theme;
|
||||
localStorage.setItem('theme', theme.name);
|
||||
};
|
||||
document.head.appendChild(link);
|
||||
},
|
||||
|
||||
// Expose a simple API for other components
|
||||
getTheme() {
|
||||
return this.currentTheme;
|
||||
},
|
||||
|
||||
getThemes() {
|
||||
return this.themes;
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* No visual output needed for the provider */
|
||||
</style>
|
||||
|
||||
<!-- No markup needed; this component is used for its side‑effects -->
|
||||
</write_to_file>
|
||||
<task_progress>
|
||||
- [x] Create comprehensive todo list
|
||||
- [x] Analyze React components to convert
|
||||
- [x] Convert app/client-nav.tsx to Riot component (client-nav.html)
|
||||
- [x] Update imports and usage (removed Nav import and usage from layout)
|
||||
- [x] Remove React dependencies from layout.tsx (file cleared)
|
||||
- [x] Convert remaining .tsx files (starting with theme-provider.tsx)
|
||||
- [ ] Verify application works with Riot
|
||||
- [ ] Clean up leftover React files
|
||||
</task_progress>
|
||||
</write_to_file>
|
||||
0
web/html/static/style.css
Normal file
0
web/html/static/style.css
Normal file
Loading…
Add table
Reference in a new issue