add(atomic): better files page

add(atomic): split view
add(atomic): move paste deletion option
add(atomic): javascript editor highlighting
chore: bump version (v0.12.1 -> v0.12.2)
This commit is contained in:
hkau 2024-04-10 11:37:12 -04:00
parent 17066671b6
commit d407706f3c
9 changed files with 595 additions and 175 deletions

View file

@ -3,7 +3,7 @@ name = "bundlrs"
authors = ["hkau"]
license = "MIT"
version = "0.12.1"
version = "0.12.2"
edition = "2021"
rust-version = "1.75"

View file

@ -13,6 +13,7 @@
"@codemirror/autocomplete": "^6.12.0",
"@codemirror/commands": "^6.3.3",
"@codemirror/lang-css": "^6.2.1",
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/lang-markdown": "^6.2.3",
"@codemirror/language": "^6.10.0",
"@codemirror/state": "^6.4.0",

View file

@ -1,4 +1,4 @@
use actix_web::{get, post, web, HttpRequest, HttpResponse, Responder};
use actix_web::{delete, get, post, web, HttpRequest, HttpResponse, Responder};
use crate::db::{self, AtomicPasteFSFile, DefaultReturn, FullPaste, PasteMetadata};
use crate::{markdown, ssm, utility};
@ -30,13 +30,6 @@ struct EditInfo {
new_custom_url: Option<String>,
}
#[derive(serde::Deserialize)]
struct EditAtomicInfo {
custom_url: String,
path: String,
content: String,
}
#[derive(serde::Deserialize)]
struct DeleteInfo {
custom_url: String,
@ -234,102 +227,6 @@ pub async fn edit_request(
.body(serde_json::to_string(&res).unwrap());
}
#[post("/api/edit-atomic")]
/// Edit an atomic paste's "file system"
pub async fn edit_atomic_request(
req: HttpRequest,
body: web::Json<EditAtomicInfo>,
data: web::Data<db::AppData>,
) -> impl Responder {
// this is essentially the same as edit_request but it handles the atomic JSON file system
// ...it does NOT accept an edit password! users must be authenticated
let custom_url: String = body.custom_url.trim().to_string();
let path: String = body.path.trim().to_string();
let content: String = body.content.trim().to_string();
// get token user
let token_cookie = req.cookie("__Secure-Token");
let token_user = if token_cookie.is_some() {
Option::Some(
data.db
.get_user_by_unhashed(token_cookie.as_ref().unwrap().value().to_string()) // if the user is returned, that means the ID is valid
.await,
)
} else {
Option::None
};
if token_user.is_some() {
// make sure user exists
if token_user.as_ref().unwrap().success == false {
return HttpResponse::NotFound().body("Invalid token");
}
}
// get paste
let paste: DefaultReturn<Option<FullPaste<PasteMetadata, String>>> =
data.db.get_paste_by_url(custom_url.clone()).await;
if paste.success == false {
return HttpResponse::Ok()
.append_header(("Content-Type", "application/json"))
.body(serde_json::to_string(&paste).unwrap());
}
// make sure paste is an atomic paste
let unwrap = paste.payload.unwrap();
let is_atomic = unwrap.paste.content.contains("\"_is_atomic\":true");
if is_atomic == false {
return HttpResponse::NotFound().body("Paste is not atomic");
}
// get file from path
let real_content = serde_json::from_str::<db::AtomicPaste>(&unwrap.paste.content);
if real_content.is_err() {
return HttpResponse::NotAcceptable().body("Paste failed to deserialize");
}
let mut decoded = real_content.unwrap();
// check for existing file in atomic paste fs
let existing = decoded.files.iter().position(|f| f.path == path);
if existing.is_some() {
// remove existing file
decoded.files.remove(existing.unwrap());
}
// insert file
decoded.files.push(AtomicPasteFSFile {
path,
content: content.clone(),
});
// ...
let res = data
.db
.edit_paste_by_url(
custom_url,
serde_json::to_string::<db::AtomicPaste>(&decoded).unwrap(), // encode content
String::new(),
Option::None,
Option::None,
if token_user.is_some() {
Option::Some(token_user.unwrap().payload.unwrap().user.username)
} else {
Option::None
},
)
.await;
// return
return HttpResponse::Ok()
.append_header(("Content-Type", "application/json"))
.body(serde_json::to_string(&res).unwrap());
}
#[post("/api/delete")]
/// Delete a paste
pub async fn delete_request(
@ -520,3 +417,255 @@ pub async fn get_from_id_request(req: HttpRequest, data: web::Data<db::AppData>)
.unwrap(),
);
}
// atomic "CRUD" operations
#[get("/api/atomic/crud/{url:.*}/{path:.*}")]
/// Read an atomic paste's "file system"
pub async fn read_atomic_request(req: HttpRequest, data: web::Data<db::AppData>) -> impl Responder {
let custom_url: String = req.match_info().get("url").unwrap().to_string();
let path: String = format!("/{}", req.match_info().get("path").unwrap());
// get token user
let token_cookie = req.cookie("__Secure-Token");
let token_user = if token_cookie.is_some() {
Option::Some(
data.db
.get_user_by_unhashed(token_cookie.as_ref().unwrap().value().to_string()) // if the user is returned, that means the ID is valid
.await,
)
} else {
Option::None
};
if token_user.is_some() {
// make sure user exists
if token_user.as_ref().unwrap().success == false {
return HttpResponse::NotFound().body("Invalid token");
}
}
// get paste
let paste: DefaultReturn<Option<FullPaste<PasteMetadata, String>>> =
data.db.get_paste_by_url(custom_url.clone()).await;
if paste.success == false {
return HttpResponse::Ok()
.append_header(("Content-Type", "application/json"))
.body(serde_json::to_string(&paste).unwrap());
}
// make sure paste is an atomic paste
let unwrap = paste.payload.unwrap();
let is_atomic = unwrap.paste.content.contains("\"_is_atomic\":true");
if is_atomic == false {
return HttpResponse::NotFound().body("Paste is not atomic");
}
// get file from path
let real_content = serde_json::from_str::<db::AtomicPaste>(&unwrap.paste.content);
if real_content.is_err() {
return HttpResponse::NotAcceptable().body("Paste failed to deserialize");
}
let decoded = real_content.unwrap();
// check for existing file in atomic paste fs
let existing = decoded.files.iter().find(|f| f.path == path);
if existing.is_none() {
return HttpResponse::NotFound()
.append_header(("Content-Type", "text/plain"))
.body("Path does not exist");
}
// return
return HttpResponse::Ok()
.append_header(("Content-Type", "text/plain"))
.body(existing.unwrap().content.clone());
}
#[post("/api/atomic/crud/{url:.*}/{path:.*}")]
/// Update an atomic paste's "file system"
pub async fn update_atomic_request(
req: HttpRequest,
body: String, // text/plain
data: web::Data<db::AppData>,
) -> impl Responder {
// this is essentially the same as edit_request but it handles the atomic JSON file system
// ...it does NOT accept an edit password! users must be authenticated
let custom_url: String = req.match_info().get("url").unwrap().to_string();
let path: String = format!("/{}", req.match_info().get("path").unwrap());
let content: String = body.clone();
// get token user
let token_cookie = req.cookie("__Secure-Token");
let token_user = if token_cookie.is_some() {
Option::Some(
data.db
.get_user_by_unhashed(token_cookie.as_ref().unwrap().value().to_string()) // if the user is returned, that means the ID is valid
.await,
)
} else {
Option::None
};
if token_user.is_some() {
// make sure user exists
if token_user.as_ref().unwrap().success == false {
return HttpResponse::NotFound().body("Invalid token");
}
}
// get paste
let paste: DefaultReturn<Option<FullPaste<PasteMetadata, String>>> =
data.db.get_paste_by_url(custom_url.clone()).await;
if paste.success == false {
return HttpResponse::Ok()
.append_header(("Content-Type", "application/json"))
.body(serde_json::to_string(&paste).unwrap());
}
// make sure paste is an atomic paste
let unwrap = paste.payload.unwrap();
let is_atomic = unwrap.paste.content.contains("\"_is_atomic\":true");
if is_atomic == false {
return HttpResponse::NotFound().body("Paste is not atomic");
}
// get file from path
let real_content = serde_json::from_str::<db::AtomicPaste>(&unwrap.paste.content);
if real_content.is_err() {
return HttpResponse::NotAcceptable().body("Paste failed to deserialize");
}
let mut decoded = real_content.unwrap();
// check for existing file in atomic paste fs
let existing = decoded.files.iter().position(|f| f.path == path);
if existing.is_some() {
// remove existing file
decoded.files.remove(existing.unwrap());
}
// insert file
decoded.files.push(AtomicPasteFSFile {
path,
content: content.clone(),
});
// ...
let res = data
.db
.edit_paste_by_url(
custom_url,
serde_json::to_string::<db::AtomicPaste>(&decoded).unwrap(), // encode content
String::new(),
Option::None,
Option::None,
if token_user.is_some() {
Option::Some(token_user.unwrap().payload.unwrap().user.username)
} else {
Option::None
},
)
.await;
// return
return HttpResponse::Ok()
.append_header(("Content-Type", "application/json"))
.body(serde_json::to_string(&res).unwrap());
}
#[delete("/api/atomic/crud/{url:.*}/{path:.*}")]
/// Delete in an atomic paste's "file system"
pub async fn delete_atomic_request(
req: HttpRequest,
data: web::Data<db::AppData>,
) -> impl Responder {
// this is essentially the same as edit_request but it handles the atomic JSON file system
// ...it does NOT accept an edit password! users must be authenticated
let custom_url: String = req.match_info().get("url").unwrap().to_string();
let path: String = format!("/{}", req.match_info().get("path").unwrap());
// get token user
let token_cookie = req.cookie("__Secure-Token");
let token_user = if token_cookie.is_some() {
Option::Some(
data.db
.get_user_by_unhashed(token_cookie.as_ref().unwrap().value().to_string()) // if the user is returned, that means the ID is valid
.await,
)
} else {
Option::None
};
if token_user.is_some() {
// make sure user exists
if token_user.as_ref().unwrap().success == false {
return HttpResponse::NotFound().body("Invalid token");
}
}
// get paste
let paste: DefaultReturn<Option<FullPaste<PasteMetadata, String>>> =
data.db.get_paste_by_url(custom_url.clone()).await;
if paste.success == false {
return HttpResponse::Ok()
.append_header(("Content-Type", "application/json"))
.body(serde_json::to_string(&paste).unwrap());
}
// make sure paste is an atomic paste
let unwrap = paste.payload.unwrap();
let is_atomic = unwrap.paste.content.contains("\"_is_atomic\":true");
if is_atomic == false {
return HttpResponse::NotFound().body("Paste is not atomic");
}
// get file from path
let real_content = serde_json::from_str::<db::AtomicPaste>(&unwrap.paste.content);
if real_content.is_err() {
return HttpResponse::NotAcceptable().body("Paste failed to deserialize");
}
let mut decoded = real_content.unwrap();
// check for existing file in atomic paste fs
let existing = decoded.files.iter().position(|f| f.path == path);
if existing.is_some() {
// remove existing file
decoded.files.remove(existing.unwrap());
}
// ...
let res = data
.db
.edit_paste_by_url(
custom_url,
serde_json::to_string::<db::AtomicPaste>(&decoded).unwrap(), // encode content
String::new(),
Option::None,
Option::None,
if token_user.is_some() {
Option::Some(token_user.unwrap().payload.unwrap().user.username)
} else {
Option::None
},
)
.await;
// return
return HttpResponse::Ok()
.append_header(("Content-Type", "application/json"))
.body(serde_json::to_string(&res).unwrap());
}

