├── screenshot.png ├── .gitignore ├── LICENSE ├── Cargo.toml ├── src ├── config.rs ├── embedded.rs ├── main.rs ├── fs.rs └── server.rs ├── README.md └── static ├── index.html ├── css └── styles.css └── js └── app.js /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bahdotsh/mdlib/HEAD/screenshot.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated files 2 | /target/ 3 | 4 | # The cargo lock file 5 | Cargo.lock 6 | 7 | # IDE files 8 | .idea/ 9 | .vscode/ 10 | *.iml 11 | .DS_Store 12 | 13 | # Config files 14 | .env 15 | 16 | # Debug logs 17 | *.log 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 mdlib Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mdlib" 3 | version = "0.1.1" 4 | edition = "2021" 5 | description = "A beautiful markdown note-taking application" 6 | documentation = "https://github.com/bahdotsh/mdlib" 7 | homepage = "https://github.com/bahdotsh/mdlib" 8 | repository = "https://github.com/bahdotsh/mdlib" 9 | keywords = ["markdown", "wiki", "notes", "knowledge-base", "web-app"] 10 | categories = ["text-processing", "web-programming", "filesystem", "gui", "visualization"] 11 | license = "MIT" 12 | 13 | [dependencies] 14 | tokio = { version = "1.28", features = ["full"] } 15 | axum = "0.6.18" 16 | tower = "0.4.13" 17 | tower-http = { version = "0.4.0", features = ["fs", "trace", "cors"] } 18 | serde = { version = "1.0", features = ["derive"] } 19 | serde_json = "1.0" 20 | notify = "5.1.0" 21 | comrak = "0.18.0" # Markdown parser 22 | thiserror = "1.0" 23 | anyhow = "1.0" 24 | tracing = "0.1" 25 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 26 | clap = { version = "4.3", features = ["derive"] } 27 | walkdir = "2.3.3" 28 | config = "0.13.3" 29 | dirs = "5.0" 30 | rust-embed = "6.8.1" # For embedding static files into the binary 31 | mime_guess = "2.0.4" # For guessing MIME types 32 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::path::PathBuf; 3 | use std::fs; 4 | use anyhow::{Result, Context}; 5 | 6 | #[derive(Debug, Clone, Serialize, Deserialize)] 7 | pub struct AppConfig { 8 | /// The port to run the server on 9 | pub port: u16, 10 | /// Whether to watch for file changes 11 | pub watch_files: bool, 12 | /// The address to bind to 13 | pub bind_address: String, 14 | /// The maximum file size in megabytes 15 | pub max_file_size_mb: u64, 16 | /// Enable dark mode by default 17 | pub default_dark_mode: bool, 18 | } 19 | 20 | impl Default for AppConfig { 21 | fn default() -> Self { 22 | Self { 23 | port: 3000, 24 | watch_files: true, 25 | bind_address: "127.0.0.1".to_string(), 26 | max_file_size_mb: 10, 27 | default_dark_mode: false, 28 | } 29 | } 30 | } 31 | 32 | impl AppConfig { 33 | /// Load configuration from a file or create default if it doesn't exist 34 | pub fn load_or_default(config_path: &PathBuf) -> Result { 35 | if config_path.exists() { 36 | let config_str = fs::read_to_string(config_path) 37 | .context(format!("Failed to read config file: {:?}", config_path))?; 38 | 39 | serde_json::from_str(&config_str) 40 | .context("Failed to parse config file") 41 | } else { 42 | let config = Self::default(); 43 | 44 | // Create parent directory if it doesn't exist 45 | if let Some(parent) = config_path.parent() { 46 | fs::create_dir_all(parent) 47 | .context(format!("Failed to create config directory: {:?}", parent))?; 48 | } 49 | 50 | // Write default config to file 51 | let config_str = serde_json::to_string_pretty(&config) 52 | .context("Failed to serialize config")?; 53 | 54 | fs::write(config_path, config_str) 55 | .context(format!("Failed to write config file: {:?}", config_path))?; 56 | 57 | Ok(config) 58 | } 59 | } 60 | 61 | // /// Save configuration to a file 62 | // pub fn save(&self, config_path: &PathBuf) -> Result<()> { 63 | // let config_str = serde_json::to_string_pretty(self) 64 | // .context("Failed to serialize config")?; 65 | 66 | // fs::write(config_path, config_str) 67 | // .context(format!("Failed to write config file: {:?}", config_path)) 68 | // } 69 | 70 | /// Get the server address with port 71 | pub fn server_address(&self) -> String { 72 | format!("{}:{}", self.bind_address, self.port) 73 | } 74 | } -------------------------------------------------------------------------------- /src/embedded.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | body::Body, 3 | http::{header, Response, StatusCode}, 4 | response::IntoResponse, 5 | }; 6 | use rust_embed::RustEmbed; 7 | use std::{borrow::Cow, future::Future, pin::Pin}; 8 | use tracing::{debug, warn}; 9 | 10 | #[derive(RustEmbed)] 11 | #[folder = "static/"] 12 | pub struct StaticAssets; 13 | 14 | pub fn serve_embedded_file(path: &str) -> Pin + Send>> { 15 | let path_owned = path.to_string(); 16 | Box::pin(async move { 17 | let path = if path_owned.is_empty() || path_owned == "/" { 18 | debug!("Serving root path as index.html"); 19 | "index.html".to_string() 20 | } else { 21 | // Remove leading slash if present 22 | if path_owned.starts_with('/') { 23 | path_owned[1..].to_string() 24 | } else { 25 | path_owned 26 | } 27 | }; 28 | 29 | debug!("Attempting to serve embedded file: {}", path); 30 | 31 | match StaticAssets::get(&path) { 32 | Some(content) => { 33 | let mime_type = mime_guess::from_path(&path) 34 | .first_or_octet_stream() 35 | .as_ref() 36 | .to_string(); 37 | 38 | debug!("Found embedded file: {} (MIME: {})", path, mime_type); 39 | 40 | let mut response = Response::builder() 41 | .header(header::CONTENT_TYPE, mime_type); 42 | 43 | // Add caching headers for non-HTML files 44 | if !path.ends_with(".html") { 45 | response = response 46 | .header(header::CACHE_CONTROL, "public, max-age=604800"); 47 | } 48 | 49 | let body = match content.data { 50 | Cow::Borrowed(bytes) => Body::from(bytes.to_vec()), 51 | Cow::Owned(bytes) => Body::from(bytes), 52 | }; 53 | 54 | response 55 | .body(body) 56 | .unwrap_or_else(|_| { 57 | warn!("Failed to build response for embedded file: {}", path); 58 | Response::new(Body::empty()) 59 | }) 60 | } 61 | None => { 62 | // Check if this is a JS or CSS file and try with appropriate subfolder 63 | if path.ends_with(".js") && !path.starts_with("js/") { 64 | debug!("Trying to find JS file in js/ subfolder"); 65 | let js_path = format!("js/{}", path); 66 | let future = serve_embedded_file(&js_path); 67 | return future.await; 68 | } else if path.ends_with(".css") && !path.starts_with("css/") { 69 | debug!("Trying to find CSS file in css/ subfolder"); 70 | let css_path = format!("css/{}", path); 71 | let future = serve_embedded_file(&css_path); 72 | return future.await; 73 | } 74 | 75 | warn!("Embedded file not found: {}", path); 76 | Response::builder() 77 | .status(StatusCode::NOT_FOUND) 78 | .body(Body::from(format!("File not found: {}", path))) 79 | .unwrap() 80 | } 81 | } 82 | }) 83 | } 84 | 85 | // Handler for all embedded static assets 86 | pub async fn static_handler(uri: axum::http::Uri) -> impl IntoResponse { 87 | let path = uri.path().trim_start_matches('/'); 88 | serve_embedded_file(path).await 89 | } 90 | 91 | // Function to verify that essential files are embedded 92 | pub fn verify_essential_files() -> Result<(), Vec<&'static str>> { 93 | let essential_files = vec![ 94 | "index.html", 95 | "js/app.js", 96 | "css/styles.css", 97 | ]; 98 | 99 | let missing_files: Vec<&str> = essential_files 100 | .into_iter() 101 | .filter(|file| StaticAssets::get(*file).is_none()) 102 | .collect(); 103 | 104 | if missing_files.is_empty() { 105 | Ok(()) 106 | } else { 107 | Err(missing_files) 108 | } 109 | } 110 | 111 | // Function to list all embedded files for debugging 112 | pub fn list_embedded_files() -> Vec { 113 | StaticAssets::iter() 114 | .map(|path| path.to_string()) 115 | .collect() 116 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mdlib - Your personal wiki/ md library 2 | 3 | A beautiful, web-based tool for creating, editing, and organizing markdown notes with real-time preview. 4 | 5 | ![mdlib Screenshot](screenshot.png) 6 | 7 | ## Features 8 | 9 | - 📝 Browse and edit markdown files in a directory 10 | - ✨ Create new markdown files 11 | - 👁️ Preview markdown rendering in real-time 12 | - 🔍 Search across all your notes 13 | - 🏷️ Tag support for better organization 14 | - 📂 Category support for hierarchical organization 15 | - 🌙 Dark mode support 16 | - ⚡ Keyboard shortcuts for quick actions 17 | - 🔄 Auto-save functionality 18 | - 💾 Simple file management 19 | - 📱 Responsive design for all device sizes 20 | - 🔍 Full-text search capabilities 21 | - 🔗 Easy navigation between linked notes 22 | - 📄 YAML frontmatter support 23 | - 🧩 Customizable configuration 24 | - 📦 Embedded web assets - run from any directory 25 | 26 | ## Getting Started 27 | 28 | ### Prerequisites 29 | 30 | - Rust and Cargo (1.54.0 or newer) 31 | 32 | ### Installation 33 | 34 | #### Via cargo install 35 | 36 | The simplest way to install mdlib is through cargo: 37 | 38 | ```bash 39 | cargo install mdlib 40 | ``` 41 | 42 | Once installed, you can run `mdlib` from any directory to serve that directory's markdown files. 43 | 44 | #### Building from source 45 | 46 | 1. Clone the repository (or download the source code): 47 | 48 | ```bash 49 | git clone https://github.com/bahdotsh/mdlib.git 50 | cd mdlib 51 | ``` 52 | 53 | 2. Build the project: 54 | 55 | ```bash 56 | cargo build --release 57 | ``` 58 | 59 | The compiled binary will be located in `target/release/mdlib`. 60 | 61 | ### Usage 62 | 63 | To start mdlib, run: 64 | 65 | ```bash 66 | mdlib [DIRECTORY] 67 | ``` 68 | 69 | Where `[DIRECTORY]` is the path to the directory containing your markdown files. If not specified, the current directory is used. 70 | 71 | You can also specify a custom configuration file: 72 | 73 | ```bash 74 | mdlib --config-file /path/to/config.json [DIRECTORY] 75 | ``` 76 | 77 | Once started, open your browser and navigate to [http://localhost:3000](http://localhost:3000). 78 | 79 | #### Configuration Commands 80 | 81 | mdlib includes special commands to manage your configuration: 82 | 83 | ```bash 84 | # Show config file location and current settings 85 | mdlib --config 86 | 87 | # Create a default config file 88 | mdlib --config create 89 | 90 | # List all embedded static files (for debugging) 91 | mdlib --list-embedded 92 | ``` 93 | 94 | ## Keyboard Shortcuts 95 | 96 | - `Ctrl/Cmd + S`: Save current file 97 | - `Ctrl/Cmd + B`: Bold selected text 98 | - `Ctrl/Cmd + I`: Italicize selected text 99 | - `Ctrl/Cmd + P`: Toggle preview mode 100 | - `Ctrl/Cmd + N`: Create new note 101 | 102 | ## Development 103 | 104 | ### Project Structure 105 | 106 | - `src/`: Source code 107 | - `main.rs`: Entry point 108 | - `fs.rs`: File system operations 109 | - `server.rs`: Web server and API endpoints 110 | - `config.rs`: Configuration management 111 | - `embedded.rs`: Embedded static assets handler 112 | - `static/`: Static web files (embedded into the binary at compile time) 113 | - `index.html`: Main HTML page 114 | - `css/`: Stylesheets 115 | - `js/`: JavaScript files 116 | 117 | ### Building from Source 118 | 119 | ```bash 120 | # Clone the repository 121 | git clone https://github.com/bahdotsh/mdlib.git 122 | cd mdlib 123 | 124 | # Build the project 125 | cargo build --release 126 | 127 | # Run the application 128 | ./target/release/mdlib 129 | ``` 130 | 131 | ## Configuration 132 | 133 | You can specify a custom configuration file with the `--config-file` option: 134 | 135 | ```bash 136 | mdlib --config-file /path/to/config.json 137 | ``` 138 | 139 | Configuration options include: 140 | - Server port 141 | - Bind address 142 | - File watching 143 | - Maximum file size 144 | - Default theme preference 145 | 146 | ### Sample Configuration 147 | 148 | Here's a sample `config.json` file with default settings: 149 | 150 | ```json 151 | { 152 | "port": 3000, 153 | "watch_files": true, 154 | "bind_address": "127.0.0.1", 155 | "max_file_size_mb": 10, 156 | "default_dark_mode": false 157 | } 158 | ``` 159 | 160 | These settings can be customized according to your preferences: 161 | - `port`: The HTTP port for the mdlib server (default: 3000) 162 | - `watch_files`: Whether to watch for file changes and auto-refresh (default: true) 163 | - `bind_address`: The address to bind to (default: "127.0.0.1", use "0.0.0.0" to allow external access) 164 | - `max_file_size_mb`: Maximum file size in megabytes (default: 10) 165 | - `default_dark_mode`: Start in dark mode by default (default: false) 166 | 167 | 168 | ## License 169 | 170 | This project is licensed under the MIT License - see the LICENSE file for details. 171 | 172 | ## Contributing 173 | 174 | Contributions are welcome! Please feel free to submit a Pull Request. -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use std::env; 3 | 4 | mod fs; 5 | mod server; 6 | mod config; 7 | mod embedded; 8 | 9 | #[tokio::main] 10 | async fn main() { 11 | // Print a welcome message 12 | println!("\n======================================================="); 13 | println!("🔍 mdlib - Your Personal Wiki / MD library!"); 14 | println!("======================================================="); 15 | 16 | // Verify that essential static files are embedded 17 | if let Err(missing_files) = embedded::verify_essential_files() { 18 | eprintln!("❌ Error: Missing essential embedded files: {:?}", missing_files); 19 | eprintln!("This indicates a problem with the compiled binary. Please report this issue."); 20 | std::process::exit(1); 21 | } 22 | 23 | // Check for special commands 24 | let args: Vec = env::args().collect(); 25 | 26 | // Handle configuration commands 27 | if args.len() > 1 { 28 | if args[1] == "--config" || args[1] == "-c" { 29 | handle_config_command(&args); 30 | return; 31 | } else if args[1] == "--list-embedded" { 32 | // Debug command to list all embedded files 33 | println!("📁 Listing all embedded files:"); 34 | for file in embedded::list_embedded_files() { 35 | println!(" - {}", file); 36 | } 37 | return; 38 | } 39 | } 40 | 41 | // Check for custom config file 42 | let mut custom_config_path = None; 43 | let mut notes_dir = None; 44 | let mut i = 1; 45 | 46 | while i < args.len() { 47 | if args[i] == "--config-file" || args[i] == "--conf" { 48 | if i + 1 < args.len() { 49 | custom_config_path = Some(PathBuf::from(&args[i + 1])); 50 | i += 2; 51 | } else { 52 | eprintln!("Error: Missing path after --config-file"); 53 | std::process::exit(1); 54 | } 55 | } else { 56 | // Assume it's the notes directory 57 | notes_dir = Some(PathBuf::from(&args[i])); 58 | i += 1; 59 | } 60 | } 61 | 62 | // Get the current directory or from args 63 | let notes_dir = notes_dir.unwrap_or_else(|| 64 | env::current_dir().expect("Failed to get current directory") 65 | ); 66 | 67 | println!("📂 Starting mdlib server in directory: {:?}", notes_dir); 68 | 69 | // Start the web server 70 | let server = server::start_server(notes_dir, custom_config_path).await; 71 | 72 | // Run the server 73 | if let Err(err) = server { 74 | eprintln!("\n❌ Server error: {}", err); 75 | std::process::exit(1); 76 | } 77 | } 78 | 79 | fn handle_config_command(args: &[String]) { 80 | // Get config path 81 | let config_path = dirs::config_dir() 82 | .unwrap_or_else(|| PathBuf::from(".")) 83 | .join("mdlib/config.json"); 84 | 85 | if args.len() == 2 { 86 | // Just --config with no other args - print the path and content if it exists 87 | println!("Config path: {:?}", config_path); 88 | if config_path.exists() { 89 | match std::fs::read_to_string(&config_path) { 90 | Ok(content) => { 91 | println!("Current config:"); 92 | println!("{}", content); 93 | }, 94 | Err(err) => { 95 | println!("Error reading config file: {}", err); 96 | } 97 | } 98 | } else { 99 | println!("Config file does not exist yet."); 100 | println!("It will be created when you run mdlib for the first time."); 101 | println!("You can create it now with `mdlib --config create`"); 102 | } 103 | } else if args.len() >= 3 { 104 | if args[2] == "create" { 105 | // Create a default config 106 | let default_config = config::AppConfig::default(); 107 | if let Some(parent) = config_path.parent() { 108 | if let Err(err) = std::fs::create_dir_all(parent) { 109 | println!("Error creating config directory: {}", err); 110 | return; 111 | } 112 | } 113 | 114 | match serde_json::to_string_pretty(&default_config) { 115 | Ok(config_str) => { 116 | match std::fs::write(&config_path, config_str) { 117 | Ok(_) => { 118 | println!("✅ Created default config at: {:?}", config_path); 119 | }, 120 | Err(err) => { 121 | println!("❌ Error writing config file: {}", err); 122 | } 123 | } 124 | }, 125 | Err(err) => { 126 | println!("Error serializing config: {}", err); 127 | } 128 | } 129 | } else { 130 | println!("Unknown config command: {}", args[2]); 131 | println!("Available commands:"); 132 | println!(" mdlib --config Show config path and current settings"); 133 | println!(" mdlib --config create Create a default config file"); 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/fs.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | use std::fs; 3 | use std::io; 4 | use walkdir::WalkDir; 5 | use anyhow::{Result, Context}; 6 | 7 | /// Represents a markdown file 8 | #[derive(Debug, Clone, serde::Serialize)] 9 | pub struct MarkdownFile { 10 | pub path: PathBuf, 11 | pub name: String, 12 | pub modified: Option, 13 | pub size: u64, 14 | pub tags: Vec, 15 | pub category: Option, 16 | } 17 | 18 | /// Lists all markdown files in the given directory and its subdirectories 19 | pub fn list_markdown_files(dir: &Path) -> Result> { 20 | let mut files = Vec::new(); 21 | 22 | for entry in WalkDir::new(dir) 23 | .follow_links(true) 24 | .into_iter() 25 | .filter_map(|e| e.ok()) 26 | { 27 | let path = entry.path(); 28 | 29 | // Skip directories, only process files 30 | if !path.is_file() { 31 | continue; 32 | } 33 | 34 | // Special case for README files 35 | let file_name = path.file_name() 36 | .and_then(|n| n.to_str()) 37 | .unwrap_or(""); 38 | 39 | if file_name.eq_ignore_ascii_case("readme") || file_name.eq_ignore_ascii_case("readme.md") { 40 | let metadata = fs::metadata(path).context("Failed to read file metadata")?; 41 | let modified = metadata.modified() 42 | .ok() 43 | .and_then(|time| time.duration_since(std::time::UNIX_EPOCH) 44 | .ok() 45 | .map(|d| d.as_secs())); 46 | 47 | // Extract category from the path (relative to base dir) 48 | let category = get_category_from_path(dir, path); 49 | 50 | // Extract tags from file content 51 | let tags = extract_tags_from_file(path).unwrap_or_default(); 52 | 53 | files.push(MarkdownFile { 54 | path: path.to_path_buf(), 55 | name: file_name.to_string(), 56 | modified, 57 | size: metadata.len(), 58 | tags, 59 | category, 60 | }); 61 | continue; 62 | } 63 | 64 | // Check if it's a markdown file with .md extension 65 | if let Some(ext) = path.extension() { 66 | if ext == "md" { 67 | let metadata = fs::metadata(path).context("Failed to read file metadata")?; 68 | let modified = metadata.modified() 69 | .ok() 70 | .and_then(|time| time.duration_since(std::time::UNIX_EPOCH) 71 | .ok() 72 | .map(|d| d.as_secs())); 73 | 74 | let name = path.file_name() 75 | .and_then(|n| n.to_str()) 76 | .unwrap_or("Untitled.md") 77 | .to_string(); 78 | 79 | // Extract category from the path (relative to base dir) 80 | let category = get_category_from_path(dir, path); 81 | 82 | // Extract tags from file content 83 | let tags = extract_tags_from_file(path).unwrap_or_default(); 84 | 85 | files.push(MarkdownFile { 86 | path: path.to_path_buf(), 87 | name, 88 | modified, 89 | size: metadata.len(), 90 | tags, 91 | category, 92 | }); 93 | } 94 | } 95 | } 96 | 97 | // Sort by name 98 | files.sort_by(|a, b| a.name.cmp(&b.name)); 99 | 100 | Ok(files) 101 | } 102 | 103 | /// Reads the content of a markdown file (or README) 104 | pub fn read_markdown_file(path: &Path) -> Result { 105 | fs::read_to_string(path).context("Failed to read file") 106 | } 107 | 108 | /// Writes content to a markdown file 109 | pub fn write_markdown_file(path: &Path, content: &str) -> Result<()> { 110 | // Create parent directories if they don't exist 111 | if let Some(parent) = path.parent() { 112 | fs::create_dir_all(parent).context("Failed to create parent directories")?; 113 | } 114 | 115 | fs::write(path, content).context("Failed to write file") 116 | } 117 | 118 | /// Creates a new markdown file with the given name 119 | pub fn create_markdown_file(dir: &Path, name: &str, content: &str) -> Result { 120 | let file_name = if name.ends_with(".md") { 121 | name.to_string() 122 | } else { 123 | format!("{}.md", name) 124 | }; 125 | 126 | let path = dir.join(file_name); 127 | 128 | write_markdown_file(&path, content)?; 129 | 130 | Ok(path) 131 | } 132 | 133 | /// Deletes a markdown file 134 | pub fn delete_markdown_file(path: &Path) -> Result<()> { 135 | fs::remove_file(path).context("Failed to delete file") 136 | } 137 | 138 | // /// Checks if a path exists 139 | 140 | // pub fn path_exists(path: &Path) -> bool { 141 | // path.exists() 142 | // } 143 | 144 | /// Gets the relative path from base directory 145 | pub fn get_relative_path(base: &Path, path: &Path) -> Result { 146 | match path.strip_prefix(base) { 147 | Ok(rel_path) => Ok(rel_path.to_path_buf()), 148 | Err(_) => { 149 | // If normal strip_prefix fails (which can happen with different 150 | // path representations), try with canonical paths 151 | let canonical_base = base.canonicalize() 152 | .context("Failed to canonicalize base path")?; 153 | let canonical_path = path.canonicalize() 154 | .context("Failed to canonicalize file path")?; 155 | 156 | canonical_path.strip_prefix(canonical_base) 157 | .map(|p| p.to_path_buf()) 158 | .map_err(|e| io::Error::new(io::ErrorKind::NotFound, e).into()) 159 | } 160 | } 161 | } 162 | 163 | /// Extract tags from a markdown file 164 | fn extract_tags_from_file(path: &Path) -> Result> { 165 | let content = fs::read_to_string(path).context("Failed to read file for tags")?; 166 | extract_tags_from_content(&content) 167 | } 168 | 169 | /// Extract tags from a markdown content string 170 | pub fn extract_tags_from_content(content: &str) -> Result> { 171 | // Look for tags in the format #tag or tags: [tag1, tag2, tag3] 172 | let mut tags = Vec::new(); 173 | 174 | // First check for a YAML frontmatter section with tags 175 | if let Some(frontmatter) = extract_frontmatter(content) { 176 | // Look for a tags: line in the frontmatter 177 | for line in frontmatter.lines() { 178 | let line = line.trim(); 179 | if let Some(stripped) = line.strip_prefix("tags:") { 180 | // Parse the tags list 181 | let tags_part = stripped.trim(); 182 | if tags_part.starts_with('[') && tags_part.ends_with(']') { 183 | // Format: tags: [tag1, tag2, tag3] 184 | let tags_list = &tags_part[1..tags_part.len()-1]; 185 | tags.extend(tags_list.split(',') 186 | .map(|s| s.trim().to_string()) 187 | .filter(|s| !s.is_empty())); 188 | } else { 189 | // Format: tags: tag1 tag2 tag3 190 | tags.extend(tags_part.split_whitespace() 191 | .map(|s| s.trim().to_string()) 192 | .filter(|s| !s.is_empty())); 193 | } 194 | } 195 | } 196 | } 197 | 198 | // Then scan for hashtags in the content 199 | for word in content.split_whitespace() { 200 | if word.starts_with('#') && word.len() > 1 { 201 | // Extract just the tag part (without #) 202 | let tag = word[1..].trim_end_matches(|c: char| !c.is_alphanumeric()).to_string(); 203 | if !tag.is_empty() && !tags.contains(&tag) { 204 | tags.push(tag); 205 | } 206 | } 207 | } 208 | 209 | Ok(tags) 210 | } 211 | 212 | /// Extract YAML frontmatter from markdown content if present 213 | fn extract_frontmatter(content: &str) -> Option { 214 | let trimmed = content.trim_start(); 215 | if let Some(stripped) = trimmed.strip_prefix("---") { 216 | if let Some(end_index) = stripped.find("---") { 217 | return Some(stripped[..end_index].trim().to_string()); 218 | } 219 | } 220 | None 221 | } 222 | 223 | /// Get the category from a file path (relative to base directory) 224 | fn get_category_from_path(base_dir: &Path, file_path: &Path) -> Option { 225 | if let Ok(rel_path) = file_path.strip_prefix(base_dir) { 226 | let parent = rel_path.parent()?; 227 | if parent.as_os_str().is_empty() { 228 | return None; // File is directly in the base directory 229 | } 230 | return Some(parent.to_string_lossy().into_owned()); 231 | } 232 | None 233 | } 234 | 235 | /// Creates a new category directory 236 | pub fn create_category(dir: &Path, category_name: &str) -> Result { 237 | let path = dir.join(category_name); 238 | 239 | // Log that we're creating the category 240 | println!("Creating category directory at: {:?}", path); 241 | 242 | // Create the directory if it doesn't exist 243 | if !path.exists() { 244 | fs::create_dir_all(&path).context("Failed to create category directory")?; 245 | println!("Category directory created successfully"); 246 | } else { 247 | println!("Category directory already exists"); 248 | } 249 | 250 | Ok(path) 251 | } 252 | 253 | /// Add tags to a markdown file 254 | pub fn add_tags_to_file(path: &Path, tags: &[String]) -> Result<()> { 255 | let content = fs::read_to_string(path).context("Failed to read file")?; 256 | let new_content = add_tags_to_content(&content, tags)?; 257 | fs::write(path, new_content).context("Failed to write file") 258 | } 259 | 260 | /// Add tags to markdown content 261 | pub fn add_tags_to_content(content: &str, tags: &[String]) -> Result { 262 | if tags.is_empty() { 263 | return Ok(content.to_string()); 264 | } 265 | 266 | let mut existing_tags = extract_tags_from_content(content)?; 267 | let mut added_any = false; 268 | 269 | // Add new tags if they don't already exist 270 | for tag in tags { 271 | if !existing_tags.contains(tag) { 272 | existing_tags.push(tag.clone()); 273 | added_any = true; 274 | } 275 | } 276 | 277 | if !added_any { 278 | // No new tags to add 279 | return Ok(content.to_string()); 280 | } 281 | 282 | // Check if there's frontmatter 283 | if let Some(frontmatter) = extract_frontmatter(content) { 284 | // Update the frontmatter with the new tags 285 | let mut updated_frontmatter = String::new(); 286 | let mut has_tags_line = false; 287 | 288 | for line in frontmatter.lines() { 289 | if line.trim().starts_with("tags:") { 290 | // Replace the tags line 291 | updated_frontmatter.push_str(&format!("tags: [{}]\n", existing_tags.join(", "))); 292 | has_tags_line = true; 293 | } else { 294 | updated_frontmatter.push_str(line); 295 | updated_frontmatter.push('\n'); 296 | } 297 | } 298 | 299 | if !has_tags_line { 300 | // Add a new tags line 301 | updated_frontmatter.push_str(&format!("tags: [{}]\n", existing_tags.join(", "))); 302 | } 303 | 304 | // Replace the frontmatter in the content 305 | let start_idx = content.find("---").unwrap_or(0); 306 | let end_idx = content[start_idx + 3..].find("---").map(|i| start_idx + i + 6).unwrap_or(start_idx); 307 | let mut new_content = String::new(); 308 | new_content.push_str("---\n"); 309 | new_content.push_str(&updated_frontmatter); 310 | new_content.push_str("---\n"); 311 | new_content.push_str(&content[end_idx..]); 312 | 313 | Ok(new_content) 314 | } else { 315 | // No frontmatter, add one 316 | let mut new_content = String::new(); 317 | new_content.push_str("---\n"); 318 | new_content.push_str(&format!("tags: [{}]\n", existing_tags.join(", "))); 319 | new_content.push_str("---\n\n"); 320 | new_content.push_str(content); 321 | 322 | Ok(new_content) 323 | } 324 | } 325 | 326 | /// Remove tags from a markdown file 327 | pub fn remove_tags_from_file(path: &Path, tags_to_remove: &[String]) -> Result<()> { 328 | let content = fs::read_to_string(path).context("Failed to read file")?; 329 | let new_content = remove_tags_from_content(&content, tags_to_remove)?; 330 | fs::write(path, new_content).context("Failed to write file") 331 | } 332 | 333 | /// Remove tags from markdown content 334 | pub fn remove_tags_from_content(content: &str, tags_to_remove: &[String]) -> Result { 335 | if tags_to_remove.is_empty() { 336 | return Ok(content.to_string()); 337 | } 338 | 339 | let mut existing_tags = extract_tags_from_content(content)?; 340 | let initial_count = existing_tags.len(); 341 | 342 | // Remove specified tags 343 | existing_tags.retain(|tag| !tags_to_remove.contains(tag)); 344 | 345 | // If no tags were removed, return original content 346 | if existing_tags.len() == initial_count { 347 | return Ok(content.to_string()); 348 | } 349 | 350 | // Check if there's frontmatter 351 | if let Some(frontmatter) = extract_frontmatter(content) { 352 | // Update the frontmatter with the remaining tags 353 | let mut updated_frontmatter = String::new(); 354 | let mut has_tags_line = false; 355 | 356 | for line in frontmatter.lines() { 357 | if line.trim().starts_with("tags:") { 358 | // Replace the tags line, or remove it if no tags left 359 | if !existing_tags.is_empty() { 360 | updated_frontmatter.push_str(&format!("tags: [{}]\n", existing_tags.join(", "))); 361 | } 362 | has_tags_line = true; 363 | } else { 364 | updated_frontmatter.push_str(line); 365 | updated_frontmatter.push('\n'); 366 | } 367 | } 368 | 369 | // If there were no tags line but we have tags, add it 370 | if !has_tags_line && !existing_tags.is_empty() { 371 | updated_frontmatter.push_str(&format!("tags: [{}]\n", existing_tags.join(", "))); 372 | } 373 | 374 | // Replace the frontmatter in the content 375 | let start_idx = content.find("---").unwrap_or(0); 376 | let end_idx = content[start_idx + 3..].find("---").map(|i| start_idx + i + 6).unwrap_or(start_idx); 377 | let mut new_content = String::new(); 378 | new_content.push_str("---\n"); 379 | new_content.push_str(&updated_frontmatter); 380 | new_content.push_str("---\n"); 381 | new_content.push_str(&content[end_idx..]); 382 | 383 | Ok(new_content) 384 | } else if !existing_tags.is_empty() { 385 | // No frontmatter but we have tags, add a new frontmatter 386 | let mut new_content = String::new(); 387 | new_content.push_str("---\n"); 388 | new_content.push_str(&format!("tags: [{}]\n", existing_tags.join(", "))); 389 | new_content.push_str("---\n\n"); 390 | new_content.push_str(content); 391 | 392 | Ok(new_content) 393 | } else { 394 | // No frontmatter and no tags left, return original content 395 | Ok(content.to_string()) 396 | } 397 | } 398 | 399 | /// Delete a category directory 400 | pub fn delete_category(dir: &Path, category_name: &str) -> Result<()> { 401 | let category_path = dir.join(category_name); 402 | 403 | // Ensure the path exists and is a directory 404 | if !category_path.exists() { 405 | return Err(anyhow::anyhow!("Category directory doesn't exist")); 406 | } 407 | 408 | if !category_path.is_dir() { 409 | return Err(anyhow::anyhow!("The specified path is not a directory")); 410 | } 411 | 412 | // Check if the directory is empty 413 | let is_empty = category_path.read_dir()?.next().is_none(); 414 | 415 | if !is_empty { 416 | return Err(anyhow::anyhow!("Cannot delete non-empty category. Move or delete files first.")); 417 | } 418 | 419 | // Delete the directory 420 | fs::remove_dir(category_path).context("Failed to delete category directory") 421 | } -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | mdlib 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 |
24 |
25 | 30 |

