chore(refactor): add templater
This commit is contained in:
parent
0c272328f8
commit
4eb8159869
114
Cargo.lock
generated
114
Cargo.lock
generated
@ -67,6 +67,61 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "askama"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28"
|
||||
dependencies = [
|
||||
"askama_derive",
|
||||
"askama_escape",
|
||||
"humansize",
|
||||
"num-traits",
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "askama_axum"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a41603f7cdbf5ac4af60760f17253eb6adf6ec5b6f14a7ed830cf687d375f163"
|
||||
dependencies = [
|
||||
"askama",
|
||||
"axum-core",
|
||||
"http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "askama_derive"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83"
|
||||
dependencies = [
|
||||
"askama_parser",
|
||||
"basic-toml",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "askama_escape"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341"
|
||||
|
||||
[[package]]
|
||||
name = "askama_parser"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0"
|
||||
dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.40"
|
||||
@ -96,6 +151,12 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.7.9"
|
||||
@ -157,6 +218,15 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "basic-toml"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bincode"
|
||||
version = "1.3.3"
|
||||
@ -528,6 +598,15 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "humansize"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7"
|
||||
dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.8.1"
|
||||
@ -638,6 +717,12 @@ version = "0.2.182"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.12"
|
||||
@ -687,8 +772,10 @@ checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||
|
||||
[[package]]
|
||||
name = "mdpreview"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"askama",
|
||||
"askama_axum",
|
||||
"axum",
|
||||
"clap",
|
||||
"futures",
|
||||
@ -726,6 +813,12 @@ dependencies = [
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@ -759,6 +852,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify"
|
||||
version = "6.1.1"
|
||||
@ -793,6 +896,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
|
||||
14
Cargo.toml
14
Cargo.toml
@ -1,15 +1,15 @@
|
||||
[package]
|
||||
name = "mdpreview"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
edition = "2024"
|
||||
authors = ["Vladislav Kan <thek4n@yandex.ru>"]
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z" # Оптимизация именно по размеру (z > s)
|
||||
lto = true # Link Time Optimization: объединяет и оптимизирует весь код целиком
|
||||
codegen-units = 1 # Уменьшает количество параллельных единиц компиляции, позволяя лучше оптимизировать
|
||||
panic = "abort" # Отключает механизм развертывания стека при панике (экономит много места)
|
||||
strip = true # Автоматически удаляет символы отладки (доступно в стабильной версии Rust 1.59+)
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = true
|
||||
|
||||
|
||||
[dependencies]
|
||||
@ -26,3 +26,5 @@ clap = { version = "4.5", features = ["derive"] }
|
||||
tower-http = { version = "0.6.8", features = ["trace", "compression-gzip", "cors"] }
|
||||
mime_guess = "2"
|
||||
rand = "0.8"
|
||||
askama = { version = "0.12", features = ["with-axum"] }
|
||||
askama_axum = "0.4"
|
||||
|
||||
494
src/main.rs
494
src/main.rs
@ -1,8 +1,9 @@
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{Path, State},
|
||||
extract::{Path as AxumPath, State},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
response::{Html, IntoResponse, Sse},
|
||||
response::{IntoResponse, Redirect, Sse},
|
||||
routing::get,
|
||||
};
|
||||
use futures::StreamExt;
|
||||
@ -11,9 +12,8 @@ use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use rand::seq::SliceRandom;
|
||||
use std::convert::Infallible;
|
||||
use std::ffi::OsStr;
|
||||
use std::fmt::Write;
|
||||
use std::net::{SocketAddr, ToSocketAddrs};
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path as StdPath, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::fs;
|
||||
@ -34,21 +34,38 @@ use markdown::markdown_to_html;
|
||||
mod other;
|
||||
use other::code_to_html;
|
||||
|
||||
const TEMPLATE_FILE: &str = include_str!("../templates/file.html");
|
||||
const TEMPLATE_DIR: &str = include_str!("../templates/dir.html");
|
||||
#[derive(Clone)]
|
||||
pub struct FileEntry {
|
||||
pub name: String,
|
||||
pub link: String,
|
||||
pub is_dir: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "dir.html")]
|
||||
pub struct DirectoryTemplate {
|
||||
pub title_path: String,
|
||||
pub files: Vec<FileEntry>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "file.html")]
|
||||
pub struct NoteTemplate {
|
||||
pub filename: String,
|
||||
pub back_link: String,
|
||||
pub content: String,
|
||||
pub sse_url: String,
|
||||
}
|
||||
|
||||
#[derive(clap::Parser, Debug)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
struct Args {
|
||||
/// Host to listen
|
||||
#[arg(long, default_value_t = String::from("localhost"))]
|
||||
host: String,
|
||||
|
||||
/// Port to listen
|
||||
#[arg(short, long, default_value_t = 8080)]
|
||||
port: u16,
|
||||
|
||||
/// Markdown documents directory root
|
||||
#[arg()]
|
||||
root: PathBuf,
|
||||
}
|
||||
@ -66,13 +83,13 @@ async fn main() {
|
||||
let args = Args::parse();
|
||||
|
||||
if !args.root.is_dir() {
|
||||
eprintln!("Root {root} is not a directory", root = args.root.display());
|
||||
eprintln!("Root {} is not a directory", args.root.display());
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::EnvFilter::new(
|
||||
std::env::var("RUST_LOG").unwrap_or_else(|_| "info,tower_http=info".into()),
|
||||
std::env::var("RUST_LOG").unwrap_or_else(|_| "info,tower_http=warn".into()),
|
||||
))
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
@ -86,7 +103,7 @@ async fn main() {
|
||||
syntax_set: Arc::new(ss),
|
||||
theme_set: Arc::new(ts),
|
||||
tx: Arc::new(tx),
|
||||
root: args.root,
|
||||
root: args.root.clone(),
|
||||
};
|
||||
|
||||
let watcher_state = state.clone();
|
||||
@ -95,37 +112,188 @@ async fn main() {
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(root))
|
||||
.route("/", get(root_handler))
|
||||
.route("/random", get(random_file))
|
||||
.route("/*path", get(serve_file))
|
||||
.route("/events/*path", get(sse_handler))
|
||||
.with_state(state)
|
||||
.layer(TraceLayer::new_for_http());
|
||||
|
||||
let addr = resolve_addr(&args.host, args.port).unwrap();
|
||||
|
||||
let addr = resolve_addr(&args.host, args.port).expect("Failed to resolve address");
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
println!("Server started on {addr}");
|
||||
println!("🚀 Server started on http://{addr}");
|
||||
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
|
||||
fn resolve_addr(host: &str, port: u16) -> io::Result<SocketAddr> {
|
||||
let addr_str = format!("{host}:{port}");
|
||||
|
||||
addr_str
|
||||
.to_socket_addrs()?
|
||||
.next()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Cannot resolve addr"))
|
||||
}
|
||||
|
||||
async fn root(State(state): State<AppState>) -> Result<Html<String>, StatusCode> {
|
||||
render_directory_index(&state.root, "").await
|
||||
async fn root_handler(State(state): State<AppState>) -> impl IntoResponse {
|
||||
match render_directory_index(&state.root, "").await {
|
||||
Ok(template) => template.into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_file(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(full_path): AxumPath<String>,
|
||||
) -> impl IntoResponse {
|
||||
if full_path.is_empty() {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
let mut requested_path = state.root.clone();
|
||||
requested_path.push(&full_path);
|
||||
|
||||
let Ok(safe_path) = fs::canonicalize(&requested_path).await else {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
};
|
||||
|
||||
let Ok(base_dir) = fs::canonicalize(&state.root).await else {
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
};
|
||||
|
||||
if !safe_path.starts_with(&base_dir) {
|
||||
eprintln!("Path traversal attempt: {}", safe_path.display());
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
let metadata = match fs::metadata(&safe_path).await {
|
||||
Ok(m) => m,
|
||||
Err(_) => return Err(StatusCode::NOT_FOUND),
|
||||
};
|
||||
|
||||
if metadata.is_dir() {
|
||||
return match render_directory_index(&safe_path, &full_path).await {
|
||||
Ok(t) => Ok(t.into_response()),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
}
|
||||
|
||||
let extension = safe_path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
let is_image = matches!(
|
||||
extension.as_str(),
|
||||
"png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp" | "ico"
|
||||
);
|
||||
|
||||
if is_image {
|
||||
let file_content = match fs::read(&safe_path).await {
|
||||
Ok(content) => content,
|
||||
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
};
|
||||
let mime_type = from_path(&safe_path).first_or_octet_stream();
|
||||
return Ok(([(header::CONTENT_TYPE, mime_type.as_ref())], file_content).into_response());
|
||||
}
|
||||
|
||||
let content = match fs::read_to_string(&safe_path).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
};
|
||||
|
||||
let html_content = if extension == "md" {
|
||||
markdown_to_html(&content, &state.syntax_set, &state.theme_set, &full_path)
|
||||
} else {
|
||||
code_to_html(
|
||||
&content,
|
||||
extension.as_str(),
|
||||
&state.syntax_set,
|
||||
&state.theme_set,
|
||||
)
|
||||
};
|
||||
|
||||
let filename = safe_path
|
||||
.file_name()
|
||||
.and_then(OsStr::to_str)
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
|
||||
let back_link = if let Some(pos) = full_path.rfind('/') {
|
||||
let parent = &full_path[..pos];
|
||||
if parent.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
format!("/{parent}")
|
||||
}
|
||||
} else {
|
||||
"/".to_string()
|
||||
};
|
||||
|
||||
let sse_url = format!("/events/{full_path}");
|
||||
|
||||
let template = NoteTemplate {
|
||||
filename,
|
||||
back_link,
|
||||
content: html_content,
|
||||
sse_url,
|
||||
};
|
||||
|
||||
Ok(template.into_response())
|
||||
}
|
||||
|
||||
async fn render_directory_index(
|
||||
dir_path: &StdPath,
|
||||
request_path: &str,
|
||||
) -> Result<DirectoryTemplate, StatusCode> {
|
||||
let mut entries = match fs::read_dir(dir_path).await {
|
||||
Ok(list) => list,
|
||||
Err(_) => return Err(StatusCode::FORBIDDEN),
|
||||
};
|
||||
|
||||
let mut files: Vec<FileEntry> = Vec::new();
|
||||
|
||||
while let Some(entry) = entries
|
||||
.next_entry()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
{
|
||||
let file_name = entry.file_name().to_string_lossy().to_string();
|
||||
|
||||
if file_name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_dir = entry.metadata().await.map(|m| m.is_dir()).unwrap_or(false);
|
||||
|
||||
let link = if request_path.is_empty() {
|
||||
file_name.clone()
|
||||
} else {
|
||||
format!("{}/{}", request_path, file_name)
|
||||
};
|
||||
|
||||
files.push(FileEntry {
|
||||
name: file_name,
|
||||
link,
|
||||
is_dir,
|
||||
});
|
||||
}
|
||||
|
||||
files.sort_by(|a, b| match (a.is_dir, b.is_dir) {
|
||||
(true, false) => std::cmp::Ordering::Less,
|
||||
(false, true) => std::cmp::Ordering::Greater,
|
||||
_ => a.name.cmp(&b.name),
|
||||
});
|
||||
|
||||
let title_path = request_path.trim_start_matches('/').to_string();
|
||||
|
||||
Ok(DirectoryTemplate { title_path, files })
|
||||
}
|
||||
|
||||
async fn sse_handler(
|
||||
State(state): State<AppState>,
|
||||
Path(full_path): Path<String>,
|
||||
AxumPath(full_path): AxumPath<String>,
|
||||
) -> impl IntoResponse {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
@ -149,7 +317,7 @@ async fn sse_handler(
|
||||
async move {
|
||||
match res {
|
||||
Ok(changed_path) => {
|
||||
if changed_path.contains(&req_path) {
|
||||
if changed_path.contains(&req_path) || changed_path.ends_with(&req_path) {
|
||||
Some(Ok::<axum::response::sse::Event, Infallible>(
|
||||
axum::response::sse::Event::default()
|
||||
.event("reload")
|
||||
@ -173,250 +341,17 @@ async fn sse_handler(
|
||||
(headers, sse)
|
||||
}
|
||||
|
||||
async fn serve_file(
|
||||
State(state): State<AppState>,
|
||||
Path(full_path): Path<String>,
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
if full_path.is_empty() {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
let mut requested_path = PathBuf::from(&state.root);
|
||||
requested_path.push(&full_path);
|
||||
|
||||
let Ok(safe_path) = fs::canonicalize(&requested_path).await else {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
};
|
||||
|
||||
let Ok(base_dir) = fs::canonicalize(&state.root).await else {
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
};
|
||||
|
||||
if !safe_path.starts_with(&base_dir) {
|
||||
eprintln!("Path traversal: {}", safe_path.display());
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
let metadata = match fs::metadata(&safe_path).await {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
eprintln!("Error getting metadata: {e}");
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
};
|
||||
|
||||
if metadata.is_dir() {
|
||||
return render_directory_index(&safe_path, &full_path)
|
||||
.await
|
||||
.map(|h| h.into_response());
|
||||
}
|
||||
|
||||
let extension = safe_path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("");
|
||||
let is_image = matches!(
|
||||
extension.to_lowercase().as_str(),
|
||||
"png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp" | "ico"
|
||||
);
|
||||
|
||||
if is_image {
|
||||
let file_content = match fs::read(&safe_path).await {
|
||||
Ok(content) => content,
|
||||
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
};
|
||||
|
||||
let mime_type = from_path(&safe_path).first_or_octet_stream();
|
||||
|
||||
return Ok(([(header::CONTENT_TYPE, mime_type.as_ref())], file_content).into_response());
|
||||
}
|
||||
|
||||
let content = match fs::read_to_string(&safe_path).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("Error reading: {e}");
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
};
|
||||
|
||||
let back_link = if let Some(pos) = full_path.rfind('/') {
|
||||
let parent = &full_path[..pos];
|
||||
if parent.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
format!("/{parent}")
|
||||
}
|
||||
} else {
|
||||
"/".to_string()
|
||||
};
|
||||
|
||||
let html_content = if let Some(extension) = safe_path.extension() {
|
||||
if extension == OsStr::new("md") {
|
||||
markdown_to_html(&content, &state.syntax_set, &state.theme_set, &full_path)
|
||||
} else if let Some(ext_str) = extension.to_str() {
|
||||
code_to_html(&content, ext_str, &state.syntax_set, &state.theme_set)
|
||||
} else {
|
||||
markdown_to_html(&content, &state.syntax_set, &state.theme_set, &full_path)
|
||||
}
|
||||
} else {
|
||||
markdown_to_html(&content, &state.syntax_set, &state.theme_set, &full_path)
|
||||
};
|
||||
|
||||
let filename: String = if let Some(filename) = PathBuf::from(&full_path).file_name() {
|
||||
filename.to_str().unwrap_or("Markdown Preview").to_string()
|
||||
} else {
|
||||
"Markdown Preview".to_string()
|
||||
};
|
||||
|
||||
// Заполнение шаблона
|
||||
let final_html = TEMPLATE_FILE
|
||||
.replace("{{TITLE}}", &filename)
|
||||
.replace("{{CONTENT}}", &html_content)
|
||||
.replace("{{SSE_URL}}", &format!("/events/{full_path}"))
|
||||
.replace("{{BACK_LINK}}", &back_link);
|
||||
|
||||
Ok(Html(final_html).into_response())
|
||||
}
|
||||
|
||||
async fn render_directory_index(
|
||||
dir_path: &PathBuf,
|
||||
request_path: &str,
|
||||
) -> Result<Html<String>, StatusCode> {
|
||||
let mut entries = match fs::read_dir(dir_path).await {
|
||||
Ok(list) => list,
|
||||
Err(e) => {
|
||||
eprintln!("Error directory reading: {e}");
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
};
|
||||
|
||||
let mut files: Vec<(String, String, bool)> = Vec::new();
|
||||
|
||||
while let Some(entry) = entries
|
||||
.next_entry()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
{
|
||||
let file_name = entry.file_name().to_string_lossy().to_string();
|
||||
|
||||
if file_name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_dir = entry.metadata().await.map(|m| m.is_dir()).unwrap_or(false);
|
||||
|
||||
let mut link_path = request_path.to_string();
|
||||
if !link_path.ends_with('/') {
|
||||
link_path.push('/');
|
||||
}
|
||||
link_path.push_str(&file_name);
|
||||
|
||||
files.push((file_name, link_path, is_dir));
|
||||
}
|
||||
|
||||
files.sort_by(|a, b| match (a.2, b.2) {
|
||||
(true, false) => std::cmp::Ordering::Less,
|
||||
(false, true) => std::cmp::Ordering::Greater,
|
||||
_ => a.0.cmp(&b.0),
|
||||
});
|
||||
|
||||
let mut list_html = String::from("<ul>");
|
||||
|
||||
if !request_path.is_empty() && request_path != "files" {
|
||||
let parent_path = request_path.rsplit_once('/').map_or("", |(p, _)| p);
|
||||
let parent_link = if parent_path.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
format!("/{parent_path}")
|
||||
};
|
||||
|
||||
let _ = write!(
|
||||
list_html,
|
||||
r#"<li>
|
||||
<a href="{parent_link}" class="back-link">📁 ..</a>
|
||||
</li>"#
|
||||
);
|
||||
}
|
||||
|
||||
for (name, link, is_dir) in files {
|
||||
let icon = if is_dir { "📁" } else { "📄" };
|
||||
let _ = write!(
|
||||
list_html,
|
||||
r#"<li>
|
||||
<a href="/{}" class="file-link">
|
||||
<span class="icon">{}</span>
|
||||
<span>{}</span>
|
||||
</a>
|
||||
</li>"#,
|
||||
link.trim_start_matches('/'),
|
||||
icon,
|
||||
name
|
||||
);
|
||||
}
|
||||
list_html.push_str("</ul>");
|
||||
|
||||
let title_path = if request_path.is_empty() {
|
||||
""
|
||||
} else {
|
||||
request_path.trim_start_matches('/')
|
||||
};
|
||||
|
||||
let final_html = TEMPLATE_DIR
|
||||
.replace("{{TITLE_PATH}}", title_path)
|
||||
.replace("{{FILE_LIST}}", &list_html);
|
||||
|
||||
Ok(Html(final_html))
|
||||
}
|
||||
|
||||
async fn run_file_watcher(state: AppState) {
|
||||
let (tx_fs, mut rx_fs) = tokio::sync::mpsc::channel::<PathBuf>(100);
|
||||
|
||||
let tx_fs_clone = tx_fs.clone();
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
move |res: Result<notify::Event, notify::Error>| {
|
||||
if let Ok(event) = res
|
||||
&& matches!(event.kind, EventKind::Modify(_))
|
||||
{
|
||||
for path in event.paths {
|
||||
let _ = tx_fs_clone.blocking_send(path);
|
||||
}
|
||||
}
|
||||
},
|
||||
Config::default(),
|
||||
)
|
||||
.expect("Failed to create watcher");
|
||||
|
||||
let watch_path = state.root;
|
||||
if let Err(e) = watcher.watch(&watch_path, RecursiveMode::Recursive) {
|
||||
eprintln!("Ошибка настройки watcher: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
while let Some(path) = rx_fs.recv().await {
|
||||
if let Some(path_str) = path.to_str() {
|
||||
let _ = state.tx.send(path_str.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn random_file(State(state): State<AppState>) -> Result<impl IntoResponse, StatusCode> {
|
||||
async fn random_file(State(state): State<AppState>) -> impl IntoResponse {
|
||||
let mut files: Vec<PathBuf> = Vec::new();
|
||||
|
||||
let mut stack = vec![state.root.clone()];
|
||||
|
||||
while let Some(current_dir) = stack.pop() {
|
||||
let mut entries = match fs::read_dir(¤t_dir).await {
|
||||
Ok(list) => list,
|
||||
Err(_) => continue,
|
||||
let Ok(mut entries) = fs::read_dir(¤t_dir).await else {
|
||||
continue;
|
||||
};
|
||||
|
||||
while let Some(entry) = entries
|
||||
.next_entry()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
{
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let path = entry.path();
|
||||
|
||||
if entry.file_name().to_string_lossy().starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
@ -436,11 +371,44 @@ async fn random_file(State(state): State<AppState>) -> Result<impl IntoResponse,
|
||||
let mut rng = rand::thread_rng();
|
||||
let random_path = files.choose(&mut rng).unwrap();
|
||||
|
||||
let relative_path = random_path
|
||||
.strip_prefix(&state.root)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let relative_path = match random_path.strip_prefix(&state.root) {
|
||||
Ok(p) => p,
|
||||
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
};
|
||||
|
||||
let url_path = relative_path.to_string_lossy().replace('\\', "/");
|
||||
|
||||
Ok(axum::response::Redirect::temporary(&format!("/{url_path}")))
|
||||
Ok(Redirect::temporary(&format!("/{url_path}")))
|
||||
}
|
||||
|
||||
async fn run_file_watcher(state: AppState) {
|
||||
let (tx_fs, mut rx_fs) = tokio::sync::mpsc::channel::<PathBuf>(100);
|
||||
|
||||
let tx_fs_clone = tx_fs.clone();
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
move |res: Result<notify::Event, notify::Error>| {
|
||||
if let Ok(event) = res
|
||||
&& matches!(
|
||||
event.kind,
|
||||
EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
|
||||
)
|
||||
{
|
||||
for path in event.paths {
|
||||
let _ = tx_fs_clone.blocking_send(path);
|
||||
}
|
||||
}
|
||||
},
|
||||
Config::default(),
|
||||
)
|
||||
.expect("Failed to create watcher");
|
||||
|
||||
if let Err(e) = watcher.watch(&state.root, RecursiveMode::Recursive) {
|
||||
eprintln!("Failed set watcher: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
while let Some(path) = rx_fs.recv().await {
|
||||
if let Some(path_str) = path.to_str() {
|
||||
let _ = state.tx.send(path_str.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
10
src/other.rs
10
src/other.rs
@ -14,10 +14,8 @@ use syntect::parsing::SyntaxSet;
|
||||
/// # Возвращает
|
||||
/// Строку HTML, содержащую обертку блока кода с заголовком и кнопкой копирования.
|
||||
pub fn code_to_html(code: &str, lang: &str, ss: &SyntaxSet, ts: &ThemeSet) -> String {
|
||||
// Используем тему по умолчанию из вашего примера
|
||||
let theme = &ts.themes["base16-ocean.dark"];
|
||||
|
||||
// Проверка на Mermaid диаграммы
|
||||
if lang == "mermaid" {
|
||||
let escaped_code = escape_html(code);
|
||||
return format!(
|
||||
@ -31,7 +29,6 @@ pub fn code_to_html(code: &str, lang: &str, ss: &SyntaxSet, ts: &ThemeSet) -> St
|
||||
);
|
||||
}
|
||||
|
||||
// Логика подсветки синтаксиса
|
||||
let lang_display = if lang.is_empty() { "text" } else { lang };
|
||||
let lang_escaped = escape_html(lang_display);
|
||||
|
||||
@ -40,36 +37,29 @@ pub fn code_to_html(code: &str, lang: &str, ss: &SyntaxSet, ts: &ThemeSet) -> St
|
||||
let mut h = HighlightLines::new(syntax, theme);
|
||||
let mut result_html = String::new();
|
||||
|
||||
// Разбиваем код на строки для построчной подсветки
|
||||
for line in code.lines() {
|
||||
// Добавляем перевод строки обратно, так как highlight_line ожидает полную строку
|
||||
let line_with_newline = format!("{line}\n");
|
||||
|
||||
match h.highlight_line(&line_with_newline, ss) {
|
||||
Ok(regions) => {
|
||||
// styled_line_to_highlighted_html может вернуть ошибку, если что-то пойдет не так
|
||||
match styled_line_to_highlighted_html(®ions[..], IncludeBackground::No) {
|
||||
Ok(html_line) => result_html.push_str(&html_line),
|
||||
Err(_) => result_html.push_str(&escape_html(&line_with_newline)),
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Если ошибка подсветки, просто экранируем строку
|
||||
result_html.push_str(&escape_html(&line_with_newline));
|
||||
}
|
||||
}
|
||||
}
|
||||
result_html
|
||||
} else {
|
||||
// Если язык не найден в наборе синтаксисов, просто экранируем весь код
|
||||
escape_html(code)
|
||||
}
|
||||
} else {
|
||||
// Если язык не указан, просто экранируем
|
||||
escape_html(code)
|
||||
};
|
||||
|
||||
// Формирование финального HTML контейнера
|
||||
format!(
|
||||
r#"<div class="code-block-wrapper">
|
||||
<div class="code-header">
|
||||
|
||||
177
templates/base.html
Normal file
177
templates/base.html
Normal file
@ -0,0 +1,177 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Note{% endblock %}</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
background-color: #121212;
|
||||
color: #e0e0e0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 40px 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.content {
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
background-color: #1e1e1e;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
||||
}
|
||||
a { color: #bb86fc; text-decoration: none !important; }
|
||||
h1, h2, h3, h4 { color: #ffffff; margin-top: 1.5em; margin-bottom: 0.5em; }
|
||||
h1 { border-bottom: 1px solid #333; padding-bottom: 10px; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 1em 0; }
|
||||
th, td { border: 1px solid #444; padding: 8px; text-align: left; }
|
||||
th { background-color: #2c2c2c; }
|
||||
blockquote {
|
||||
border-left: 4px solid #bb86fc;
|
||||
margin: 1em 0;
|
||||
padding-left: 1em;
|
||||
color: #aaa;
|
||||
background: #252525;
|
||||
padding: 10px;
|
||||
}
|
||||
code { font-family: 'Consolas', 'Monaco', monospace; }
|
||||
p > code, li > code {
|
||||
background-color: #2c2c2c;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: #ff79c6;
|
||||
}
|
||||
.header a { padding: 1em; color: #757575; font-size: 0.95em; }
|
||||
.code-block-wrapper {
|
||||
margin: 1em 0;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background-color: #2b303b;
|
||||
}
|
||||
.code-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: #232730;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid #444;
|
||||
font-size: 0.85em;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
.code-lang { font-weight: bold; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.copy-btn {
|
||||
background: transparent;
|
||||
border: 1px solid #555;
|
||||
color: #ccc;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8em;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.copy-btn:hover { background-color: #444; color: #fff; border-color: #777; }
|
||||
.copy-btn:active { transform: scale(0.95); }
|
||||
pre { padding: 15px; overflow-x: auto; margin: 0; }
|
||||
pre code { background: transparent; padding: 0; color: inherit; }
|
||||
.mermaid-wrapper .mermaid {
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 0 0 6px 6px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
border-top: 1px solid #333;
|
||||
padding-top: 15px;
|
||||
}
|
||||
.footer a { padding: 1em; color: #757575; }
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: #90a4ae;
|
||||
text-decoration: none;
|
||||
font-size: 0.95em;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.back-link span { margin-right: 8px; font-size: 1.2em; }
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 1.5em auto;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
/* Стили для списка файлов */
|
||||
ul { list-style-type: none; padding: 0; margin: 0; }
|
||||
li { padding: 10px 0; border-bottom: 1px solid #333; }
|
||||
.file-link {
|
||||
color: #64b5f6;
|
||||
font-size: 1.1em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.file-link:hover { color: #90caf9; }
|
||||
.icon { margin-right: 10px; min-width: 24px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="content">
|
||||
<div class="header">
|
||||
{% block header %}
|
||||
<a href="/" class="back-link" onmouseover="this.style.color='#ffffff'" onmouseout="this.style.color='#90a4ae'">
|
||||
<span>←</span> Back
|
||||
</a>
|
||||
<a href="/random">🎲 Random note</a>
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
<div class="footer">
|
||||
<a href="/">← Main</a>
|
||||
<a href="/random">🎲 Random note</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
function copyCode(button) {
|
||||
const wrapper = button.closest('.code-block-wrapper');
|
||||
if (!wrapper) return;
|
||||
const target = wrapper.querySelector('pre') || wrapper.querySelector('.mermaid');
|
||||
if (!target) return;
|
||||
const codeText = target.innerText;
|
||||
|
||||
navigator.clipboard.writeText(codeText).then(() => {
|
||||
const originalText = button.innerText;
|
||||
button.innerText = 'Copied!';
|
||||
button.style.borderColor = '#2ecc71';
|
||||
button.style.color = '#2ecc71';
|
||||
setTimeout(() => {
|
||||
button.innerText = originalText;
|
||||
button.style.borderColor = '';
|
||||
button.style.color = '';
|
||||
}, 2000);
|
||||
}).catch(err => {
|
||||
console.error('Error copying:', err);
|
||||
button.innerText = 'Error';
|
||||
});
|
||||
}
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: true,
|
||||
theme: 'dark',
|
||||
securityLevel: 'loose',
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,91 +1,34 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Directory</title>
|
||||
<style>
|
||||
body {
|
||||
background-color: #121212;
|
||||
color: #e0e0e0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 40px 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.content {
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
background-color: #1e1e1e;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
||||
}
|
||||
h1 {
|
||||
color: #ffffff;
|
||||
border-bottom: 1px solid #333;
|
||||
padding-bottom: 10px;
|
||||
margin-top: 0;
|
||||
}
|
||||
a {
|
||||
text-decoration: none !important;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
a:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
li {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #333;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.file-link {
|
||||
color: #64b5f6;
|
||||
text-decoration: none !important;
|
||||
font-size: 1.1em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.file-link:hover {
|
||||
color: #90caf9;
|
||||
}
|
||||
.back-link {
|
||||
color: #90a4ae;
|
||||
font-weight: bold;
|
||||
text-decoration: none !important;
|
||||
display: block;
|
||||
}
|
||||
.icon {
|
||||
margin-right: 10px;
|
||||
min-width: 24px;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
border-top: 1px solid #333;
|
||||
padding-top: 15px;
|
||||
}
|
||||
.footer a {
|
||||
padding: 1em;
|
||||
color: #757575;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="content">
|
||||
<h1>📂 Directory: /{{TITLE_PATH}}</h1>
|
||||
{{FILE_LIST}}
|
||||
<div class="footer">
|
||||
<a href="/">← Main</a>
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Directory: /{{ title_path }}{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
{% if !title_path.is_empty() %}
|
||||
<a href="../" class="back-link">
|
||||
<span>←</span> Up
|
||||
</a>
|
||||
{% else %}
|
||||
<span style="color: #757575; font-size: 0.95em;">🏠 Root</span>
|
||||
{% endif %}
|
||||
<a href="/random">🎲 Random note</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>📂 Directory: /{{ title_path }}</h1>
|
||||
<ul>
|
||||
{% if !title_path.is_empty() %}
|
||||
<li>
|
||||
<a href="../" class="back-link">📁 ..</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for file in files %}
|
||||
<li>
|
||||
<a href="/{{ file.link }}" class="file-link">
|
||||
<span class="icon">{% if file.is_dir %}📁{% else %}📄{% endif %}</span>
|
||||
<span>{{ file.name }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
|
||||
@ -1,212 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{TITLE}}</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
background-color: #121212;
|
||||
color: #e0e0e0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 40px 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.content {
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
background-color: #1e1e1e;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
||||
}
|
||||
a {
|
||||
color: #bb86fc;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
h1, h2, h3, h4 {
|
||||
color: #ffffff;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
h1 {
|
||||
border-bottom: 1px solid #333;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 1em 0;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #444;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background-color: #2c2c2c;
|
||||
}
|
||||
blockquote {
|
||||
border-left: 4px solid #bb86fc;
|
||||
margin: 1em 0;
|
||||
padding-left: 1em;
|
||||
color: #aaa;
|
||||
background: #252525;
|
||||
padding: 10px;
|
||||
}
|
||||
code {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
p > code, li > code {
|
||||
background-color: #2c2c2c;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: #ff79c6;
|
||||
}
|
||||
{% extends "base.html" %}
|
||||
|
||||
.header a {
|
||||
padding: 1em;
|
||||
color: #757575;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
{% block title %}{{ filename }}{% endblock %}
|
||||
|
||||
.code-block-wrapper {
|
||||
margin: 1em 0;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background-color: #2b303b;
|
||||
}
|
||||
.code-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: #232730;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid #444;
|
||||
font-size: 0.85em;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
.code-lang {
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.copy-btn {
|
||||
background: transparent;
|
||||
border: 1px solid #555;
|
||||
color: #ccc;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8em;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.copy-btn:hover {
|
||||
background-color: #444;
|
||||
color: #fff;
|
||||
border-color: #777;
|
||||
}
|
||||
.copy-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
pre {
|
||||
padding: 15px;
|
||||
overflow-x: auto;
|
||||
margin: 0;
|
||||
}
|
||||
pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.mermaid-wrapper .mermaid {
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 0 0 6px 6px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#status {
|
||||
position: fixed;
|
||||
top: 10px; right: 10px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.connected {
|
||||
background-color: #2ecc71;
|
||||
color: #000;
|
||||
}
|
||||
.disconnected {
|
||||
background-color: #e74c3c;
|
||||
color: #fff;
|
||||
}
|
||||
.reconnecting {
|
||||
background-color: #f1c40f;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
border-top: 1px solid #333;
|
||||
padding-top: 15px;
|
||||
}
|
||||
.footer a {
|
||||
padding: 1em;
|
||||
color: #757575;
|
||||
}
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: #90a4ae;
|
||||
text-decoration: none;
|
||||
font-size: 0.95em;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.back-link span {
|
||||
margin-right: 8px;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 1.5em auto;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="content">
|
||||
<div class="header">
|
||||
<a href="{{BACK_LINK}}" class="back-link" onmouseover="this.style.color='#ffffff'" onmouseout="this.style.color='#90a4ae'">
|
||||
{% block header %}
|
||||
<a href="{{ back_link }}" class="back-link" onmouseover="this.style.color='#ffffff'" onmouseout="this.style.color='#90a4ae'">
|
||||
<span>←</span> Back
|
||||
</a>
|
||||
<a href="/random">🎲 Random note</a>
|
||||
</div>
|
||||
{{CONTENT}}
|
||||
{% endblock %}
|
||||
|
||||
<div class="footer">
|
||||
<a href="/">← Main</a>
|
||||
<a href="/random">🎲 Random note</a>
|
||||
</div>
|
||||
</div>
|
||||
{% block content %}
|
||||
{{ content|safe }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
<script>
|
||||
const sseUrl = "{{SSE_URL}}";
|
||||
{% block scripts %}
|
||||
const sseUrl = "{{ sse_url }}";
|
||||
|
||||
function connect() {
|
||||
const evtSource = new EventSource(sseUrl);
|
||||
@ -220,36 +28,4 @@
|
||||
});
|
||||
}
|
||||
connect();
|
||||
|
||||
function copyCode(button) {
|
||||
const wrapper = button.closest('.code-block-wrapper');
|
||||
if (!wrapper) return;
|
||||
const target = wrapper.querySelector('pre') || wrapper.querySelector('.mermaid');
|
||||
|
||||
if (!target) return;
|
||||
const codeText = target.innerText;
|
||||
|
||||
navigator.clipboard.writeText(codeText).then(() => {
|
||||
const originalText = button.innerText;
|
||||
button.innerText = 'Copied!';
|
||||
button.style.borderColor = '#2ecc71';
|
||||
button.style.color = '#2ecc71';
|
||||
setTimeout(() => {
|
||||
button.innerText = originalText;
|
||||
button.style.borderColor = '';
|
||||
button.style.color = '';
|
||||
}, 2000);
|
||||
}).catch(err => {
|
||||
console.error('Error copying:', err);
|
||||
button.innerText = 'Error';
|
||||
});
|
||||
}
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: true,
|
||||
theme: 'dark',
|
||||
securityLevel: 'loose',
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{% endblock %}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user