View file

@ -112,12 +112,15 @@ async fn main() -> std::io::Result<()> {
.service(crate::api::pastes::render_request)
.service(crate::api::pastes::create_request)
.service(crate::api::pastes::edit_request)
.service(crate::api::pastes::edit_atomic_request)
.service(crate::api::pastes::delete_request)
.service(crate::api::pastes::metadata_request)
// POST api::pastes SSM
.service(crate::api::pastes::render_ssm_request)
.service(crate::api::pastes::render_paste_ssm_request)
// atomic api
.service(crate::api::pastes::read_atomic_request)
.service(crate::api::pastes::update_atomic_request)
.service(crate::api::pastes::delete_atomic_request)
// GET api
.service(crate::api::pastes::get_from_url_request)
.service(crate::api::pastes::get_from_id_request)

View file

@ -15,6 +15,7 @@ struct EditQueryProps {
#[derive(Default, Properties, PartialEq, serde::Deserialize)]
struct FSProps {
pub custom_url: String,
pub files: Vec<db::AtomicPasteFSFile>,
pub auth_state: Option<bool>,
}
@ -84,7 +85,7 @@ fn Dashboard(props: &Props) -> Html {
<div class="card round secondary flex g-4 flex-column justify-center" id="pastes_list">
{for props.pastes.iter().map(|p| html! {
<a class="button secondary round full justify-start" href={format!("/d/atomic/{}?path=/index.html", &p.id)}>
<a class="button secondary round full justify-start" href={format!("/d/atomic/{}?", &p.id)}>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-folder-archive"><circle cx="15" cy="19" r="2"/><path d="M20.9 19.8A2 2 0 0 0 22 18V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h5.1"/><path d="M15 11v-1"/><path d="M15 17v-2"/></svg>
{&p.custom_url}
</a>
@ -261,7 +262,26 @@ You can create an account at: /d/auth/register",
fn EditPaste(props: &EditProps) -> Html {
return html! {
<div class="flex flex-column" style="height: 100dvh;">
<div id="_doc" style="height: 100%; overflow: auto;" />
<div class="panes flex mobile:flex-column" style="height: 100%; overflow: auto;">
<div id="_doc" class="full" style="height: 100%; overflow: auto; display: block;"></div>
<div id="_preview_browser" class="full" style="height: 100%; overflow: hidden; display: none;">
<div class="full flex g-4 bg-0" style="padding: var(--u-04); height: 47.8px;">
<button class="round" id="preview" title="Refresh Preview">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-refresh-cw"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
</button>
</div>
<iframe id="_preview_pane" class="full" style="height: calc(100% - 47.8px); overflow: auto;" frameborder="0" src="about:blank"></iframe>
</div>
<style>
{"#_preview_pane { background: white; }
#_preview_browser { border-left: solid 1px var(--background-surface2a); }
@media screen and (max-width: 900px) { #_preview_browser { border-left: 0; border-top: solid 1px var(--background-surface2a); } }"}
</style>
</div>
<div class="card secondary flex mobile:justify-center justify-space-between align-center" style="
overflow: auto hidden;
border-top: 1px solid var(--background-surface2a);
@ -270,11 +290,19 @@ fn EditPaste(props: &EditProps) -> Html {
<b style="min-width: max-content;" class="device:desktop">{&props.file.path}</b>
<div class="flex g-4">
<button class="round secondary" id="save">{"Save"}</button>
<button class="round red secondary" id="delete">{"Delete"}</button>
<a href="?" class="button round secondary" id="save" target="_blank">{"Files"}</a>
<button class="round secondary green" id="save" title="Save File">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-save"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
</button>
<a href="?" class="button round secondary" id="file_explorer" title="Manage Files">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-folder-tree"><path d="M20 10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-2.5a1 1 0 0 1-.8-.4l-.9-1.2A1 1 0 0 0 15 3h-2a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z"/><path d="M20 21a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1h-2.9a1 1 0 0 1-.88-.55l-.42-.85a1 1 0 0 0-.92-.6H13a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z"/><path d="M3 5a2 2 0 0 0 2 2h3"/><path d="M3 3v13a2 2 0 0 0 2 2h3"/></svg>
</a>
<div class="hr-left" />
<button class="round border" id="preview">{"Preview"}</button>
<button class="round secondary red" id="split_view" title="Split View">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-columns-2"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M12 3v18"/></svg>
</button>
</div>
</div>
@ -299,22 +327,137 @@ fn build_edit_renderer_with_props(props: EditProps) -> ServerRenderer<EditPaste>
fn PasteFiles(props: &FSProps) -> Html {
return html! {
<div class="flex flex-column" style="height: 100dvh;">
<main class="small">
<div class="card secondary round flex flex-column g-4">
{for props.files.iter().map(|p| html! {
<a href={format!("?path={}", &p.path)}>{&p.path}</a>
})}
<GlobalMenu auth_state={props.auth_state} />
<div class="toolbar flex justify-space-between">
// left
<div class="flex">
<button title="Menu" b_onclick="window.toggle_child_menu(event.target, '#upper\\\\:globalmenu')" style="border-left: 0">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-menu"><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/></svg>
</button>
<a class="button" href="/d" style="border-left: 0">
{"Dashboard"}
</a>
</div>
</div>
<div class="toolbar-layout-wrapper">
<div id="link-header" style="display: flex;" class="flex-column bg-1">
<div class="link-header-top"></div>
<div class="link-header-middle">
<h1 class="no-margin">{&props.custom_url}</h1>
</div>
<div class="link-header-bottom">
<a href="/d" class="button">{"Home"}</a>
<a href="/d/pastes" class="button">{"Pastes"}</a>
<a href="/d/atomic" class="button active">{"Atomic"}</a>
<a href="::PUFFER_ROOT::/d" class="button">{"Boards"}</a>
</div>
</div>
<main class="flex flex-column g-4 small">
<div id="error" class="mdnote note-error full" style="display: none;" />
<div id="success" class="mdnote note-note full" style="display: none;" />
<div id="custom_url" style="display: none;">{&props.custom_url}</div>
<form class="flex justify-center align-center g-4">
<input type="text" placeholder="/index.(html|css|js)" name="path" class="round full" minlength={4} />
<button class="round bundles-primary" style="min-width: max-content;">{"Open"}</button>
</form>
<table class="full stripped">
<thead>
<tr>
<th>{"Path"}</th>
<th>{"Actions"}</th>
</tr>
</thead>
<tbody>
{for props.files.iter().map(|p| html! {
<tr>
<td><a href={format!("?path={}", &p.path)}>{&p.path}</a></td>
<td class="flex g-4 flex-wrap">
<a class="button secondary round" href={format!("?path={}", &p.path)} title="Edit File">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-pen-line"><path d="m18 5-3-3H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2"/><path d="M8 18h1"/><path d="M18.4 9.6a2 2 0 1 1 3 3L17 17l-4 1 1-4Z"/></svg>
</a>
<button class="secondary round action:more-modal" data-suffix={format!("{}{}", &props.custom_url, &p.path)} title="More Options">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-wrench"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
</button>
</td>
</tr>
})}
</tbody>
</table>
<hr />
<form class="flex justify-center align-center g-4 flex-wrap mobile:flex-column">
<input type="text" placeholder="/index.html" name="path" class="round mobile:max" minlength={4} />
<button class="round bundles-primary mobile:max">{"Open"}</button>
</form>
</div>
<h6 class="no-margin">{"Paste Options"}</h6>
<Footer auth_state={props.auth_state} />
</main>
<table class="full stripped">
<thead>
<tr>
<th>{"Name"}</th>
<th>{"Use"}</th>
</tr>
</thead>
<tbody>
<tr>
<td>{"View"}</td>
<td>
<a class="button round secondary" target="_blank" href={format!("/{}", props.custom_url)}>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-play"><circle cx="12" cy="12" r="10"/><polygon points="10 8 16 12 10 16 10 8"/></svg>
{"Run"}
</a>
</td>
</tr>
<tr>
<td>{"Delete"}</td>
<td>
<button class="round secondary" id="delete">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-play"><circle cx="12" cy="12" r="10"/><polygon points="10 8 16 12 10 16 10 8"/></svg>
{"Run"}
</button>
</td>
</tr>
</tbody>
</table>
// ...
<dialog id="more-modal">
<div style="width: 25rem; max-width: 100%;">
<h2 class="no-margin full text-center">{"More Options"}</h2>
<hr />
<div id="more-modal-actions" class="flex flex-column g-4"></div>
<hr />
<div class="full flex justify-right">
<a
class="button round red"
href="javascript:document.getElementById('more-modal').close();"
>
{"Close"}
</a>
</div>
</div>
</dialog>
<script type="module">
{"import \"/static/js/AtomicOverview.js\";"}
</script>
<Footer auth_state={props.auth_state} />
</main>
</div>
</div>
};
}
@ -395,6 +538,7 @@ You can create an account at: /d/auth/register",
// show file list if path is none
if info.path.is_none() {
let renderer = build_fs_renderer_with_props(FSProps {
custom_url: unwrap.custom_url.clone(),
files: decoded.files,
auth_state: if req.cookie("__Secure-Token").is_some() {
Option::Some(true)

View file

@ -384,8 +384,9 @@ pub async fn paste_view_request(
));
}
#[get("/h/{url:.*}/{path:.*}")]
/// Available at "/h/{custom_url}/{file_path}"
// #[get("/h/{url:.*}/{path:.*}")]
#[get("/+{url:.*}/{path:.*}")]
/// Available at "/+{custom_url}/{file_path}"
pub async fn atomic_paste_view_request(
req: HttpRequest,
data: web::Data<AppData>,

View file

@ -32,6 +32,7 @@ import {
import { basicSetup } from "codemirror";
import { html, htmlCompletionSource } from "@codemirror/lang-html";
import { javascript } from "@codemirror/lang-javascript";
import { css, cssCompletionSource } from "@codemirror/lang-css";
import { tags } from "@lezer/highlight";
@ -193,6 +194,7 @@ export function create_editor(
path: string
) {
if (globalThis.Bun) return; // must be run from client
const file_type = path.split(".").pop();
const view = new EditorView({
// @ts-ignore
@ -232,7 +234,11 @@ export function create_editor(
indentOnInput(),
indentUnit.of(" "),
// language
path.endsWith("css") ? css() : html({ autoCloseTags: true }),
path.endsWith("css")
? css()
: path.endsWith("js")
? javascript()
: html({ autoCloseTags: true }),
path.endsWith("html") ? HTMLLint : EmptyLint,
// default
basicSetup,
@ -289,11 +295,49 @@ export function create_editor(
};
// handle interactions
let view_split: boolean = false;
const preview_button = document.getElementById(
"preview"
) as HTMLButtonElement | null;
if (preview_button) {
const split_button = document.getElementById(
"split_view"
) as HTMLButtonElement | null;
const preview_browser = document.getElementById(
"_preview_browser"
) as HTMLDivElement | null;
const preview_pane = document.getElementById(
"_preview_pane"
) as HTMLIFrameElement | null;
if (split_button && preview_browser) {
if (file_type !== "html") {
split_button.remove();
}
// split view on click
split_button.addEventListener("click", () => {
view_split = !view_split;
if (view_split) {
preview_browser.style.display = "block";
split_button.classList.remove("red");
split_button.classList.add("green");
preview_button?.click(); // refresh preview
} else {
preview_browser.style.display = "none";
split_button.classList.remove("green");
split_button.classList.add("red");
}
});
}
if (preview_button && preview_pane) {
let url: string = "";
preview_button.addEventListener("click", () => {
if (url.length > 0) {
@ -308,8 +352,8 @@ export function create_editor(
// get url
url = URL.createObjectURL(blob);
// open
window.open(url);
// load
preview_pane.src = url;
});
}
@ -319,15 +363,11 @@ export function create_editor(
if (save_button) {
save_button.addEventListener("click", async () => {
const res = await fetch("/api/edit-atomic", {
const res = await fetch(`/api/atomic/crud/${custom_url}${path}`, {
method: "POST",
body: JSON.stringify({
custom_url,
path,
content: (globalThis as any).AtomicEditor.Content,
}),
body: (globalThis as any).AtomicEditor.Content,
headers: {
"Content-Type": "application/json",
"Content-Type": "text/plain",
},
});
@ -341,44 +381,11 @@ export function create_editor(
});
}
const delete_button = document.getElementById(
"delete"
) as HTMLButtonElement | null;
if (delete_button) {
delete_button.addEventListener("click", async () => {
const _confirm = confirm(
"Are you sure you would like to do this? This URL will be available for anybody to claim. **This will delete the paste, not the page!"
);
if (!_confirm) return;
const edit_password = prompt(
"Please enter this paste's custom URL to confirm:"
);
if (!edit_password) return;
const res = await fetch("/api/delete", {
method: "POST",
body: JSON.stringify({
custom_url,
edit_password: edit_password,
}),
headers: {
"Content-Type": "application/json",
},
});
const json = await res.json();
if (json.success === false) {
return alert(json.message);
} else {
window.location.href = "/";
}
});
}
// prevent exit
window.addEventListener("beforeunload", (e) => {
e.preventDefault();
e.returnValue = true;
});
// return
return view;

View file

@ -0,0 +1,114 @@
const error: HTMLElement = document.getElementById("error")!;
const success: HTMLElement = document.getElementById("success")!;
function init_delete_buttons() {
const delete_buttons: HTMLButtonElement[] = Array.from(
document.getElementsByClassName("action:delete-file")
) as HTMLButtonElement[];
if (delete_buttons) {
// delete files
for (const delete_button of delete_buttons) {
delete_button.addEventListener("click", async (e) => {
e.preventDefault();
const res = await fetch(
delete_button.getAttribute("data-endpoint")!,
{
method: "DELETE",
}
);
const json = await res.json();
if (json.success === false) {
error.style.display = "block";
error.innerHTML = `<div class="mdnote-title">${json.message}</div>`;
} else {
success.style.display = "block";
success.innerHTML = `<div class="mdnote-title">${json.message}</div>`;
}
(
document.getElementById("more-modal") as HTMLDialogElement
).close();
});
}
}
}
const more_buttons: HTMLButtonElement[] = Array.from(
document.getElementsByClassName("action:more-modal")
) as HTMLButtonElement[];
const more_modal_actions: HTMLDivElement | null = document.getElementById(
"more-modal-actions"
) as HTMLDivElement | null;
if (more_buttons && more_modal_actions) {
for (const button of more_buttons) {
button.addEventListener("click", () => {
const data_suffix = button.getAttribute("data-suffix")!;
more_modal_actions.innerHTML = `<a class="button full justify-start round" target="_blank" href="/+${data_suffix}" title="View File">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-eye"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
View
</a>
<button class="red round full justify-start action:delete-file" data-endpoint="/api/atomic/crud/${data_suffix}" title="Delete File">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
Delete
</button>`;
(
document.getElementById("more-modal") as HTMLDialogElement
).showModal();
init_delete_buttons();
});
}
}
const custom_url = (document.getElementById("custom_url") as HTMLDivElement)
.innerText;
const delete_button = document.getElementById(
"delete"
) as HTMLButtonElement | null;
if (delete_button) {
delete_button.addEventListener("click", async () => {
const _confirm = confirm(
"Are you sure you would like to do this? This URL will be available for anybody to claim. **This will delete the entire paste and all its files!"
);
if (!_confirm) return;
const edit_password = prompt(
"Please enter this paste's edit password:"
);
if (!edit_password) return;
const res = await fetch("/api/delete", {
method: "POST",
body: JSON.stringify({
custom_url,
edit_password: edit_password,
}),
headers: {
"Content-Type": "application/json",
},
});
const json = await res.json();
if (json.success === false) {
return alert(json.message);
} else {
window.location.href = "/";
}
});
}
// default export
export default {};

View file

@ -17,6 +17,7 @@ const output = await build({
"./static/ts/pages/NewAtomic.ts",
"./static/ts/pages/ManageBoardPost.ts",
"./static/ts/pages/SDManageUser.ts",
"./static/ts/pages/AtomicOverview.ts",
],
minify: {
identifiers: true,