mdlib

31 |
32 |
33 |
34 | 40 | 41 | 42 | 43 |
44 | 49 | 55 |
56 |
57 |
58 | 59 |
60 | 61 | 141 | 142 | 143 |
144 | 145 |
146 |
147 | 148 | 149 | 150 |
151 |

No Notes Selected

152 |

Select a note from the sidebar to view it, or create a new note to build your personal wiki.

153 | 159 |
160 | 161 | 162 | 261 |
262 |
263 | 264 | 265 | 293 | 294 | 295 | 312 | 313 | 314 | 315 | -------------------------------------------------------------------------------- /static/css/styles.css: -------------------------------------------------------------------------------- 1 | /* Custom Styles for mdlib */ 2 | 3 | /* Base styles */ 4 | :root { 5 | --primary-color: #4f46e5; 6 | --primary-hover: #4338ca; 7 | --primary-light: #e0e7ff; 8 | --success-color: #10b981; 9 | --danger-color: #ef4444; 10 | --text-color: #111827; 11 | --text-muted: #6b7280; 12 | --border-color: #e5e7eb; 13 | --bg-color: #f9fafb; 14 | --card-bg: #ffffff; 15 | --transition-speed: 0.2s; 16 | --sidebar-width: 300px; /* Fixed sidebar width */ 17 | } 18 | 19 | body { 20 | font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 21 | transition: background-color 0.3s ease, color 0.3s ease; 22 | } 23 | 24 | /* Markdown Preview Styles */ 25 | .markdown-body { 26 | font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 27 | line-height: 1.7; 28 | color: var(--text-color); 29 | max-width: 100%; 30 | } 31 | 32 | .markdown-body h1, 33 | .markdown-body h2, 34 | .markdown-body h3, 35 | .markdown-body h4, 36 | .markdown-body h5, 37 | .markdown-body h6 { 38 | margin-top: 1.5em; 39 | margin-bottom: 0.75em; 40 | font-weight: 600; 41 | line-height: 1.3; 42 | } 43 | 44 | .markdown-body h1 { 45 | font-size: 2em; 46 | padding-bottom: 0.3em; 47 | border-bottom: 1px solid var(--border-color); 48 | color: #2d3748; 49 | } 50 | 51 | .markdown-body h2 { 52 | font-size: 1.5em; 53 | padding-bottom: 0.3em; 54 | border-bottom: 1px solid var(--border-color); 55 | color: #2d3748; 56 | } 57 | 58 | .markdown-body h3 { 59 | font-size: 1.25em; 60 | color: #2d3748; 61 | } 62 | 63 | .markdown-body h4 { 64 | font-size: 1em; 65 | color: #2d3748; 66 | } 67 | 68 | .markdown-body h5 { 69 | font-size: 0.875em; 70 | color: #4a5568; 71 | } 72 | 73 | .markdown-body h6 { 74 | font-size: 0.85em; 75 | color: #718096; 76 | } 77 | 78 | .markdown-body p { 79 | margin-top: 0; 80 | margin-bottom: 1.2em; 81 | } 82 | 83 | .markdown-body blockquote { 84 | padding: 0.75em 1.2em; 85 | color: #718096; 86 | border-left: 0.25em solid #e2e8f0; 87 | margin: 0 0 1.2em 0; 88 | background-color: #f8fafc; 89 | border-radius: 0.25em; 90 | } 91 | 92 | .markdown-body ul, 93 | .markdown-body ol { 94 | padding-left: 2em; 95 | margin-top: 0; 96 | margin-bottom: 1.2em; 97 | } 98 | 99 | .markdown-body li + li { 100 | margin-top: 0.25em; 101 | } 102 | 103 | .markdown-body code { 104 | padding: 0.2em 0.4em; 105 | margin: 0; 106 | font-size: 85%; 107 | background-color: #f1f5f9; 108 | border-radius: 0.25em; 109 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 110 | } 111 | 112 | .markdown-body pre { 113 | word-wrap: normal; 114 | padding: 1rem; 115 | overflow: auto; 116 | line-height: 1.45; 117 | background-color: #f8fafc; 118 | border-radius: 0.375rem; 119 | margin-bottom: 1.2em; 120 | box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05); 121 | } 122 | 123 | .markdown-body pre code { 124 | padding: 0; 125 | margin: 0; 126 | background-color: transparent; 127 | border: 0; 128 | word-break: normal; 129 | white-space: pre; 130 | display: inline; 131 | overflow: visible; 132 | line-height: inherit; 133 | word-wrap: normal; 134 | } 135 | 136 | .markdown-body img { 137 | max-width: 100%; 138 | box-sizing: content-box; 139 | background-color: #fff; 140 | border-radius: 0.375rem; 141 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 142 | } 143 | 144 | .markdown-body table { 145 | border-spacing: 0; 146 | border-collapse: collapse; 147 | margin-top: 0; 148 | margin-bottom: 16px; 149 | width: 100%; 150 | overflow: auto; 151 | border-radius: 0.375rem; 152 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); 153 | } 154 | 155 | .markdown-body table th { 156 | font-weight: 600; 157 | padding: 0.75rem 1rem; 158 | border: 1px solid #e2e8f0; 159 | background-color: #f8fafc; 160 | } 161 | 162 | .markdown-body table td { 163 | padding: 0.75rem 1rem; 164 | border: 1px solid #e2e8f0; 165 | } 166 | 167 | .markdown-body table tr { 168 | background-color: #fff; 169 | border-top: 1px solid #e2e8f0; 170 | } 171 | 172 | .markdown-body table tr:nth-child(2n) { 173 | background-color: #f8fafc; 174 | } 175 | 176 | .markdown-body hr { 177 | height: 1px; 178 | padding: 0; 179 | margin: 24px 0; 180 | background-color: #e2e8f0; 181 | border: 0; 182 | } 183 | 184 | .markdown-body a { 185 | color: var(--primary-color); 186 | text-decoration: none; 187 | transition: color var(--transition-speed) ease; 188 | } 189 | 190 | .markdown-body a:hover { 191 | text-decoration: underline; 192 | color: var(--primary-hover); 193 | } 194 | 195 | /* Dark Mode Styles */ 196 | .dark-mode { 197 | --primary-color: #818cf8; 198 | --primary-hover: #6366f1; 199 | --primary-light: #312e81; 200 | --text-color: #f1f5f9; 201 | --text-muted: #94a3b8; 202 | --border-color: #334155; 203 | --bg-color: #111827; 204 | --card-bg: #1e293b; 205 | 206 | background-color: var(--bg-color); 207 | color: var(--text-color); 208 | } 209 | 210 | .dark-mode .bg-white { 211 | background-color: var(--card-bg) !important; 212 | } 213 | 214 | .dark-mode .bg-gray-50 { 215 | background-color: #1f2937 !important; 216 | } 217 | 218 | .dark-mode .text-gray-700, 219 | .dark-mode .text-gray-800, 220 | .dark-mode .text-gray-900 { 221 | color: var(--text-color) !important; 222 | } 223 | 224 | .dark-mode .text-gray-500, 225 | .dark-mode .text-gray-600 { 226 | color: var(--text-muted) !important; 227 | } 228 | 229 | .dark-mode .border-gray-200, 230 | .dark-mode .border-gray-300 { 231 | border-color: var(--border-color) !important; 232 | } 233 | 234 | /* Fix for input fields in dark mode */ 235 | .dark-mode input[type="text"], 236 | .dark-mode textarea, 237 | .dark-mode #search-input { 238 | background-color: #2d3748; 239 | color: #e2e8f0; 240 | border-color: var(--border-color); 241 | caret-color: #e2e8f0; /* This ensures cursor visibility */ 242 | } 243 | 244 | .dark-mode #new-note-name::placeholder, 245 | .dark-mode #search-input::placeholder { 246 | color: #94a3b8; 247 | } 248 | 249 | /* Make Cancel button more visible in dark mode */ 250 | .dark-mode #btn-modal-cancel { 251 | background-color: #374151; 252 | color: #e2e8f0; 253 | } 254 | 255 | .dark-mode #btn-modal-cancel:hover { 256 | background-color: #4b5563; 257 | } 258 | 259 | .dark-mode #editor { 260 | background-color: #1a202c; 261 | color: var(--text-color); 262 | border-color: var(--border-color); 263 | } 264 | 265 | .dark-mode .markdown-body h1, 266 | .dark-mode .markdown-body h2, 267 | .dark-mode .markdown-body h3, 268 | .dark-mode .markdown-body h4 { 269 | color: #e2e8f0; 270 | } 271 | 272 | .dark-mode .markdown-body h5, 273 | .dark-mode .markdown-body h6 { 274 | color: #cbd5e1; 275 | } 276 | 277 | .dark-mode .markdown-body h1, 278 | .dark-mode .markdown-body h2 { 279 | border-bottom-color: var(--border-color); 280 | } 281 | 282 | .dark-mode .markdown-body blockquote { 283 | color: #cbd5e1; 284 | border-left-color: #475569; 285 | background-color: #1e293b; 286 | } 287 | 288 | .dark-mode .markdown-body code { 289 | background-color: #1e293b; 290 | } 291 | 292 | .dark-mode .markdown-body pre { 293 | background-color: #0f172a; 294 | } 295 | 296 | .dark-mode .markdown-body table th, 297 | .dark-mode .markdown-body table td { 298 | border-color: var(--border-color); 299 | } 300 | 301 | .dark-mode .markdown-body table th { 302 | background-color: #1e293b; 303 | } 304 | 305 | .dark-mode .markdown-body table tr { 306 | background-color: var(--card-bg); 307 | border-top-color: var(--border-color); 308 | } 309 | 310 | .dark-mode .markdown-body table tr:nth-child(2n) { 311 | background-color: #0f172a; 312 | } 313 | 314 | .dark-mode .markdown-body hr { 315 | background-color: var(--border-color); 316 | } 317 | 318 | .dark-mode .markdown-body a { 319 | color: #93c5fd; 320 | } 321 | 322 | .dark-mode .markdown-body a:hover { 323 | color: #bfdbfe; 324 | } 325 | 326 | /* Improved Sidebar Styles */ 327 | #sidebar { 328 | width: 300px; /* Fixed width value */ 329 | height: calc(100vh - 4rem); /* Adjust based on header height */ 330 | position: sticky; 331 | top: 4rem; 332 | overflow: hidden; 333 | display: flex; 334 | flex-direction: column; 335 | transition: none; /* Remove width transition */ 336 | flex-shrink: 0; /* Prevent sidebar from shrinking */ 337 | } 338 | 339 | #sidebar > div { 340 | height: 100%; 341 | overflow-y: auto; 342 | overflow-x: hidden; 343 | padding-right: 6px; /* Compensate for scrollbar to avoid layout shift */ 344 | scrollbar-width: thin; 345 | } 346 | 347 | /* File List with Improved Handling for Long Names */ 348 | #file-list { 349 | max-height: 100%; 350 | overflow-y: auto; 351 | margin-bottom: 1rem; 352 | } 353 | 354 | #file-list li { 355 | padding: 0.5rem 0.75rem; 356 | cursor: pointer; 357 | border-radius: 0.375rem; 358 | margin-bottom: 0.25rem; 359 | display: flex; 360 | align-items: center; 361 | transition: background-color 0.2s, color 0.2s; 362 | position: relative; 363 | color: var(--text-color); 364 | box-sizing: border-box; 365 | } 366 | 367 | #file-list li .note-icon { 368 | margin-right: 0.5rem !important; 369 | flex-shrink: 0; 370 | width: 20px; 371 | text-align: center; 372 | display: inline-flex !important; 373 | justify-content: center; 374 | align-items: center; 375 | } 376 | 377 | #file-list li .note-title { 378 | flex: 1 !important; 379 | white-space: nowrap; 380 | overflow: hidden; 381 | text-overflow: ellipsis; 382 | padding-left: 2px; 383 | display: inline-block; 384 | text-align: left !important; 385 | min-width: 0 !important; 386 | } 387 | 388 | /* Category List Improvements */ 389 | #category-list { 390 | margin-bottom: 1rem; 391 | max-height: 200px; 392 | overflow-y: auto; 393 | overflow-x: hidden; 394 | } 395 | 396 | .category-item { 397 | display: flex; 398 | align-items: center; 399 | padding: 0.5rem 0.75rem; 400 | border-radius: 0.375rem; 401 | cursor: pointer; 402 | margin-bottom: 0.25rem; 403 | transition: background-color 0.2s; 404 | position: relative; 405 | text-align: left; 406 | justify-content: flex-start; 407 | } 408 | 409 | .category-name { 410 | margin-left: 0.5rem; 411 | flex: 1; 412 | white-space: nowrap; 413 | overflow: hidden; 414 | text-overflow: ellipsis; 415 | } 416 | 417 | /* Tags Section Improvements */ 418 | #tags-container { 419 | display: flex; 420 | flex-wrap: wrap; 421 | align-items: flex-start; 422 | justify-content: flex-start; 423 | text-align: left; 424 | gap: 0.5rem; 425 | max-height: 150px; 426 | overflow-y: auto; 427 | padding-bottom: 0.5rem; 428 | } 429 | 430 | .tag { 431 | display: inline-flex; 432 | align-items: center; 433 | justify-content: flex-start; 434 | text-align: left; 435 | padding: 0.25rem 0.6rem; 436 | border-radius: 9999px; 437 | font-size: 0.75rem; 438 | background-color: var(--primary-light); 439 | color: var(--primary-color); 440 | max-width: 150px; 441 | white-space: nowrap; 442 | overflow: hidden; 443 | text-overflow: ellipsis; 444 | transition: background-color var(--transition-speed); 445 | } 446 | 447 | /* Enhanced Scrollbar Styles */ 448 | ::-webkit-scrollbar { 449 | width: 6px; 450 | height: 6px; 451 | } 452 | 453 | ::-webkit-scrollbar-track { 454 | background: transparent; 455 | } 456 | 457 | ::-webkit-scrollbar-thumb { 458 | background: #d1d5db; 459 | border-radius: 6px; 460 | } 461 | 462 | ::-webkit-scrollbar-thumb:hover { 463 | background: #9ca3af; 464 | } 465 | 466 | /* Custom scrollbar for Firefox */ 467 | * { 468 | scrollbar-width: thin; 469 | scrollbar-color: #d1d5db transparent; 470 | } 471 | 472 | /* Responsive sidebar handling */ 473 | @media (max-width: 768px) { 474 | #sidebar { 475 | position: fixed; 476 | z-index: 40; 477 | width: 300px; /* Keep the same fixed width */ 478 | height: 100vh; 479 | top: 0; 480 | left: 0; 481 | transform: translateX(-100%); 482 | transition: transform 0.3s ease; 483 | } 484 | 485 | #sidebar.active { 486 | transform: translateX(0); 487 | } 488 | 489 | .sidebar-toggle { 490 | display: block !important; 491 | } 492 | } 493 | 494 | /* Remove breakpoint width adjustments */ 495 | @media (min-width: 769px) and (max-width: 1280px) { 496 | /* Remove the root variable override */ 497 | } 498 | 499 | @media (min-width: 1281px) { 500 | /* Remove the root variable override */ 501 | } 502 | 503 | /* Fix for long context menu items */ 504 | #context-menu { 505 | max-width: 250px; 506 | } 507 | 508 | #context-menu > div { 509 | white-space: nowrap; 510 | overflow: hidden; 511 | text-overflow: ellipsis; 512 | } 513 | 514 | /* Add toast container to prevent layout shift */ 515 | .toast-container { 516 | position: fixed; 517 | bottom: 1rem; 518 | right: 1rem; 519 | z-index: 100; 520 | max-width: 320px; 521 | display: flex; 522 | flex-direction: column; 523 | gap: 0.5rem; 524 | } 525 | 526 | /* Modern Buttons */ 527 | button { 528 | transition: all var(--transition-speed) ease; 529 | } 530 | 531 | /* Transitions */ 532 | .fade-enter { 533 | opacity: 0; 534 | } 535 | 536 | .fade-enter-active { 537 | opacity: 1; 538 | transition: opacity var(--transition-speed) ease; 539 | } 540 | 541 | .fade-exit { 542 | opacity: 1; 543 | } 544 | 545 | .fade-exit-active { 546 | opacity: 0; 547 | transition: opacity var(--transition-speed) ease; 548 | } 549 | 550 | /* Modern scrollbars */ 551 | ::-webkit-scrollbar { 552 | width: 8px; 553 | height: 8px; 554 | } 555 | 556 | ::-webkit-scrollbar-track { 557 | background: transparent; 558 | } 559 | 560 | ::-webkit-scrollbar-thumb { 561 | background: #d1d5db; 562 | border-radius: 4px; 563 | } 564 | 565 | ::-webkit-scrollbar-thumb:hover { 566 | background: #9ca3af; 567 | } 568 | 569 | .dark-mode ::-webkit-scrollbar-thumb { 570 | background: #4b5563; 571 | } 572 | 573 | .dark-mode ::-webkit-scrollbar-thumb:hover { 574 | background: #6b7280; 575 | } 576 | 577 | /* Tags styling */ 578 | .tag:hover { 579 | background-color: #d1d5db; 580 | } 581 | 582 | .tag.active { 583 | background-color: #4f46e5; 584 | color: white; 585 | } 586 | 587 | /* Category styling */ 588 | .category-item:hover { 589 | background-color: #f3f4f6; 590 | } 591 | 592 | .category-item.active { 593 | background-color: #e5edff; 594 | color: #4f46e5; 595 | font-weight: 500; 596 | } 597 | 598 | .category-delete-btn { 599 | visibility: hidden; 600 | opacity: 0; 601 | transition: opacity 0.2s ease; 602 | } 603 | 604 | .category-item:hover .category-delete-btn { 605 | visibility: visible; 606 | opacity: 1; 607 | } 608 | 609 | .category-item svg { 610 | margin-right: 0.5rem; 611 | flex-shrink: 0; 612 | } 613 | 614 | .category-item span { 615 | white-space: nowrap; 616 | overflow: hidden; 617 | text-overflow: ellipsis; 618 | } 619 | 620 | .category-add-form { 621 | display: flex; 622 | margin-bottom: 1rem; 623 | border: 1px solid #d1d5db; 624 | border-radius: 0.375rem; 625 | overflow: hidden; 626 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); 627 | } 628 | 629 | .category-input { 630 | flex-grow: 1; 631 | padding: 0.5rem 0.75rem; 632 | border: none; 633 | outline: none; 634 | font-size: 0.875rem; 635 | } 636 | 637 | .category-input:focus { 638 | box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.2); 639 | } 640 | 641 | .category-add-button { 642 | background-color: #4f46e5; 643 | color: white; 644 | padding: 0.5rem 0.75rem; 645 | font-size: 0.875rem; 646 | border: none; 647 | cursor: pointer; 648 | transition: background-color 0.2s; 649 | } 650 | 651 | .category-add-button:hover { 652 | background-color: #4338ca; 653 | } 654 | 655 | .category-badge { 656 | display: inline-flex; 657 | align-items: center; 658 | background-color: #eef2ff; 659 | color: #4f46e5; 660 | padding: 0.25rem 0.5rem; 661 | border-radius: 9999px; 662 | font-size: 0.75rem; 663 | margin-right: 0.5rem; 664 | margin-bottom: 0.5rem; 665 | } 666 | 667 | .category-badge svg { 668 | margin-right: 0.25rem; 669 | width: 0.75rem; 670 | height: 0.75rem; 671 | } 672 | 673 | .category-section-title { 674 | display: flex; 675 | align-items: center; 676 | justify-content: space-between; 677 | margin-bottom: 1rem; 678 | } 679 | 680 | .category-section-title h2 { 681 | margin: 0; 682 | font-size: 1.125rem; 683 | font-weight: 600; 684 | color: #1f2937; 685 | } 686 | 687 | .category-empty { 688 | text-align: left; 689 | padding: 1rem; 690 | color: #6b7280; 691 | font-style: italic; 692 | font-size: 0.875rem; 693 | } 694 | 695 | /* New category modal */ 696 | .category-modal { 697 | background-color: white; 698 | border-radius: 0.5rem; 699 | padding: 1.5rem; 700 | max-width: 24rem; 701 | width: 100%; 702 | box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); 703 | } 704 | 705 | .category-modal-title { 706 | font-size: 1.25rem; 707 | font-weight: 600; 708 | margin-bottom: 1rem; 709 | color: #1f2937; 710 | } 711 | 712 | .category-modal-button { 713 | padding: 0.5rem 1rem; 714 | border-radius: 0.375rem; 715 | font-weight: 500; 716 | font-size: 0.875rem; 717 | transition: all 0.2s; 718 | } 719 | 720 | .category-modal-confirm { 721 | background-color: #4f46e5; 722 | color: white; 723 | } 724 | 725 | .category-modal-confirm:hover { 726 | background-color: #4338ca; 727 | } 728 | 729 | .category-modal-cancel { 730 | background-color: #f3f4f6; 731 | color: #1f2937; 732 | } 733 | 734 | .category-modal-cancel:hover { 735 | background-color: #e5e7eb; 736 | } 737 | 738 | /* Context menu styling */ 739 | #context-menu { 740 | min-width: 160px; 741 | box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); 742 | border: 1px solid #e5e7eb; 743 | border-radius: 0.375rem; 744 | } 745 | 746 | #context-menu > div { 747 | padding: 0.5rem 1rem; 748 | cursor: pointer; 749 | } 750 | 751 | #context-menu > div:hover { 752 | background-color: #eef2ff; 753 | } 754 | 755 | #context-menu > div:first-child { 756 | border-top-left-radius: 0.375rem; 757 | border-top-right-radius: 0.375rem; 758 | } 759 | 760 | #context-menu > div:last-child { 761 | border-bottom-left-radius: 0.375rem; 762 | border-bottom-right-radius: 0.375rem; 763 | } 764 | 765 | /* File item styling with tags */ 766 | .file-item { 767 | display: flex; 768 | justify-content: space-between; 769 | align-items: center; 770 | padding: 0.375rem 0.5rem; 771 | border-radius: 0.25rem; 772 | transition: all 0.2s; 773 | } 774 | 775 | .file-item:hover { 776 | background-color: #eef2ff; 777 | } 778 | 779 | .file-item .tags-indicator { 780 | font-size: 0.75rem; 781 | background-color: #e5e7eb; 782 | color: #4b5563; 783 | padding: 0.125rem 0.375rem; 784 | border-radius: 0.25rem; 785 | } 786 | 787 | /* Sidebar sections */ 788 | .sidebar-section { 789 | margin-bottom: 1.5rem; 790 | } 791 | 792 | .sidebar-section-title { 793 | font-size: 1.125rem; 794 | font-weight: 600; 795 | margin-bottom: 0.75rem; 796 | color: #4b5563; 797 | display: flex; 798 | align-items: center; 799 | } 800 | 801 | .sidebar-section-title svg { 802 | margin-right: 0.5rem; 803 | width: 1.25rem; 804 | height: 1.25rem; 805 | color: #4f46e5; 806 | } 807 | 808 | /* Toast notification */ 809 | .toast { 810 | padding: 0.75rem 1rem; 811 | border-radius: 0.375rem; 812 | background-color: var(--card-bg); 813 | box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); 814 | color: var(--text-color); 815 | font-size: 0.875rem; 816 | opacity: 0; 817 | transform: translateY(10px); 818 | transition: opacity 0.3s ease, transform 0.3s ease; 819 | display: flex; 820 | align-items: center; 821 | } 822 | 823 | .toast-success { 824 | border-left: 4px solid var(--success-color); 825 | } 826 | 827 | .toast-error { 828 | border-left: 4px solid var(--danger-color); 829 | } 830 | 831 | /* Tag hover styles */ 832 | .tag-item { 833 | position: relative; 834 | display: inline-flex; 835 | align-items: center; 836 | } 837 | 838 | .tag-delete-btn { 839 | margin-left: 0.25rem; 840 | visibility: hidden; 841 | opacity: 0; 842 | transition: opacity 0.2s ease; 843 | } 844 | 845 | .tag-item:hover .tag-delete-btn { 846 | visibility: visible; 847 | opacity: 1; 848 | } 849 | 850 | /* Toast notifications */ 851 | .toast-container { 852 | position: fixed; 853 | bottom: 1rem; 854 | right: 1rem; 855 | z-index: 100; 856 | max-width: 320px; 857 | display: flex; 858 | flex-direction: column; 859 | gap: 0.5rem; 860 | } 861 | 862 | .toast { 863 | padding: 0.75rem 1rem; 864 | border-radius: 0.375rem; 865 | background-color: var(--card-bg); 866 | box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); 867 | color: var(--text-color); 868 | font-size: 0.875rem; 869 | opacity: 0; 870 | transform: translateY(10px); 871 | transition: opacity 0.3s ease, transform 0.3s ease; 872 | display: flex; 873 | align-items: center; 874 | } 875 | 876 | .toast-success { 877 | border-left: 4px solid var(--success-color); 878 | } 879 | 880 | .toast-error { 881 | border-left: 4px solid var(--danger-color); 882 | } 883 | 884 | /* Context menu styles */ 885 | #context-menu { 886 | box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); 887 | border: 1px solid var(--border-color); 888 | max-width: 200px; 889 | } 890 | 891 | #context-menu > div { 892 | padding: 0.5rem 1rem; 893 | cursor: pointer; 894 | transition: background-color var(--transition-speed); 895 | white-space: nowrap; 896 | overflow: hidden; 897 | text-overflow: ellipsis; 898 | } 899 | 900 | #context-menu > div:hover { 901 | background-color: #f3f4f6; 902 | } 903 | 904 | .dark-mode #context-menu { 905 | background-color: #1e293b; 906 | border-color: #334155; 907 | } 908 | 909 | .dark-mode #context-menu > div:hover { 910 | background-color: #374151; 911 | } 912 | 913 | /* Enhanced File List */ 914 | .file-list-container { 915 | overflow-y: auto; 916 | max-height: calc(100vh - 450px); 917 | min-height: 200px; 918 | border-radius: 0.375rem; 919 | scrollbar-width: thin; 920 | } 921 | 922 | #file-list { 923 | position: relative; 924 | } 925 | 926 | #file-list li { 927 | display: flex; 928 | align-items: center; 929 | padding: 0.5rem 0.75rem; 930 | border-radius: 0.375rem; 931 | cursor: pointer; 932 | margin-bottom: 0.25rem; 933 | font-size: 0.9rem; 934 | transition: background-color var(--transition-speed); 935 | position: relative; 936 | color: var(--text-color); 937 | } 938 | 939 | #file-list li .note-icon { 940 | margin-right: 0.5rem; 941 | flex-shrink: 0; 942 | } 943 | 944 | #file-list li .note-title { 945 | flex: 1; 946 | white-space: nowrap; 947 | overflow: hidden; 948 | text-overflow: ellipsis; 949 | } 950 | 951 | #file-list li:hover { 952 | background-color: #f3f4f6; 953 | } 954 | 955 | .dark-mode #file-list li:hover { 956 | background-color: #374151; 957 | } 958 | 959 | #file-list li.active { 960 | background-color: var(--primary-light); 961 | color: var(--primary-color); 962 | font-weight: 500; 963 | } 964 | 965 | .dark-mode #file-list li.active { 966 | background-color: #312e81; 967 | color: #a5b4fc; 968 | } 969 | 970 | /* Enhanced Category Styles */ 971 | .category-list-container { 972 | overflow-y: auto; 973 | max-height: 200px; 974 | border-radius: 0.375rem; 975 | scrollbar-width: thin; 976 | } 977 | 978 | .category-item { 979 | display: flex; 980 | align-items: center; 981 | padding: 0.5rem 0.75rem; 982 | border-radius: 0.375rem; 983 | cursor: pointer; 984 | margin-bottom: 0.25rem; 985 | transition: background-color 0.2s; 986 | position: relative; 987 | } 988 | 989 | .category-item svg { 990 | flex-shrink: 0; 991 | } 992 | 993 | .category-name { 994 | margin-left: 0.5rem; 995 | flex: 1; 996 | white-space: nowrap; 997 | overflow: hidden; 998 | text-overflow: ellipsis; 999 | } 1000 | 1001 | .category-delete-btn { 1002 | opacity: 0; 1003 | transition: opacity 0.2s; 1004 | } 1005 | 1006 | .category-item:hover .category-delete-btn { 1007 | opacity: 1; 1008 | } 1009 | 1010 | .category-item:hover { 1011 | background-color: #f3f4f6; 1012 | } 1013 | 1014 | .dark-mode .category-item:hover { 1015 | background-color: #374151; 1016 | } 1017 | 1018 | .category-item.active { 1019 | background-color: var(--primary-light); 1020 | color: var(--primary-color); 1021 | font-weight: 500; 1022 | } 1023 | 1024 | .dark-mode .category-item.active { 1025 | background-color: #312e81; 1026 | color: #a5b4fc; 1027 | } 1028 | 1029 | /* Ensure all text inputs are left-aligned */ 1030 | input[type="text"], 1031 | textarea, 1032 | select { 1033 | text-align: left; 1034 | } 1035 | 1036 | /* Responsive Sidebar Toggle */ 1037 | .sidebar-toggle { 1038 | display: none; 1039 | } 1040 | 1041 | /* Mobile Responsive Design */ 1042 | @media (max-width: 768px) { 1043 | main { 1044 | flex-direction: column; 1045 | } 1046 | 1047 | #sidebar { 1048 | position: fixed; 1049 | z-index: 40; 1050 | width: 100%; 1051 | height: 100vh; 1052 | top: 0; 1053 | left: 0; 1054 | transform: translateX(-100%); 1055 | transition: transform 0.3s ease; 1056 | } 1057 | 1058 | #sidebar.active { 1059 | transform: translateX(0); 1060 | } 1061 | 1062 | .sidebar-toggle { 1063 | display: block; 1064 | } 1065 | 1066 | .file-list-container { 1067 | max-height: calc(100vh - 400px); 1068 | } 1069 | 1070 | #content-container { 1071 | margin-top: 1rem; 1072 | } 1073 | 1074 | #toolbar { 1075 | flex-wrap: wrap; 1076 | } 1077 | 1078 | #file-info { 1079 | width: 100%; 1080 | margin-bottom: 0.5rem; 1081 | } 1082 | } 1083 | 1084 | /* Compact Mode for many items */ 1085 | @media (min-height: 800px) { 1086 | .file-list-container { 1087 | max-height: calc(100vh - 350px); 1088 | } 1089 | } 1090 | 1091 | /* Delete button in file list */ 1092 | .delete-btn { 1093 | opacity: 0; 1094 | transition: opacity 0.2s, color 0.2s; 1095 | } 1096 | 1097 | #file-list li:hover .delete-btn { 1098 | opacity: 1; 1099 | } 1100 | 1101 | .dark-mode .delete-btn { 1102 | color: #6b7280; 1103 | } 1104 | 1105 | .dark-mode .delete-btn:hover { 1106 | color: #ef4444; 1107 | } -------------------------------------------------------------------------------- /src/server.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use std::sync::Arc; 3 | use std::fs as std_fs; 4 | use tokio::sync::RwLock; 5 | use axum::{ 6 | extract::{Path as AxumPath, State, Query}, 7 | response::IntoResponse, 8 | routing::{get, post, put, delete}, 9 | Router, Json, http::StatusCode, 10 | }; 11 | use serde::{Deserialize, Serialize}; 12 | use anyhow::{Result, Context}; 13 | use tower_http::trace::TraceLayer; 14 | use tracing::info; 15 | 16 | use crate::fs; 17 | use crate::config::AppConfig; 18 | use crate::embedded::static_handler; 19 | 20 | // Define API types 21 | #[derive(Debug, Serialize)] 22 | struct ApiResponse { 23 | status: String, 24 | data: T, 25 | } 26 | 27 | #[derive(Debug, Serialize)] 28 | struct ApiError { 29 | status: String, 30 | message: String, 31 | } 32 | 33 | // Single response type to handle both success and error cases 34 | enum ApiResult { 35 | Success(StatusCode, T), 36 | Error(StatusCode, String), 37 | } 38 | 39 | impl IntoResponse for ApiResult { 40 | fn into_response(self) -> axum::response::Response { 41 | match self { 42 | ApiResult::Success(status, data) => { 43 | ( 44 | status, 45 | Json(ApiResponse { 46 | status: "success".to_string(), 47 | data, 48 | }), 49 | ).into_response() 50 | }, 51 | ApiResult::Error(status, message) => { 52 | ( 53 | status, 54 | Json(ApiError { 55 | status: "error".to_string(), 56 | message, 57 | }), 58 | ).into_response() 59 | } 60 | } 61 | } 62 | } 63 | 64 | #[derive(Debug, Deserialize)] 65 | struct CreateFileRequest { 66 | name: String, 67 | content: String, 68 | } 69 | 70 | #[derive(Debug, Deserialize)] 71 | struct UpdateFileRequest { 72 | content: String, 73 | } 74 | 75 | #[derive(Debug, Deserialize)] 76 | struct SearchQuery { 77 | q: Option, 78 | tag: Option, 79 | category: Option, 80 | } 81 | 82 | #[derive(Debug, Deserialize)] 83 | struct AddTagsRequest { 84 | tags: Vec, 85 | } 86 | 87 | #[derive(Debug, Deserialize)] 88 | struct CreateCategoryRequest { 89 | name: String, 90 | } 91 | 92 | #[derive(Debug, Deserialize)] 93 | struct RemoveTagsRequest { 94 | tags: Vec, 95 | } 96 | 97 | // App state 98 | #[derive(Clone)] 99 | struct AppState { 100 | base_dir: PathBuf, 101 | config: Arc>, 102 | } 103 | 104 | /// Start the web server 105 | pub async fn start_server(base_dir: PathBuf, custom_config_path: Option) -> Result<()> { 106 | // Initialize tracing 107 | tracing_subscriber::fmt::init(); 108 | 109 | // Load configuration 110 | let config_path = match custom_config_path { 111 | Some(path) => path, 112 | None => dirs::config_dir() 113 | .unwrap_or_else(|| PathBuf::from(".")) 114 | .join("mdlib/config.json") 115 | }; 116 | 117 | println!("📝 Using config file: {:?}", config_path); 118 | 119 | let config = AppConfig::load_or_default(&config_path) 120 | .context("Failed to load configuration")?; 121 | 122 | let app_state = AppState { 123 | base_dir, 124 | config: Arc::new(RwLock::new(config)), 125 | }; 126 | 127 | // Define routes 128 | let api_routes = Router::new() 129 | .route("/files", get(list_files)) 130 | .route("/files", post(create_file)) 131 | .route("/files/:filename", get(get_file)) 132 | .route("/files/:filename", put(update_file)) 133 | .route("/files/:filename", delete(delete_file)) 134 | .route("/search", get(search_files)) 135 | .route("/tags/:filename", put(add_tags)) 136 | .route("/tags/:filename", delete(remove_tags)) 137 | .route("/category", post(create_category)) 138 | .route("/category/:category_name", delete(delete_category)) 139 | .route("/categories", get(list_categories)); 140 | 141 | // Combine API routes with static files 142 | // Use embedded static files instead of physical directory 143 | let app = Router::new() 144 | .nest("/api", api_routes) 145 | .fallback(static_handler) 146 | .layer(TraceLayer::new_for_http()) 147 | .with_state(app_state.clone()); 148 | 149 | // Get the server address 150 | let addr = { 151 | let config = app_state.config.read().await; 152 | config.server_address().parse() 153 | .context("Invalid server address")? 154 | }; 155 | 156 | // Print a clear message showing the URL for users 157 | println!("\n======================================================="); 158 | println!("🚀 mdlib server is running at: http://{}", addr); 159 | println!("======================================================="); 160 | println!("📝 Open this URL in your browser to access your Personal Wiki"); 161 | println!("💡 Press Ctrl+C to stop the server"); 162 | println!("=======================================================\n"); 163 | 164 | info!("Starting server on {}", addr); 165 | 166 | // Start the server 167 | axum::Server::bind(&addr) 168 | .serve(app.into_make_service()) 169 | .await 170 | .context("Server error") 171 | } 172 | 173 | /// List all markdown files 174 | async fn list_files( 175 | State(state): State, 176 | ) -> impl IntoResponse { 177 | match fs::list_markdown_files(&state.base_dir) { 178 | Ok(files) => ApiResult::Success(StatusCode::OK, files), 179 | Err(err) => ApiResult::Error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), 180 | } 181 | } 182 | 183 | /// Get the content of a file 184 | async fn get_file( 185 | State(state): State, 186 | AxumPath(filename): AxumPath, 187 | ) -> impl IntoResponse { 188 | // First, try to find the file directly at the path given 189 | let direct_path = state.base_dir.join(&filename); 190 | 191 | // Check if file exists and is a markdown file 192 | if direct_path.is_file() { 193 | // Verify it's a markdown file or at least has no extension (like README) 194 | if let Some(ext) = direct_path.extension() { 195 | if ext != "md" { 196 | return ApiResult::Error(StatusCode::NOT_FOUND, "Not a markdown file".to_string()); 197 | } 198 | } 199 | 200 | return match fs::read_markdown_file(&direct_path) { 201 | Ok(content) => ApiResult::Success(StatusCode::OK, content), 202 | Err(err) => ApiResult::Error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), 203 | }; 204 | } 205 | 206 | // If the direct path doesn't work, the file might be in a category folder 207 | // First, try to handle it as a path with slashes or backslashes 208 | let path_parts: Vec<&str> = filename.split(['/', '\\']).collect(); 209 | if path_parts.len() > 1 { 210 | let combined_path = state.base_dir.join(&filename); 211 | if combined_path.is_file() { 212 | return match fs::read_markdown_file(&combined_path) { 213 | Ok(content) => ApiResult::Success(StatusCode::OK, content), 214 | Err(err) => ApiResult::Error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), 215 | }; 216 | } 217 | } 218 | 219 | // Last resort: search for the file by name in all categories 220 | // First, get a list of all markdown files 221 | let all_files = match fs::list_markdown_files(&state.base_dir) { 222 | Ok(files) => files, 223 | Err(err) => return ApiResult::Error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), 224 | }; 225 | 226 | // Try to find a file with a matching name 227 | let file_name_buf = PathBuf::from(&filename); 228 | let file_name = file_name_buf.file_name() 229 | .and_then(|n| n.to_str()) 230 | .unwrap_or(&filename); 231 | 232 | for file in all_files { 233 | let curr_file_name = file.path.file_name() 234 | .and_then(|n| n.to_str()) 235 | .unwrap_or(""); 236 | 237 | if curr_file_name == file_name || curr_file_name.to_lowercase() == file_name.to_lowercase() { 238 | return match fs::read_markdown_file(&file.path) { 239 | Ok(content) => ApiResult::Success(StatusCode::OK, content), 240 | Err(err) => ApiResult::Error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), 241 | }; 242 | } 243 | } 244 | 245 | // If we get here, we couldn't find the file 246 | ApiResult::Error(StatusCode::NOT_FOUND, "File not found".to_string()) 247 | } 248 | 249 | /// Create a new markdown file 250 | async fn create_file( 251 | State(state): State, 252 | Json(request): Json, 253 | ) -> impl IntoResponse { 254 | let name = request.name.trim(); 255 | if name.is_empty() { 256 | return ApiResult::Error(StatusCode::BAD_REQUEST, "Filename cannot be empty".to_string()); 257 | } 258 | 259 | // Extract category from the frontmatter if it exists 260 | let mut category_path = PathBuf::new(); 261 | 262 | // Check if content has frontmatter with a category 263 | if let Some(category) = extract_category_from_content(&request.content) { 264 | // Make sure the category directory exists 265 | match fs::create_category(&state.base_dir, &category) { 266 | Ok(path) => { 267 | category_path = path; 268 | println!("Using category: {}, path: {:?}", category, category_path); 269 | }, 270 | Err(err) => { 271 | return ApiResult::Error( 272 | StatusCode::INTERNAL_SERVER_ERROR, 273 | format!("Failed to create category directory: {}", err) 274 | ); 275 | } 276 | } 277 | } 278 | 279 | // Create the file in the appropriate location 280 | let file_path = if category_path.as_os_str().is_empty() { 281 | // No category, create in base directory 282 | match fs::create_markdown_file(&state.base_dir, name, &request.content) { 283 | Ok(path) => path, 284 | Err(err) => { 285 | return ApiResult::Error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()); 286 | } 287 | } 288 | } else { 289 | // Create in category directory 290 | match fs::create_markdown_file(&category_path, name, &request.content) { 291 | Ok(path) => path, 292 | Err(err) => { 293 | return ApiResult::Error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()); 294 | } 295 | } 296 | }; 297 | 298 | // Return the relative path 299 | match fs::get_relative_path(&state.base_dir, &file_path) { 300 | Ok(rel_path) => { 301 | ApiResult::Success(StatusCode::CREATED, rel_path.to_string_lossy().into_owned()) 302 | }, 303 | Err(err) => { 304 | ApiResult::Error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) 305 | } 306 | } 307 | } 308 | 309 | // Helper function to extract category from content 310 | fn extract_category_from_content(content: &str) -> Option { 311 | if let Some(frontmatter) = extract_frontmatter(content) { 312 | for line in frontmatter.lines() { 313 | let line = line.trim(); 314 | if let Some(stripped) = line.strip_prefix("category:") { 315 | return Some(stripped.trim().to_string()); 316 | } 317 | } 318 | } 319 | None 320 | } 321 | 322 | // Extract YAML frontmatter from markdown content if present 323 | fn extract_frontmatter(content: &str) -> Option { 324 | let trimmed = content.trim_start(); 325 | if let Some(stripped) = trimmed.strip_prefix("---") { 326 | if let Some(end_index) = stripped.find("---") { 327 | return Some(stripped[..end_index].trim().to_string()); 328 | } 329 | } 330 | None 331 | } 332 | 333 | /// Update an existing file 334 | async fn update_file( 335 | State(state): State, 336 | AxumPath(filename): AxumPath, 337 | Json(request): Json, 338 | ) -> impl IntoResponse { 339 | // First, try direct path 340 | let direct_path = state.base_dir.join(&filename); 341 | 342 | if direct_path.is_file() { 343 | // Verify it's a markdown file or README 344 | let is_readme = direct_path.file_name() 345 | .and_then(|name| name.to_str()) 346 | .map(|name| name.to_lowercase().starts_with("readme")) 347 | .unwrap_or(false); 348 | 349 | if is_readme || direct_path.extension().is_some_and(|ext| ext == "md") { 350 | return match fs::write_markdown_file(&direct_path, &request.content) { 351 | Ok(_) => ApiResult::Success(StatusCode::OK, "File updated".to_string()), 352 | Err(err) => ApiResult::Error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), 353 | }; 354 | } 355 | } 356 | 357 | // If direct path doesn't work, the file might be in a category folder 358 | // Try to handle it as a path with slashes or backslashes 359 | let path_parts: Vec<&str> = filename.split(['/', '\\']).collect(); 360 | if path_parts.len() > 1 { 361 | let combined_path = state.base_dir.join(&filename); 362 | if combined_path.is_file() { 363 | // Verify it's a markdown file 364 | if combined_path.extension().is_some_and(|ext| ext == "md") { 365 | return match fs::write_markdown_file(&combined_path, &request.content) { 366 | Ok(_) => ApiResult::Success(StatusCode::OK, "File updated".to_string()), 367 | Err(err) => ApiResult::Error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), 368 | }; 369 | } 370 | } 371 | } 372 | 373 | // Last resort: search for the file in all directories 374 | // Get all markdown files 375 | let all_files = match fs::list_markdown_files(&state.base_dir) { 376 | Ok(files) => files, 377 | Err(err) => return ApiResult::Error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), 378 | }; 379 | 380 | // Try to find a file with a matching name 381 | let file_name_buf = PathBuf::from(&filename); 382 | let file_name = file_name_buf.file_name() 383 | .and_then(|n| n.to_str()) 384 | .unwrap_or(&filename); 385 | 386 | for file in all_files { 387 | let curr_file_name = file.path.file_name() 388 | .and_then(|n| n.to_str()) 389 | .unwrap_or(""); 390 | 391 | if curr_file_name == file_name || curr_file_name.to_lowercase() == file_name.to_lowercase() { 392 | return match fs::write_markdown_file(&file.path, &request.content) { 393 | Ok(_) => ApiResult::Success(StatusCode::OK, "File updated".to_string()), 394 | Err(err) => ApiResult::Error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), 395 | }; 396 | } 397 | } 398 | 399 | // If we get here, we couldn't find the file 400 | ApiResult::Error(StatusCode::NOT_FOUND, "File not found".to_string()) 401 | } 402 | 403 | /// Delete a file 404 | async fn delete_file( 405 | State(state): State, 406 | AxumPath(filename): AxumPath, 407 | ) -> impl IntoResponse { 408 | // First, try direct path 409 | let direct_path = state.base_dir.join(&filename); 410 | 411 | if direct_path.is_file() { 412 | // Verify it's a markdown file or README 413 | let is_readme = direct_path.file_name() 414 | .and_then(|name| name.to_str()) 415 | .map(|name| name.to_lowercase().starts_with("readme")) 416 | .unwrap_or(false); 417 | 418 | if is_readme || direct_path.extension().is_some_and(|ext| ext == "md") { 419 | return match fs::delete_markdown_file(&direct_path) { 420 | Ok(_) => ApiResult::Success(StatusCode::OK, "File deleted".to_string()), 421 | Err(err) => ApiResult::Error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), 422 | }; 423 | } 424 | } 425 | 426 | // If direct path doesn't work, try handling it as a path with slashes or backslashes 427 | let path_parts: Vec<&str> = filename.split(['/', '\\']).collect(); 428 | if path_parts.len() > 1 { 429 | let combined_path = state.base_dir.join(&filename); 430 | if combined_path.is_file() { 431 | // Verify it's a markdown file 432 | if combined_path.extension().is_some_and(|ext| ext == "md") { 433 | return match fs::delete_markdown_file(&combined_path) { 434 | Ok(_) => ApiResult::Success(StatusCode::OK, "File deleted".to_string()), 435 | Err(err) => ApiResult::Error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), 436 | }; 437 | } 438 | } 439 | } 440 | 441 | // Last resort: search for the file in all directories 442 | let all_files = match fs::list_markdown_files(&state.base_dir) { 443 | Ok(files) => files, 444 | Err(err) => return ApiResult::Error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), 445 | }; 446 | 447 | // Try to find a file with a matching name 448 | let file_name_buf = PathBuf::from(&filename); 449 | let file_name = file_name_buf.file_name() 450 | .and_then(|n| n.to_str()) 451 | .unwrap_or(&filename); 452 | 453 | for file in all_files { 454 | let curr_file_name = file.path.file_name() 455 | .and_then(|n| n.to_str()) 456 | .unwrap_or(""); 457 | 458 | if curr_file_name == file_name || curr_file_name.to_lowercase() == file_name.to_lowercase() { 459 | return match fs::delete_markdown_file(&file.path) { 460 | Ok(_) => ApiResult::Success(StatusCode::OK, "File deleted".to_string()), 461 | Err(err) => ApiResult::Error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), 462 | }; 463 | } 464 | } 465 | 466 | // If we get here, we couldn't find the file 467 | ApiResult::Error(StatusCode::NOT_FOUND, "File not found".to_string()) 468 | } 469 | 470 | /// Search for files containing a query 471 | async fn search_files( 472 | State(state): State, 473 | Query(query): Query, 474 | ) -> impl IntoResponse { 475 | // Get all markdown files 476 | let files = match fs::list_markdown_files(&state.base_dir) { 477 | Ok(files) => files, 478 | Err(err) => { 479 | return ApiResult::Error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()); 480 | } 481 | }; 482 | 483 | // Filter files by the search criteria 484 | let mut matching_files = Vec::new(); 485 | 486 | for file in files { 487 | let mut matches = true; 488 | 489 | // Filter by text content if search term is provided 490 | if let Some(term) = &query.q { 491 | if !term.trim().is_empty() { 492 | if let Ok(content) = fs::read_markdown_file(&file.path) { 493 | if !content.to_lowercase().contains(&term.to_lowercase()) { 494 | matches = false; 495 | } 496 | } else { 497 | matches = false; 498 | } 499 | } 500 | } 501 | 502 | // Filter by tag if provided 503 | if let Some(tag) = &query.tag { 504 | if !file.tags.iter().any(|t| t.to_lowercase() == tag.to_lowercase()) { 505 | matches = false; 506 | } 507 | } 508 | 509 | // Filter by category if provided 510 | if let Some(category) = &query.category { 511 | match &file.category { 512 | Some(file_category) if file_category.to_lowercase() == category.to_lowercase() => { 513 | // Category matches 514 | }, 515 | _ => { 516 | matches = false; 517 | } 518 | } 519 | } 520 | 521 | if matches { 522 | matching_files.push(file); 523 | } 524 | } 525 | 526 | ApiResult::Success(StatusCode::OK, matching_files) 527 | } 528 | 529 | /// Add tags to a file 530 | async fn add_tags( 531 | State(state): State, 532 | AxumPath(filename): AxumPath, 533 | Json(request): Json, 534 | ) -> impl IntoResponse { 535 | // First, try direct path 536 | let direct_path = state.base_dir.join(&filename); 537 | 538 | if direct_path.is_file() { 539 | // Verify it's a markdown file or README 540 | let is_readme = direct_path.file_name() 541 | .and_then(|name| name.to_str()) 542 | .map(|name| name.to_lowercase().starts_with("readme")) 543 | .unwrap_or(false); 544 | 545 | if is_readme || direct_path.extension().is_some_and(|ext| ext == "md") { 546 | return match fs::add_tags_to_file(&direct_path, &request.tags) { 547 | Ok(_) => ApiResult::Success(StatusCode::OK, "Tags added".to_string()), 548 | Err(err) => ApiResult::Error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), 549 | }; 550 | } 551 | } 552 | 553 | // If direct path doesn't work, try handling it as a path with slashes or backslashes 554 | let path_parts: Vec<&str> = filename.split(['/', '\\']).collect(); 555 | if path_parts.len() > 1 { 556 | let combined_path = state.base_dir.join(&filename); 557 | if combined_path.is_file() { 558 | // Verify it's a markdown file 559 | if combined_path.extension().is_some_and(|ext| ext == "md") { 560 | return match fs::add_tags_to_file(&combined_path, &request.tags) { 561 | Ok(_) => ApiResult::Success(StatusCode::OK, "Tags added".to_string()), 562 | Err(err) => ApiResult::Error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), 563 | }; 564 | } 565 | } 566 | } 567 | 568 | // Last resort: search for the file in all directories 569 | let all_files = match fs::list_markdown_files(&state.base_dir) { 570 | Ok(files) => files, 571 | Err(err) => return ApiResult::Error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), 572 | }; 573 | 574 | // Try to find a file with a matching name 575 | let file_name_buf = PathBuf::from(&filename); 576 | let file_name = file_name_buf.file_name() 577 | .and_then(|n| n.to_str()) 578 | .unwrap_or(&filename); 579 | 580 | for file in all_files { 581 | let curr_file_name = file.path.file_name() 582 | .and_then(|n| n.to_str()) 583 | .unwrap_or(""); 584 | 585 | if curr_file_name == file_name || curr_file_name.to_lowercase() == file_name.to_lowercase() { 586 | return match fs::add_tags_to_file(&file.path, &request.tags) { 587 | Ok(_) => ApiResult::Success(StatusCode::OK, "Tags added".to_string()), 588 | Err(err) => ApiResult::Error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), 589 | }; 590 | } 591 | } 592 | 593 | // If we get here, we couldn't find the file 594 | ApiResult::Error(StatusCode::NOT_FOUND, "File not found".to_string()) 595 | } 596 | 597 | /// Create a new category 598 | async fn create_category( 599 | State(state): State, 600 | Json(request): Json, 601 | ) -> impl IntoResponse { 602 | // Validate the category name 603 | let name = request.name.trim(); 604 | if name.is_empty() { 605 | return ApiResult::Error(StatusCode::BAD_REQUEST, "Category name cannot be empty".to_string()); 606 | } 607 | 608 | // Create the category directory 609 | match fs::create_category(&state.base_dir, name) { 610 | Ok(_) => ApiResult::Success(StatusCode::CREATED, name.to_string()), 611 | Err(err) => ApiResult::Error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), 612 | } 613 | } 614 | 615 | /// List all categories 616 | async fn list_categories( 617 | State(state): State, 618 | ) -> impl IntoResponse { 619 | let base_dir = &state.base_dir; 620 | 621 | // Get all markdown files to extract unique categories 622 | let files = match fs::list_markdown_files(base_dir) { 623 | Ok(files) => files, 624 | Err(err) => { 625 | return ApiResult::Error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()); 626 | } 627 | }; 628 | 629 | // Extract unique categories from files 630 | let mut categories = Vec::new(); 631 | for file in &files { 632 | if let Some(category) = &file.category { 633 | if !categories.contains(category) { 634 | categories.push(category.clone()); 635 | } 636 | } 637 | } 638 | 639 | // Also scan for directories that might be empty categories 640 | if let Ok(entries) = std_fs::read_dir(base_dir) { 641 | for entry in entries.filter_map(|e| e.ok()) { 642 | if let Ok(file_type) = entry.file_type() { 643 | if file_type.is_dir() { 644 | if let Some(name) = entry.file_name().to_str() { 645 | // Skip hidden directories 646 | if !name.starts_with('.') { 647 | let category = name.to_string(); 648 | if !categories.contains(&category) { 649 | categories.push(category); 650 | } 651 | } 652 | } 653 | } 654 | } 655 | } 656 | } 657 | 658 | // Sort categories 659 | categories.sort(); 660 | 661 | ApiResult::Success(StatusCode::OK, categories) 662 | } 663 | 664 | /// Remove tags from a file 665 | async fn remove_tags( 666 | State(state): State, 667 | AxumPath(filename): AxumPath, 668 | Json(request): Json, 669 | ) -> impl IntoResponse { 670 | // First, try direct path 671 | let direct_path = state.base_dir.join(&filename); 672 | 673 | if direct_path.is_file() { 674 | // Verify it's a markdown file or README 675 | let is_readme = direct_path.file_name() 676 | .and_then(|name| name.to_str()) 677 | .map(|name| name.to_lowercase().starts_with("readme")) 678 | .unwrap_or(false); 679 | 680 | if is_readme || direct_path.extension().is_some_and(|ext| ext == "md") { 681 | return match fs::remove_tags_from_file(&direct_path, &request.tags) { 682 | Ok(_) => ApiResult::Success(StatusCode::OK, "Tags removed".to_string()), 683 | Err(err) => ApiResult::Error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), 684 | }; 685 | } 686 | } 687 | 688 | // If direct path doesn't work, try handling it as a path with slashes or backslashes 689 | let path_parts: Vec<&str> = filename.split(['/', '\\']).collect(); 690 | if path_parts.len() > 1 { 691 | let combined_path = state.base_dir.join(&filename); 692 | if combined_path.is_file() { 693 | // Verify it's a markdown file 694 | if combined_path.extension().is_some_and(|ext| ext == "md") { 695 | return match fs::remove_tags_from_file(&combined_path, &request.tags) { 696 | Ok(_) => ApiResult::Success(StatusCode::OK, "Tags removed".to_string()), 697 | Err(err) => ApiResult::Error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), 698 | }; 699 | } 700 | } 701 | } 702 | 703 | // Last resort: search for the file in all directories 704 | let all_files = match fs::list_markdown_files(&state.base_dir) { 705 | Ok(files) => files, 706 | Err(err) => return ApiResult::Error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), 707 | }; 708 | 709 | // Try to find a file with a matching name 710 | let file_name_buf = PathBuf::from(&filename); 711 | let file_name = file_name_buf.file_name() 712 | .and_then(|n| n.to_str()) 713 | .unwrap_or(&filename); 714 | 715 | for file in all_files { 716 | let curr_file_name = file.path.file_name() 717 | .and_then(|n| n.to_str()) 718 | .unwrap_or(""); 719 | 720 | if curr_file_name == file_name || curr_file_name.to_lowercase() == file_name.to_lowercase() { 721 | return match fs::remove_tags_from_file(&file.path, &request.tags) { 722 | Ok(_) => ApiResult::Success(StatusCode::OK, "Tags removed".to_string()), 723 | Err(err) => ApiResult::Error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), 724 | }; 725 | } 726 | } 727 | 728 | // If we get here, we couldn't find the file 729 | ApiResult::Error(StatusCode::NOT_FOUND, "File not found".to_string()) 730 | } 731 | 732 | /// Delete a category 733 | async fn delete_category( 734 | State(state): State, 735 | AxumPath(category_name): AxumPath, 736 | ) -> impl IntoResponse { 737 | // Validate the category name 738 | let name = category_name.trim(); 739 | if name.is_empty() { 740 | return ApiResult::Error(StatusCode::BAD_REQUEST, "Category name cannot be empty".to_string()); 741 | } 742 | 743 | // Check if the category exists and delete it 744 | match fs::delete_category(&state.base_dir, name) { 745 | Ok(_) => ApiResult::Success(StatusCode::OK, format!("Category '{}' deleted", name)), 746 | Err(err) => ApiResult::Error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), 747 | } 748 | } -------------------------------------------------------------------------------- /static/js/app.js: -------------------------------------------------------------------------------- 1 | /** 2 | * mdlib - Markdown Note-Taking Application 3 | * Client-side functionality 4 | */ 5 | 6 | document.addEventListener('DOMContentLoaded', () => { 7 | // DOM Elements 8 | const fileList = document.getElementById('file-list'); 9 | const editor = document.getElementById('editor'); 10 | const preview = document.getElementById('preview'); 11 | const editorPane = document.getElementById('editor-pane'); 12 | const previewPane = document.getElementById('preview-pane'); 13 | const btnSave = document.getElementById('btn-save'); 14 | const btnNewNote = document.getElementById('btn-new-note'); 15 | const btnEmptyNewNote = document.getElementById('btn-empty-new-note'); 16 | const btnEdit = document.getElementById('btn-edit'); 17 | const btnSplitView = document.getElementById('btn-split-view'); 18 | const btnPreviewOnly = document.getElementById('btn-preview-only'); 19 | const btnAddTags = document.getElementById('btn-add-tags'); 20 | const newNoteModal = document.getElementById('new-note-modal'); 21 | const newNoteName = document.getElementById('new-note-name'); 22 | const newNoteCategory = document.getElementById('new-note-category'); 23 | const newNoteTags = document.getElementById('new-note-tags'); 24 | const btnModalCreate = document.getElementById('btn-modal-create'); 25 | const btnModalCancel = document.getElementById('btn-modal-cancel'); 26 | const searchInput = document.getElementById('search-input'); 27 | const toggleDarkMode = document.getElementById('toggle-dark-mode'); 28 | const editorToolbar = document.getElementById('editor-toolbar'); 29 | const toolbarButtons = document.querySelectorAll('[data-format]'); 30 | const emptyState = document.getElementById('empty-state'); 31 | const contentContainer = document.getElementById('content-container'); 32 | const currentFilename = document.getElementById('current-filename'); 33 | const addTagsModal = document.getElementById('add-tags-modal'); 34 | const tagInput = document.getElementById('tag-input'); 35 | const btnTagAdd = document.getElementById('btn-tag-add'); 36 | const btnTagCancel = document.getElementById('btn-tag-cancel'); 37 | const categoryList = document.getElementById('category-list'); 38 | const tagsContainer = document.getElementById('tags-container'); 39 | const newCategoryInput = document.getElementById('new-category-input'); 40 | const addCategoryBtn = document.getElementById('add-category-btn'); 41 | const newCategoryBtn = document.getElementById('new-category-btn'); 42 | const categoryForm = document.getElementById('category-form'); 43 | const sidebarToggle = document.getElementById('sidebar-toggle'); 44 | const sidebar = document.getElementById('sidebar'); 45 | 46 | // State 47 | let currentFile = null; 48 | let isEditing = false; 49 | let viewMode = 'preview'; // 'preview', 'edit', or 'split' 50 | let isDarkMode = localStorage.getItem('darkMode') === 'true'; 51 | let autoSaveTimeout = null; 52 | let allTags = new Set(); 53 | let categories = []; 54 | let isMobile = window.innerWidth < 768; 55 | 56 | // Initialize the application 57 | init(); 58 | 59 | // Initialization 60 | function init() { 61 | // Configure highlight.js 62 | if (typeof hljs !== 'undefined') { 63 | // Register commonly used languages if we're using the full bundle 64 | hljs.highlightAll(); 65 | console.log('highlight.js loaded successfully'); 66 | } else { 67 | console.error('highlight.js is not loaded. Syntax highlighting will not work.'); 68 | } 69 | 70 | // Load files 71 | loadFiles(); 72 | 73 | // Load categories 74 | loadCategories(); 75 | 76 | // Load tags 77 | updateTagsDisplay(); 78 | 79 | // Set up event listeners 80 | setupEventListeners(); 81 | 82 | // Apply dark mode if needed 83 | if (isDarkMode) { 84 | document.body.classList.add('dark-mode'); 85 | } 86 | 87 | // Configure marked renderer 88 | configureMarked(); 89 | 90 | // Check screen size and set up responsive behavior 91 | checkScreenSize(); 92 | window.addEventListener('resize', debounce(checkScreenSize, 100)); 93 | 94 | // Create toast container 95 | createToastContainer(); 96 | } 97 | 98 | // Configure marked for rendering markdown 99 | function configureMarked() { 100 | marked.setOptions({ 101 | breaks: true, 102 | gfm: true, 103 | headerIds: true, 104 | highlight: function(code, lang) { 105 | if (typeof hljs === 'undefined') { 106 | return code; // Fallback if highlight.js isn't loaded 107 | } 108 | 109 | try { 110 | if (lang && hljs.getLanguage(lang)) { 111 | return hljs.highlight(lang, code).value; 112 | } 113 | return hljs.highlightAuto(code).value; 114 | } catch (e) { 115 | console.error('Error highlighting code:', e); 116 | return code; // Return original code on error 117 | } 118 | } 119 | }); 120 | } 121 | 122 | // Set up event listeners 123 | function setupEventListeners() { 124 | // New note buttons 125 | btnNewNote.addEventListener('click', showNewNoteModal); 126 | btnEmptyNewNote.addEventListener('click', showNewNoteModal); 127 | 128 | // Edit button - switch to edit mode 129 | btnEdit.addEventListener('click', () => setViewMode('edit')); 130 | 131 | // Split view button - show both editor and preview 132 | btnSplitView.addEventListener('click', () => setViewMode('split')); 133 | 134 | // Preview only button - switch back to preview mode 135 | btnPreviewOnly.addEventListener('click', () => setViewMode('preview')); 136 | 137 | // Add tags button in toolbar 138 | btnAddTags.addEventListener('click', () => { 139 | if (currentFile) { 140 | showAddTagsModal(currentFile); 141 | } else { 142 | showToast('No file is currently open.', 'error'); 143 | } 144 | }); 145 | 146 | // Save the current file 147 | btnSave.addEventListener('click', saveCurrentFile); 148 | 149 | // Create a new note from the modal 150 | btnModalCreate.addEventListener('click', createNewNote); 151 | 152 | // Cancel creating a new note 153 | btnModalCancel.addEventListener('click', hideNewNoteModal); 154 | 155 | // Add tag buttons 156 | btnTagAdd.addEventListener('click', addTagsToCurrentFile); 157 | btnTagCancel.addEventListener('click', hideAddTagsModal); 158 | 159 | // Show category form 160 | newCategoryBtn.addEventListener('click', toggleCategoryForm); 161 | 162 | // Add category button 163 | addCategoryBtn.addEventListener('click', createCategory); 164 | 165 | // Add category on enter key 166 | newCategoryInput.addEventListener('keydown', e => { 167 | if (e.key === 'Enter') { 168 | createCategory(); 169 | } 170 | }); 171 | 172 | // Search functionality 173 | searchInput.addEventListener('input', debounce(searchFiles, 300)); 174 | 175 | // Toggle dark mode 176 | toggleDarkMode.addEventListener('click', () => { 177 | isDarkMode = !isDarkMode; 178 | document.body.classList.toggle('dark-mode', isDarkMode); 179 | localStorage.setItem('darkMode', isDarkMode); 180 | }); 181 | 182 | // Auto-preview as you type 183 | editor.addEventListener('input', () => { 184 | updatePreview(); 185 | scheduleAutoSave(); 186 | }); 187 | 188 | // Format text using toolbar buttons 189 | toolbarButtons.forEach(button => { 190 | button.addEventListener('click', () => { 191 | const format = button.getAttribute('data-format'); 192 | applyFormat(format); 193 | }); 194 | }); 195 | 196 | // Handle clicks on links in the preview pane (wiki-linking) 197 | preview.addEventListener('click', e => { 198 | // Check if the clicked element is a link 199 | if (e.target.tagName === 'A') { 200 | e.preventDefault(); 201 | const href = e.target.getAttribute('href'); 202 | 203 | // Only handle .md links or links without extension (assumed to be markdown) 204 | if (href && (href.endsWith('.md') || !href.includes('.'))) { 205 | // Remove leading slash if present for consistency 206 | const cleanHref = href.startsWith('/') ? href.substring(1) : href; 207 | console.log('Loading markdown link:', cleanHref); 208 | loadFile(cleanHref); 209 | } else { 210 | // For external links, open in a new tab 211 | window.open(href, '_blank'); 212 | } 213 | } 214 | }); 215 | 216 | // Keyboard shortcuts 217 | document.addEventListener('keydown', handleKeyboardShortcuts); 218 | 219 | // Enter key in new note name input 220 | newNoteName.addEventListener('keydown', e => { 221 | if (e.key === 'Enter') { 222 | createNewNote(); 223 | } 224 | }); 225 | 226 | // Escape key to exit modals 227 | document.addEventListener('keydown', e => { 228 | if (e.key === 'Escape') { 229 | if (!newNoteModal.classList.contains('hidden')) { 230 | hideNewNoteModal(); 231 | } 232 | if (!addTagsModal.classList.contains('hidden')) { 233 | hideAddTagsModal(); 234 | } 235 | } 236 | }); 237 | 238 | // Sidebar toggle for mobile 239 | if (sidebarToggle) { 240 | sidebarToggle.addEventListener('click', toggleSidebar); 241 | } 242 | 243 | // Close sidebar when clicking on the content area in mobile view 244 | document.addEventListener('click', (e) => { 245 | if (isMobile && 246 | sidebar.classList.contains('active') && 247 | !sidebar.contains(e.target) && 248 | e.target !== sidebarToggle) { 249 | toggleSidebar(); 250 | } 251 | }); 252 | } 253 | 254 | // Check screen size and set up responsive behavior 255 | function checkScreenSize() { 256 | isMobile = window.innerWidth < 768; 257 | 258 | if (isMobile) { 259 | sidebarToggle.classList.remove('hidden'); 260 | } else { 261 | sidebarToggle.classList.add('hidden'); 262 | sidebar.classList.remove('active'); 263 | } 264 | } 265 | 266 | // Toggle sidebar for mobile view 267 | function toggleSidebar() { 268 | sidebar.classList.toggle('active'); 269 | } 270 | 271 | // Create toast container 272 | function createToastContainer() { 273 | const container = document.createElement('div'); 274 | container.className = 'toast-container'; 275 | document.body.appendChild(container); 276 | } 277 | 278 | // Show new note modal 279 | function showNewNoteModal() { 280 | // Load categories first to ensure dropdown is populated 281 | loadCategories(); 282 | 283 | newNoteModal.classList.remove('hidden'); 284 | newNoteName.focus(); 285 | } 286 | 287 | // Hide new note modal 288 | function hideNewNoteModal() { 289 | newNoteModal.classList.add('hidden'); 290 | newNoteName.value = ''; 291 | newNoteCategory.value = ''; 292 | newNoteTags.value = ''; 293 | } 294 | 295 | // Set the view mode (preview, edit, or split) 296 | function setViewMode(mode) { 297 | viewMode = mode; 298 | isEditing = mode === 'edit' || mode === 'split'; 299 | 300 | // Update UI based on view mode 301 | if (mode === 'preview') { 302 | editorPane.classList.add('hidden'); 303 | previewPane.classList.remove('hidden'); 304 | editorToolbar.classList.add('hidden'); 305 | btnEdit.classList.remove('hidden'); 306 | btnSplitView.classList.remove('hidden'); 307 | btnPreviewOnly.classList.add('hidden'); 308 | btnSave.classList.add('hidden'); 309 | document.getElementById('content').classList.remove('split-view'); 310 | } else if (mode === 'edit') { 311 | editorPane.classList.remove('hidden'); 312 | previewPane.classList.add('hidden'); 313 | editorToolbar.classList.remove('hidden'); 314 | btnEdit.classList.add('hidden'); 315 | btnSplitView.classList.remove('hidden'); 316 | btnPreviewOnly.classList.remove('hidden'); 317 | btnSave.classList.remove('hidden'); 318 | document.getElementById('content').classList.remove('split-view'); 319 | setTimeout(() => editor.focus(), 0); 320 | } else if (mode === 'split') { 321 | editorPane.classList.remove('hidden'); 322 | previewPane.classList.remove('hidden'); 323 | editorToolbar.classList.remove('hidden'); 324 | btnEdit.classList.remove('hidden'); 325 | btnSplitView.classList.add('hidden'); 326 | btnPreviewOnly.classList.remove('hidden'); 327 | btnSave.classList.remove('hidden'); 328 | document.getElementById('content').classList.add('split-view'); 329 | updatePreview(); 330 | setTimeout(() => editor.focus(), 0); 331 | } 332 | } 333 | 334 | // Load all markdown files 335 | function loadFiles() { 336 | fileList.innerHTML = ` 337 |
  • 338 | 339 | 340 | 341 | 342 | Loading notes... 343 |
  • 344 | `; 345 | 346 | fetch('/api/files') 347 | .then(response => response.json()) 348 | .then(data => { 349 | if (data.status === 'success') { 350 | displayFiles(data.data); 351 | // Update tags display after loading files 352 | updateTagsDisplay(); 353 | } else { 354 | fileList.innerHTML = '
  • Error loading files
  • '; 355 | console.error('Error loading files:', data.message); 356 | } 357 | }) 358 | .catch(error => { 359 | fileList.innerHTML = '
  • Error loading files
  • '; 360 | console.error('Error loading files:', error); 361 | }); 362 | } 363 | 364 | // Display files in the file list 365 | function displayFiles(files) { 366 | // Clear the existing file list 367 | fileList.innerHTML = ''; 368 | 369 | if (files.length === 0) { 370 | // Display a message if there are no files 371 | const listItem = document.createElement('li'); 372 | listItem.className = 'text-gray-500 text-sm italic'; 373 | listItem.textContent = 'No notes found'; 374 | fileList.appendChild(listItem); 375 | return; 376 | } 377 | 378 | // Process and sort files 379 | const processedFiles = files.map(file => { 380 | // Extract just the file name without path and extension 381 | let fileName = file.path.split('/').pop().replace('.md', ''); 382 | 383 | // Extract category if it exists in the path 384 | let category = ''; 385 | const pathParts = file.path.split('/'); 386 | if (pathParts.length > 2) { 387 | category = pathParts[pathParts.length - 2]; 388 | } 389 | 390 | return { 391 | path: file.path, 392 | name: fileName, 393 | category: category, 394 | tags: file.tags || [] 395 | }; 396 | }); 397 | 398 | // Sort files alphabetically by name and secondarily by category 399 | processedFiles.sort((a, b) => { 400 | // First sort by category if it exists 401 | if (a.category && b.category && a.category !== b.category) { 402 | return a.category.localeCompare(b.category); 403 | } 404 | // Then sort by name 405 | return a.name.localeCompare(b.name); 406 | }); 407 | 408 | // Create a document fragment for better performance 409 | const fragment = document.createDocumentFragment(); 410 | 411 | // Track categories for visual separation 412 | let lastCategory = null; 413 | 414 | // Add each file to the list 415 | processedFiles.forEach(file => { 416 | // If category is changing, add a visual separator 417 | if (file.category && file.category !== lastCategory) { 418 | lastCategory = file.category; 419 | 420 | // Only add a separator if this isn't the first category 421 | if (fragment.childElementCount > 0) { 422 | const separator = document.createElement('li'); 423 | separator.className = 'py-1 my-1'; 424 | separator.style.borderBottom = '1px solid var(--border-color)'; 425 | fragment.appendChild(separator); 426 | } 427 | } 428 | 429 | const listItem = document.createElement('li'); 430 | listItem.setAttribute('data-path', file.path); 431 | listItem.style.justifyContent = 'flex-start'; 432 | listItem.style.textAlign = 'left'; 433 | 434 | // Create inner structure for better styling 435 | const noteIcon = document.createElement('span'); 436 | noteIcon.className = 'note-icon flex-shrink-0 mr-2'; 437 | noteIcon.innerHTML = ` 438 | 439 | `; 440 | 441 | const noteTitle = document.createElement('span'); 442 | noteTitle.className = 'note-title text-left'; 443 | noteTitle.textContent = file.name; 444 | noteTitle.title = file.name; // Add tooltip for long names 445 | noteTitle.style.flex = '1'; 446 | noteTitle.style.minWidth = '0'; 447 | 448 | // Add tags indicator if the file has tags 449 | if (file.tags && file.tags.length > 0) { 450 | const tagsIndicator = document.createElement('span'); 451 | tagsIndicator.className = 'tags-indicator ml-1 text-xs text-indigo-500 flex-shrink-0'; 452 | tagsIndicator.textContent = `(${file.tags.length})`; 453 | tagsIndicator.title = `Tags: ${file.tags.join(', ')}`; 454 | noteTitle.appendChild(tagsIndicator); 455 | } 456 | 457 | // Add delete button 458 | const deleteBtn = document.createElement('button'); 459 | deleteBtn.className = 'delete-btn ml-2 opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 transition-opacity flex-shrink-0'; 460 | deleteBtn.title = 'Delete note'; 461 | deleteBtn.innerHTML = ` 462 | 463 | 464 | 465 | `; 466 | 467 | // Stop propagation to prevent opening the note when clicking the delete button 468 | deleteBtn.addEventListener('click', (e) => { 469 | e.stopPropagation(); 470 | deleteFile(file.path); 471 | }); 472 | 473 | listItem.appendChild(noteIcon); 474 | listItem.appendChild(noteTitle); 475 | listItem.appendChild(deleteBtn); 476 | 477 | // Add hover class to make the list item a proper group for hover actions 478 | listItem.classList.add('group'); 479 | 480 | // Add click event to load the file 481 | listItem.addEventListener('click', () => { 482 | // Remove active class from all list items 483 | document.querySelectorAll('#file-list li').forEach(item => { 484 | item.classList.remove('active'); 485 | }); 486 | 487 | // Add active class to the clicked item 488 | listItem.classList.add('active'); 489 | 490 | // Load the file 491 | loadFile(file.path); 492 | 493 | // Close sidebar on mobile after selecting a note 494 | if (isMobile && sidebar.classList.contains('active')) { 495 | toggleSidebar(); 496 | } 497 | }); 498 | 499 | // Add right-click context menu for file operations 500 | listItem.addEventListener('contextmenu', e => { 501 | e.preventDefault(); 502 | showContextMenu(e, file.path); 503 | }); 504 | 505 | // Add to fragment 506 | fragment.appendChild(listItem); 507 | }); 508 | 509 | // Append all items at once 510 | fileList.appendChild(fragment); 511 | 512 | // Virtual scrolling for large number of notes 513 | if (processedFiles.length > 100) { 514 | enableVirtualScrolling(); 515 | } 516 | } 517 | 518 | // Enable virtual scrolling for large number of notes 519 | function enableVirtualScrolling() { 520 | const fileListContainer = fileList.parentElement; 521 | const items = Array.from(fileList.children); 522 | const itemHeight = items[0]?.offsetHeight || 30; // Default fallback height 523 | 524 | // Keep track of which items are rendered 525 | let renderedItems = new Set(); 526 | let visibleRange = { start: 0, end: 0 }; 527 | 528 | // Function to update which items are visible 529 | function updateVisibleItems() { 530 | const containerTop = fileListContainer.scrollTop; 531 | const containerHeight = fileListContainer.offsetHeight; 532 | 533 | // Calculate which items should be visible (with buffer) 534 | const bufferItems = 10; // Items to render before/after visible area 535 | const startIndex = Math.max(0, Math.floor(containerTop / itemHeight) - bufferItems); 536 | const endIndex = Math.min(items.length - 1, Math.ceil((containerTop + containerHeight) / itemHeight) + bufferItems); 537 | 538 | // If range hasn't changed, don't update DOM 539 | if (visibleRange.start === startIndex && visibleRange.end === endIndex) { 540 | return; 541 | } 542 | 543 | visibleRange = { start: startIndex, end: endIndex }; 544 | 545 | // Hide items that are now outside the visible range 546 | renderedItems.forEach(index => { 547 | if (index < startIndex || index > endIndex) { 548 | items[index].style.display = 'none'; 549 | renderedItems.delete(index); 550 | } 551 | }); 552 | 553 | // Show items that are now inside the visible range 554 | for (let i = startIndex; i <= endIndex; i++) { 555 | if (!renderedItems.has(i) && items[i]) { 556 | items[i].style.display = ''; 557 | renderedItems.add(i); 558 | } 559 | } 560 | } 561 | 562 | // Initial update and add scroll listener 563 | updateVisibleItems(); 564 | fileListContainer.addEventListener('scroll', updateVisibleItems); 565 | } 566 | 567 | // Show context menu for a file 568 | function showContextMenu(e, filePath) { 569 | // Remove any existing context menu 570 | const existingMenu = document.getElementById('context-menu'); 571 | if (existingMenu) { 572 | existingMenu.remove(); 573 | } 574 | 575 | // Create context menu 576 | const contextMenu = document.createElement('div'); 577 | contextMenu.id = 'context-menu'; 578 | contextMenu.className = 'absolute bg-white shadow-lg rounded-lg overflow-hidden z-50 py-1'; 579 | contextMenu.style.left = `${e.pageX}px`; 580 | contextMenu.style.top = `${e.pageY}px`; 581 | 582 | // Rename option 583 | const renameOption = document.createElement('div'); 584 | renameOption.className = 'px-4 py-2 hover:bg-gray-100 cursor-pointer text-sm'; 585 | renameOption.innerHTML = ` 586 | 587 | 588 | 589 | Rename 590 | `; 591 | renameOption.addEventListener('click', () => { 592 | const newName = prompt('Enter new name:', getFilename(filePath).replace('.md', '')); 593 | if (newName) { 594 | // TODO: Implement rename functionality 595 | closeContextMenu(); 596 | } 597 | }); 598 | 599 | // Edit Tags option 600 | const editTagsOption = document.createElement('div'); 601 | editTagsOption.className = 'px-4 py-2 hover:bg-gray-100 cursor-pointer text-sm'; 602 | editTagsOption.innerHTML = ` 603 | 604 | 605 | 606 | Edit Tags 607 | `; 608 | editTagsOption.addEventListener('click', () => { 609 | showAddTagsModal(filePath); 610 | closeContextMenu(); 611 | }); 612 | 613 | // Change Category option 614 | const changeCategoryOption = document.createElement('div'); 615 | changeCategoryOption.className = 'px-4 py-2 hover:bg-gray-100 cursor-pointer text-sm'; 616 | changeCategoryOption.innerHTML = ` 617 | 618 | 619 | 620 | Change Category 621 | `; 622 | changeCategoryOption.addEventListener('click', () => { 623 | // Get the current category 624 | const pathParts = filePath.split('/'); 625 | let currentCategory = ''; 626 | if (pathParts.length > 2) { 627 | currentCategory = pathParts[pathParts.length - 2]; 628 | } 629 | 630 | // Create a dropdown with available categories 631 | let categoryOptions = ''; 632 | categories.forEach(cat => { 633 | const selected = cat === currentCategory ? 'selected' : ''; 634 | categoryOptions += ``; 635 | }); 636 | 637 | // Show custom dialog 638 | const dialog = document.createElement('div'); 639 | dialog.className = 'fixed inset-0 flex items-center justify-center z-50 bg-black bg-opacity-50'; 640 | dialog.innerHTML = ` 641 |
    642 |

    Change Category

    643 | 646 |
    647 | 648 | 649 |
    650 |
    651 | `; 652 | document.body.appendChild(dialog); 653 | 654 | // Handle dialog buttons 655 | document.getElementById('cancel-category').addEventListener('click', () => { 656 | dialog.remove(); 657 | }); 658 | 659 | document.getElementById('save-category').addEventListener('click', () => { 660 | const newCategory = document.getElementById('category-select').value; 661 | changeNoteCategory(filePath, newCategory); 662 | dialog.remove(); 663 | }); 664 | 665 | closeContextMenu(); 666 | }); 667 | 668 | // Delete option 669 | const deleteOption = document.createElement('div'); 670 | deleteOption.className = 'px-4 py-2 hover:bg-gray-100 cursor-pointer text-sm text-red-600'; 671 | deleteOption.innerHTML = ` 672 | 673 | 674 | 675 | Delete 676 | `; 677 | deleteOption.addEventListener('click', () => { 678 | if (confirm('Are you sure you want to delete this note?')) { 679 | deleteFile(filePath); 680 | } 681 | closeContextMenu(); 682 | }); 683 | 684 | contextMenu.appendChild(renameOption); 685 | contextMenu.appendChild(editTagsOption); 686 | contextMenu.appendChild(changeCategoryOption); 687 | contextMenu.appendChild(deleteOption); 688 | document.body.appendChild(contextMenu); 689 | 690 | // Close menu when clicking outside 691 | document.addEventListener('click', closeContextMenu); 692 | 693 | // Close menu when scrolling 694 | document.addEventListener('scroll', closeContextMenu); 695 | 696 | // Prevent menu from going off-screen 697 | setTimeout(() => { 698 | const menuRect = contextMenu.getBoundingClientRect(); 699 | if (menuRect.right > window.innerWidth) { 700 | contextMenu.style.left = `${window.innerWidth - menuRect.width - 5}px`; 701 | } 702 | if (menuRect.bottom > window.innerHeight) { 703 | contextMenu.style.top = `${window.innerHeight - menuRect.height - 5}px`; 704 | } 705 | }, 0); 706 | 707 | function closeContextMenu() { 708 | contextMenu.remove(); 709 | document.removeEventListener('click', closeContextMenu); 710 | document.removeEventListener('scroll', closeContextMenu); 711 | } 712 | } 713 | 714 | // Load a file into the editor 715 | function loadFile(path) { 716 | // Need to use the full path for files in categories 717 | fetch(`/api/files/${encodeURIComponent(path)}`) 718 | .then(response => response.json()) 719 | .then(data => { 720 | if (data.status === 'success') { 721 | // Update the editor and preview 722 | editor.value = data.data; 723 | updatePreview(); 724 | 725 | // Store the full path for saving 726 | currentFile = path; 727 | 728 | // Display just the filename in the UI 729 | currentFilename.textContent = getFilename(path); 730 | 731 | // Update active file in the list 732 | const fileItems = fileList.querySelectorAll('li'); 733 | fileItems.forEach(item => { 734 | item.classList.remove('active'); 735 | if (item.getAttribute('data-path') === path) { 736 | item.classList.add('active'); 737 | } 738 | }); 739 | 740 | // Show content container, hide empty state 741 | emptyState.classList.add('hidden'); 742 | contentContainer.classList.remove('hidden'); 743 | 744 | // Set to preview mode initially 745 | setViewMode('preview'); 746 | 747 | // Add file name to document title 748 | document.title = `${getFilename(path)} - mdlib Personal Wiki`; 749 | } else { 750 | console.error('Error loading file:', data.message); 751 | alert(`Error loading file: ${data.message}`); 752 | } 753 | }) 754 | .catch(error => { 755 | console.error('Error loading file:', error); 756 | alert('Error loading file. Please try again.'); 757 | }); 758 | } 759 | 760 | // Update the preview with the current editor content 761 | function updatePreview() { 762 | if (!editor.value) { 763 | preview.innerHTML = '
    Nothing to preview
    '; 764 | return; 765 | } 766 | 767 | try { 768 | // Sanitize HTML to prevent XSS 769 | const sanitizedHtml = DOMPurify.sanitize(marked.parse(editor.value)); 770 | preview.innerHTML = sanitizedHtml; 771 | 772 | // Apply syntax highlighting to code blocks 773 | if (typeof hljs !== 'undefined') { 774 | preview.querySelectorAll('pre code').forEach(block => { 775 | hljs.highlightBlock(block); 776 | }); 777 | } 778 | } catch (error) { 779 | console.error('Error rendering markdown:', error); 780 | preview.innerHTML = `
    Error rendering markdown: ${error.message}
    `; 781 | } 782 | } 783 | 784 | // Save the current file 785 | function saveCurrentFile() { 786 | if (!currentFile) { 787 | alert('No file is currently open.'); 788 | return; 789 | } 790 | 791 | const content = editor.value; 792 | 793 | // Use the full path stored in currentFile 794 | fetch(`/api/files/${encodeURIComponent(currentFile)}`, { 795 | method: 'PUT', 796 | headers: { 797 | 'Content-Type': 'application/json' 798 | }, 799 | body: JSON.stringify({ content }) 800 | }) 801 | .then(response => response.json()) 802 | .then(data => { 803 | if (data.status === 'success') { 804 | // Show success message 805 | const saveBtn = document.getElementById('btn-save'); 806 | const originalText = saveBtn.innerHTML; 807 | 808 | saveBtn.innerHTML = ` 809 | 810 | 811 | 812 | Saved! 813 | `; 814 | 815 | setTimeout(() => { 816 | saveBtn.innerHTML = originalText; 817 | }, 2000); 818 | 819 | // Update preview 820 | updatePreview(); 821 | } else { 822 | console.error('Error saving file:', data.message); 823 | alert(`Error saving file: ${data.message}`); 824 | } 825 | }) 826 | .catch(error => { 827 | console.error('Error saving file:', error); 828 | alert('Error saving file. Please try again.'); 829 | }); 830 | } 831 | 832 | // Schedule auto-save 833 | function scheduleAutoSave() { 834 | if (autoSaveTimeout) { 835 | clearTimeout(autoSaveTimeout); 836 | } 837 | 838 | autoSaveTimeout = setTimeout(() => { 839 | if (currentFile && isEditing) { 840 | saveCurrentFile(); 841 | } 842 | }, 5000); // Auto-save after 5 seconds of inactivity 843 | } 844 | 845 | // Create a new note 846 | function createNewNote() { 847 | const name = newNoteName.value.trim(); 848 | 849 | if (!name) { 850 | alert('Please enter a name for the new note.'); 851 | return; 852 | } 853 | 854 | // Add .md extension if not present 855 | const fileName = name.endsWith('.md') ? name : `${name}.md`; 856 | 857 | // Get category and tags 858 | const category = newNoteCategory.value.trim(); 859 | const tagsInput = newNoteTags.value.trim(); 860 | const tags = tagsInput ? tagsInput.split(',').map(tag => tag.trim()).filter(tag => tag) : []; 861 | 862 | // Generate frontmatter if we have category or tags 863 | let frontmatter = ''; 864 | if (category || tags.length > 0) { 865 | frontmatter = '---\n'; 866 | if (category) { 867 | frontmatter += `category: ${category}\n`; 868 | } 869 | if (tags.length > 0) { 870 | frontmatter += `tags: [${tags.join(', ')}]\n`; 871 | } 872 | frontmatter += '---\n\n'; 873 | } 874 | 875 | // Create content with frontmatter 876 | const content = frontmatter + '# ' + name + '\n\nStart writing your markdown here...'; 877 | 878 | // Create the file 879 | fetch('/api/files', { 880 | method: 'POST', 881 | headers: { 882 | 'Content-Type': 'application/json' 883 | }, 884 | body: JSON.stringify({ 885 | name: fileName, 886 | content: content 887 | }) 888 | }) 889 | .then(response => response.json()) 890 | .then(data => { 891 | if (data.status === 'success') { 892 | // Close the modal 893 | hideNewNoteModal(); 894 | 895 | // Refresh file list 896 | loadFiles(); 897 | 898 | // Load the new file 899 | setTimeout(() => { 900 | loadFile(data.data); 901 | 902 | // Switch to edit mode for new files 903 | setViewMode('split'); 904 | }, 300); 905 | } else { 906 | console.error('Error creating file:', data.message); 907 | alert(`Error creating file: ${data.message}`); 908 | } 909 | }) 910 | .catch(error => { 911 | console.error('Error creating file:', error); 912 | alert('Error creating file. Please try again.'); 913 | }); 914 | } 915 | 916 | // Search files 917 | function searchFiles() { 918 | const query = searchInput.value.trim(); 919 | 920 | if (!query) { 921 | loadFiles(); 922 | return; 923 | } 924 | 925 | fetch(`/api/search?q=${encodeURIComponent(query)}`) 926 | .then(response => response.json()) 927 | .then(data => { 928 | if (data.status === 'success') { 929 | displayFiles(data.data); 930 | } else { 931 | fileList.innerHTML = '
  • Error searching files
  • '; 932 | console.error('Error searching files:', data.message); 933 | } 934 | }) 935 | .catch(error => { 936 | fileList.innerHTML = '
  • Error searching files
  • '; 937 | console.error('Error searching files:', error); 938 | }); 939 | } 940 | 941 | // Apply formatting to the editor 942 | function applyFormat(format) { 943 | if (!isEditing) return; 944 | 945 | const start = editor.selectionStart; 946 | const end = editor.selectionEnd; 947 | const selectedText = editor.value.substring(start, end); 948 | let replacement = ''; 949 | 950 | switch (format) { 951 | case 'bold': 952 | replacement = `**${selectedText}**`; 953 | break; 954 | case 'italic': 955 | replacement = `*${selectedText}*`; 956 | break; 957 | case 'heading': 958 | replacement = `# ${selectedText}`; 959 | break; 960 | case 'link': 961 | replacement = `[${selectedText}](url)`; 962 | break; 963 | case 'image': 964 | replacement = `![${selectedText}](image-url)`; 965 | break; 966 | case 'list': 967 | replacement = selectedText 968 | .split('\n') 969 | .map(line => line.trim() ? `- ${line}` : line) 970 | .join('\n'); 971 | break; 972 | case 'code': 973 | replacement = selectedText.includes('\n') 974 | ? '```\n' + selectedText + '\n```' 975 | : '`' + selectedText + '`'; 976 | break; 977 | } 978 | 979 | editor.value = editor.value.substring(0, start) + replacement + editor.value.substring(end); 980 | editor.focus(); 981 | 982 | // Update preview 983 | updatePreview(); 984 | 985 | // Schedule auto-save 986 | scheduleAutoSave(); 987 | } 988 | 989 | // Handle keyboard shortcuts 990 | function handleKeyboardShortcuts(e) { 991 | // Only process if we're in edit mode 992 | if (!isEditing) return; 993 | 994 | // Cmd/Ctrl + S to save 995 | if ((e.metaKey || e.ctrlKey) && e.key === 's') { 996 | e.preventDefault(); 997 | saveCurrentFile(); 998 | } 999 | 1000 | // Process other shortcuts only if we have a file open and the editor is focused 1001 | if (currentFile && document.activeElement === editor) { 1002 | // Cmd/Ctrl + B for bold 1003 | if ((e.metaKey || e.ctrlKey) && e.key === 'b') { 1004 | e.preventDefault(); 1005 | applyFormat('bold'); 1006 | } 1007 | 1008 | // Cmd/Ctrl + I for italic 1009 | if ((e.metaKey || e.ctrlKey) && e.key === 'i') { 1010 | e.preventDefault(); 1011 | applyFormat('italic'); 1012 | } 1013 | 1014 | // Cmd/Ctrl + K for link 1015 | if ((e.metaKey || e.ctrlKey) && e.key === 'k') { 1016 | e.preventDefault(); 1017 | applyFormat('link'); 1018 | } 1019 | } 1020 | } 1021 | 1022 | // Extract filename from path 1023 | function getFilename(path) { 1024 | return path.split('/').pop(); 1025 | } 1026 | 1027 | // Debounce function to limit how often a function is called 1028 | function debounce(func, delay) { 1029 | let timeout; 1030 | return function executedFunction(...args) { 1031 | const later = () => { 1032 | clearTimeout(timeout); 1033 | func(...args); 1034 | }; 1035 | 1036 | clearTimeout(timeout); 1037 | timeout = setTimeout(later, delay); 1038 | }; 1039 | } 1040 | 1041 | // Show add tags modal 1042 | function showAddTagsModal(filePath) { 1043 | tagInput.value = ''; 1044 | addTagsModal.setAttribute('data-file', filePath || currentFile); 1045 | addTagsModal.classList.remove('hidden'); 1046 | tagInput.focus(); 1047 | } 1048 | 1049 | // Hide add tags modal 1050 | function hideAddTagsModal() { 1051 | addTagsModal.classList.add('hidden'); 1052 | tagInput.value = ''; 1053 | } 1054 | 1055 | // Add tags to current file 1056 | function addTagsToCurrentFile() { 1057 | const filePath = addTagsModal.getAttribute('data-file'); 1058 | if (!filePath) { 1059 | alert('No file selected.'); 1060 | return; 1061 | } 1062 | 1063 | const tagsInput = tagInput.value.trim(); 1064 | if (!tagsInput) { 1065 | alert('Please enter at least one tag.'); 1066 | return; 1067 | } 1068 | 1069 | const tags = tagsInput.split(',').map(tag => tag.trim()).filter(tag => tag); 1070 | 1071 | // Use the full file path 1072 | fetch(`/api/tags/${encodeURIComponent(filePath)}`, { 1073 | method: 'PUT', 1074 | headers: { 1075 | 'Content-Type': 'application/json' 1076 | }, 1077 | body: JSON.stringify({ tags }) 1078 | }) 1079 | .then(response => response.json()) 1080 | .then(data => { 1081 | if (data.status === 'success') { 1082 | // Close the modal 1083 | hideAddTagsModal(); 1084 | 1085 | // Refresh file list to show updated tags 1086 | loadFiles(); 1087 | 1088 | // If this was the current file, reload it to show updated content 1089 | if (filePath === currentFile) { 1090 | loadFile(filePath); 1091 | } 1092 | 1093 | // Update tags display 1094 | updateTagsDisplay(); 1095 | } else { 1096 | console.error('Error adding tags:', data.message); 1097 | alert(`Error adding tags: ${data.message}`); 1098 | } 1099 | }) 1100 | .catch(error => { 1101 | console.error('Error adding tags:', error); 1102 | alert('Error adding tags. Please try again.'); 1103 | }); 1104 | } 1105 | 1106 | // Load categories 1107 | function loadCategories() { 1108 | fetch('/api/categories') 1109 | .then(response => response.json()) 1110 | .then(data => { 1111 | if (data.status === 'success') { 1112 | categories = data.data; 1113 | updateCategoriesDisplay(); 1114 | } else { 1115 | categoryList.innerHTML = '
  • Error loading categories
  • '; 1116 | console.error('Error loading categories:', data.message); 1117 | } 1118 | }) 1119 | .catch(error => { 1120 | categoryList.innerHTML = '
  • Error loading categories
  • '; 1121 | console.error('Error loading categories:', error); 1122 | }); 1123 | } 1124 | 1125 | // Update categories display 1126 | function updateCategoriesDisplay() { 1127 | categoryList.innerHTML = ''; 1128 | 1129 | if (categories.length === 0) { 1130 | const emptyItem = document.createElement('li'); 1131 | emptyItem.className = 'category-empty'; 1132 | emptyItem.textContent = 'No categories'; 1133 | categoryList.appendChild(emptyItem); 1134 | return; 1135 | } 1136 | 1137 | // Sort categories alphabetically 1138 | categories.sort((a, b) => a.localeCompare(b)); 1139 | 1140 | const fragment = document.createDocumentFragment(); 1141 | 1142 | // Add "All Notes" option 1143 | const allNotesItem = document.createElement('li'); 1144 | allNotesItem.className = 'category-item'; 1145 | allNotesItem.style.justifyContent = 'flex-start'; 1146 | allNotesItem.style.textAlign = 'left'; 1147 | allNotesItem.innerHTML = ` 1148 | 1149 | 1150 | 1151 | All Notes 1152 | `; 1153 | 1154 | allNotesItem.addEventListener('click', () => { 1155 | document.querySelectorAll('.category-item').forEach(item => { 1156 | item.classList.remove('active'); 1157 | }); 1158 | allNotesItem.classList.add('active'); 1159 | loadFiles(); 1160 | }); 1161 | 1162 | fragment.appendChild(allNotesItem); 1163 | 1164 | // Add each category 1165 | categories.forEach(category => { 1166 | const listItem = document.createElement('li'); 1167 | listItem.className = 'category-item'; 1168 | listItem.setAttribute('data-category', category); 1169 | listItem.style.justifyContent = 'flex-start'; 1170 | listItem.style.textAlign = 'left'; 1171 | 1172 | listItem.innerHTML = ` 1173 | 1174 | 1175 | 1176 | ${category} 1177 | 1182 | `; 1183 | 1184 | // Add click event to filter by category 1185 | listItem.addEventListener('click', (e) => { 1186 | // Don't trigger if the delete button was clicked 1187 | if (e.target.closest('.category-delete-btn')) { 1188 | return; 1189 | } 1190 | 1191 | document.querySelectorAll('.category-item').forEach(item => { 1192 | item.classList.remove('active'); 1193 | }); 1194 | listItem.classList.add('active'); 1195 | 1196 | searchByCategory(category); 1197 | }); 1198 | 1199 | // Add click event to delete button 1200 | const deleteBtn = listItem.querySelector('.category-delete-btn'); 1201 | deleteBtn.addEventListener('click', (e) => { 1202 | e.stopPropagation(); 1203 | if (confirm(`Are you sure you want to delete the category "${category}"? This will not delete the notes in this category.`)) { 1204 | deleteCategory(category); 1205 | } 1206 | }); 1207 | 1208 | fragment.appendChild(listItem); 1209 | }); 1210 | 1211 | categoryList.appendChild(fragment); 1212 | 1213 | // Also update the category dropdown in the new note modal 1214 | updateCategoryDropdown(); 1215 | } 1216 | 1217 | // Update the category dropdown in the new note modal 1218 | function updateCategoryDropdown() { 1219 | newNoteCategory.innerHTML = ''; 1220 | 1221 | categories.forEach(category => { 1222 | const option = document.createElement('option'); 1223 | option.value = category; 1224 | option.textContent = category; 1225 | newNoteCategory.appendChild(option); 1226 | }); 1227 | } 1228 | 1229 | // Create a new category 1230 | function createCategory() { 1231 | const name = newCategoryInput.value.trim(); 1232 | 1233 | if (!name) { 1234 | alert('Please enter a category name.'); 1235 | return; 1236 | } 1237 | 1238 | fetch('/api/category', { 1239 | method: 'POST', 1240 | headers: { 1241 | 'Content-Type': 'application/json' 1242 | }, 1243 | body: JSON.stringify({ name }) 1244 | }) 1245 | .then(response => response.json()) 1246 | .then(data => { 1247 | if (data.status === 'success') { 1248 | // Clear input and hide form 1249 | newCategoryInput.value = ''; 1250 | hideCategoryForm(); 1251 | 1252 | // Show success message 1253 | const successMessage = document.createElement('div'); 1254 | successMessage.className = 'fixed bottom-4 right-4 bg-green-100 border-l-4 border-green-500 text-green-700 p-4 rounded shadow-md'; 1255 | successMessage.innerHTML = `

    Category "${name}" created successfully!

    `; 1256 | document.body.appendChild(successMessage); 1257 | 1258 | // Remove success message after 3 seconds 1259 | setTimeout(() => { 1260 | successMessage.remove(); 1261 | }, 3000); 1262 | 1263 | // Reload categories 1264 | loadCategories(); 1265 | } else { 1266 | console.error('Error creating category:', data.message); 1267 | alert(`Error creating category: ${data.message}`); 1268 | } 1269 | }) 1270 | .catch(error => { 1271 | console.error('Error creating category:', error); 1272 | alert('Error creating category. Please try again.'); 1273 | }); 1274 | } 1275 | 1276 | // Update tags display 1277 | function updateTagsDisplay() { 1278 | // Collect all tags from files 1279 | allTags = new Set(); 1280 | 1281 | fetch('/api/files') 1282 | .then(response => response.json()) 1283 | .then(data => { 1284 | if (data.status === 'success') { 1285 | // Process all files and collect tags 1286 | data.data.forEach(file => { 1287 | if (file.tags && Array.isArray(file.tags)) { 1288 | file.tags.forEach(tag => allTags.add(tag)); 1289 | } 1290 | }); 1291 | 1292 | // Display the collected tags 1293 | if (allTags.size === 0) { 1294 | tagsContainer.innerHTML = 'No tags found'; 1295 | return; 1296 | } 1297 | 1298 | tagsContainer.innerHTML = ''; 1299 | 1300 | // Add each tag 1301 | Array.from(allTags).sort().forEach(tag => { 1302 | const tagSpan = document.createElement('span'); 1303 | tagSpan.className = 'tag text-xs bg-indigo-100 text-indigo-800 rounded px-2 py-1 cursor-pointer hover:bg-indigo-200 flex items-center gap-1'; 1304 | 1305 | // Tag text 1306 | const tagText = document.createElement('span'); 1307 | tagText.textContent = tag; 1308 | tagSpan.appendChild(tagText); 1309 | 1310 | tagSpan.addEventListener('click', () => { 1311 | searchByTag(tag); 1312 | }); 1313 | 1314 | tagsContainer.appendChild(tagSpan); 1315 | }); 1316 | } 1317 | }) 1318 | .catch(error => { 1319 | console.error('Error loading tags:', error); 1320 | tagsContainer.innerHTML = 'Error loading tags'; 1321 | }); 1322 | } 1323 | 1324 | // Search by category 1325 | function searchByCategory(category) { 1326 | fetch(`/api/search?category=${encodeURIComponent(category)}`) 1327 | .then(response => response.json()) 1328 | .then(data => { 1329 | if (data.status === 'success') { 1330 | displayFiles(data.data); 1331 | } else { 1332 | fileList.innerHTML = '
  • Error searching files
  • '; 1333 | console.error('Error searching files:', data.message); 1334 | } 1335 | }) 1336 | .catch(error => { 1337 | fileList.innerHTML = '
  • Error searching files
  • '; 1338 | console.error('Error searching files:', error); 1339 | }); 1340 | } 1341 | 1342 | // Search by tag 1343 | function searchByTag(tag) { 1344 | fetch(`/api/search?tag=${encodeURIComponent(tag)}`) 1345 | .then(response => response.json()) 1346 | .then(data => { 1347 | if (data.status === 'success') { 1348 | displayFiles(data.data); 1349 | } else { 1350 | fileList.innerHTML = '
  • Error searching files
  • '; 1351 | console.error('Error searching files:', data.message); 1352 | } 1353 | }) 1354 | .catch(error => { 1355 | fileList.innerHTML = '
  • Error searching files
  • '; 1356 | console.error('Error searching files:', error); 1357 | }); 1358 | } 1359 | 1360 | // Toggle category form visibility 1361 | function toggleCategoryForm() { 1362 | if (categoryForm.classList.contains('hidden')) { 1363 | showCategoryForm(); 1364 | } else { 1365 | hideCategoryForm(); 1366 | } 1367 | } 1368 | 1369 | // Show category form 1370 | function showCategoryForm() { 1371 | categoryForm.classList.remove('hidden'); 1372 | newCategoryInput.focus(); 1373 | } 1374 | 1375 | // Hide category form 1376 | function hideCategoryForm() { 1377 | categoryForm.classList.add('hidden'); 1378 | newCategoryInput.value = ''; 1379 | } 1380 | 1381 | // Delete a file 1382 | function deleteFile(path) { 1383 | if (!confirm(`Are you sure you want to delete this note?`)) { 1384 | return; 1385 | } 1386 | 1387 | fetch(`/api/files/${encodeURIComponent(path)}`, { 1388 | method: 'DELETE' 1389 | }) 1390 | .then(response => response.json()) 1391 | .then(data => { 1392 | if (data.status === 'success') { 1393 | // If the current file is the one being deleted, clear the editor 1394 | if (currentFile === path) { 1395 | currentFile = null; 1396 | editor.value = ''; 1397 | preview.innerHTML = ''; 1398 | emptyState.classList.remove('hidden'); 1399 | contentContainer.classList.add('hidden'); 1400 | document.title = 'mdlib Personal Wiki'; 1401 | } 1402 | 1403 | // Refresh the file list 1404 | loadFiles(); 1405 | 1406 | // Show success message 1407 | showToast('Note deleted successfully', 'success'); 1408 | } else { 1409 | console.error('Error deleting file:', data.message); 1410 | showToast(`Error deleting note: ${data.message}`, 'error'); 1411 | } 1412 | }) 1413 | .catch(error => { 1414 | console.error('Error deleting file:', error); 1415 | showToast('Error deleting note. Please try again.', 'error'); 1416 | }); 1417 | } 1418 | 1419 | // Remove a tag from a file 1420 | function removeTagFromFile(filePath, tag) { 1421 | fetch(`/api/tags/${encodeURIComponent(filePath)}`, { 1422 | method: 'DELETE', 1423 | headers: { 1424 | 'Content-Type': 'application/json' 1425 | }, 1426 | body: JSON.stringify({ tags: [tag] }) 1427 | }) 1428 | .then(response => response.json()) 1429 | .then(data => { 1430 | if (data.status === 'success') { 1431 | // Refresh the file if it's currently open 1432 | if (currentFile === filePath) { 1433 | loadFile(filePath); 1434 | } 1435 | 1436 | // Refresh the file list 1437 | loadFiles(); 1438 | 1439 | // Show success message 1440 | showToast(`Tag "${tag}" removed successfully`, 'success'); 1441 | 1442 | // Update tags display 1443 | updateTagsDisplay(); 1444 | } else { 1445 | console.error('Error removing tag:', data.message); 1446 | showToast(`Error removing tag: ${data.message}`, 'error'); 1447 | } 1448 | }) 1449 | .catch(error => { 1450 | console.error('Error removing tag:', error); 1451 | showToast('Error removing tag. Please try again.', 'error'); 1452 | }); 1453 | } 1454 | 1455 | // Delete a category 1456 | function deleteCategory(category) { 1457 | if (!confirm(`Are you sure you want to delete the category "${category}"?`)) { 1458 | return; 1459 | } 1460 | 1461 | fetch(`/api/category/${encodeURIComponent(category)}`, { 1462 | method: 'DELETE' 1463 | }) 1464 | .then(response => response.json()) 1465 | .then(data => { 1466 | if (data.status === 'success') { 1467 | // Refresh categories 1468 | loadCategories(); 1469 | // Refresh files 1470 | loadFiles(); 1471 | 1472 | // Show success message 1473 | showToast(`Category "${category}" deleted successfully`, 'success'); 1474 | } else { 1475 | console.error('Error deleting category:', data.message); 1476 | showToast(`Error: ${data.message}`, 'error'); 1477 | } 1478 | }) 1479 | .catch(error => { 1480 | console.error('Error deleting category:', error); 1481 | showToast('Error deleting category. Please try again.', 'error'); 1482 | }); 1483 | } 1484 | 1485 | // Change a note's category 1486 | function changeNoteCategory(filePath, newCategory) { 1487 | // Extract the filename from the path 1488 | const fileName = filePath.split('/').pop(); 1489 | 1490 | fetch('/api/move', { 1491 | method: 'POST', 1492 | headers: { 1493 | 'Content-Type': 'application/json' 1494 | }, 1495 | body: JSON.stringify({ 1496 | path: filePath, 1497 | newPath: newCategory ? `/${newCategory}/${fileName}` : `/${fileName}` 1498 | }) 1499 | }) 1500 | .then(response => response.json()) 1501 | .then(data => { 1502 | if (data.status === 'success') { 1503 | // If the current file is the one being moved, update currentFile 1504 | if (currentFile === filePath) { 1505 | currentFile = data.newPath; 1506 | } 1507 | 1508 | // Refresh the file list 1509 | loadFiles(); 1510 | 1511 | // Show success message 1512 | showToast('Note category changed successfully', 'success'); 1513 | } else { 1514 | console.error('Error changing category:', data.message); 1515 | showToast(`Error: ${data.message}`, 'error'); 1516 | } 1517 | }) 1518 | .catch(error => { 1519 | console.error('Error changing category:', error); 1520 | showToast('Error changing category. Please try again.', 'error'); 1521 | }); 1522 | } 1523 | 1524 | // Show toast notification 1525 | function showToast(message, type = 'success') { 1526 | const toastContainer = document.querySelector('.toast-container'); 1527 | if (!toastContainer) return; 1528 | 1529 | const toast = document.createElement('div'); 1530 | toast.className = `toast toast-${type}`; 1531 | toast.textContent = message; 1532 | 1533 | toastContainer.appendChild(toast); 1534 | 1535 | // Trigger reflow to enable CSS transition 1536 | toast.offsetHeight; 1537 | 1538 | // Show toast 1539 | toast.style.opacity = '1'; 1540 | toast.style.transform = 'translateY(0)'; 1541 | 1542 | // Remove toast after 3 seconds 1543 | setTimeout(() => { 1544 | toast.style.opacity = '0'; 1545 | toast.style.transform = 'translateY(10px)'; 1546 | 1547 | // Remove from DOM after fade out 1548 | setTimeout(() => { 1549 | toast.remove(); 1550 | }, 300); 1551 | }, 3000); 1552 | } 1553 | }); --------------------------------------------------------------------------------