add(frontend): vibrant integration

remove(frontend): atomic pastes
This commit is contained in:
hkau 2024-04-29 17:42:47 -04:00
parent 61f9e88523
commit c778a29581
21 changed files with 62 additions and 1711 deletions

View file

@ -1,6 +1,6 @@
use actix_web::{delete, get, post, web, HttpRequest, HttpResponse, Responder};
use actix_web::{get, post, web, HttpRequest, HttpResponse, Responder};
use crate::db::{self, AtomicPasteFSFile, DefaultReturn, FullPaste, PasteMetadata};
use crate::db::{self, DefaultReturn, FullPaste, PasteMetadata};
use crate::{markdown, ssm, utility};
#[derive(Default, PartialEq, serde::Deserialize)]
@ -485,187 +485,3 @@ pub async fn read_atomic_request(req: HttpRequest, data: web::Data<db::AppData>)
.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

@ -635,84 +635,6 @@ impl Database {
};
}
/// Get all atomic [pastes](Paste) owned by a specific user
///
/// # Arguments:
/// * `owner` - `String` of the owner's `username`
pub async fn get_atomic_pastes_by_owner(
&self,
owner: String,
) -> DefaultReturn<Option<Vec<PasteIdentifier>>> {
// check in cache
let cached = self
.base
.cachedb
.get(format!("pastes-by-owner-atomic:{}:atomic", owner))
.await;
if cached.is_some() {
// ...
let pastes =
serde_json::from_str::<Vec<PasteIdentifier>>(cached.unwrap().as_str()).unwrap();
// return
return DefaultReturn {
success: true,
message: owner,
payload: Option::Some(pastes),
};
}
// ...
let query: &str = if (self.base.db._type == "sqlite") | (self.base.db._type == "mysql") {
"SELECT * FROM \"Pastes\" WHERE \"metadata\" LIKE ? AND \"content\" LIKE ?"
} else {
"SELECT * FROM \"Pastes\" WHERE \"metadata\" LIKE $1 AND \"content\" LIKE $2"
};
let c = &self.base.db.client;
let res = sqlquery(query)
.bind::<&String>(&format!("%\"owner\":\"{}\"%", &owner))
.bind("%\"_is_atomic\":true%")
.fetch_all(c)
.await;
if res.is_err() {
return DefaultReturn {
success: false,
message: String::from(res.err().unwrap().to_string()),
payload: Option::None,
};
}
// build res
let mut full_res: Vec<PasteIdentifier> = Vec::new();
for row in res.unwrap() {
let row = self.base.textify_row(row).data;
full_res.push(PasteIdentifier {
custom_url: row.get("custom_url").unwrap().to_string(),
id: row.get("id").unwrap().to_string(),
});
}
// store in cache
self.base
.cachedb
.set(
format!("pastes-by-owner:{}:atomic", owner),
serde_json::to_string::<Vec<PasteIdentifier>>(&full_res).unwrap(),
)
.await;
// return
return DefaultReturn {
success: true,
message: owner,
payload: Option::Some(full_res),
};
}
// SET
/// Create a new [`Paste`] given various properties
///
@ -781,6 +703,15 @@ impl Database {
};
}
// project cannot have names we may need
if ["dashboard", "api"].contains(&p.custom_url.as_str()) {
return DefaultReturn {
success: false,
message: String::from("Custom URL is invalid"),
payload: Option::None,
};
}
// (characters used)
let regex = regex::RegexBuilder::new("^[\\w\\_\\-\\.\\!\\p{Extended_Pictographic}]+$")
.multi_line(true)

View file

@ -116,8 +116,6 @@ async fn main() -> std::io::Result<()> {
.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)
@ -130,10 +128,6 @@ async fn main() -> std::io::Result<()> {
.service(crate::pages::settings::user_settings_request)
.service(crate::pages::settings::paste_settings_request)
.service(crate::pages::paste_view::dashboard_request)
// GET dashboard (atomic pastes)
.service(crate::pages::atomic_editor::dashboard_request)
.service(crate::pages::atomic_editor::new_request)
.service(crate::pages::atomic_editor::edit_request)
// GET staff
.service(crate::pages::staff::dashboard_request)
.service(crate::pages::staff::staff_boards_dashboard_request)

View file

@ -1,237 +0,0 @@
use actix_web::HttpRequest;
use actix_web::{get, web, HttpResponse, Responder};
use super::base;
use askama::Template;
use crate::db::{self, AtomicPasteFSFile, FullPaste, PasteMetadata};
#[derive(Default, PartialEq, serde::Deserialize)]
struct EditQueryProps {
pub path: Option<String>,
}
#[derive(Template)]
#[template(path = "paste/atomic/overview.html")]
struct FSOverviewTemplate {
custom_url: String,
files: Vec<db::AtomicPasteFSFile>,
// required fields (super::base)
info: String,
auth_state: bool,
guppy: String,
puffer: String,
site_name: String,
body_embed: String,
}
#[derive(Template)]
#[template(path = "paste/atomic/editor.html")]
struct EditorTemplate {
custom_url: String,
file: db::AtomicPasteFSFile,
file_content: String,
// required fields
site_name: String,
body_embed: String,
}
#[derive(Template)]
#[template(path = "paste/atomic/new.html")]
struct NewTemplate {
// required fields (super::base)
info: String,
auth_state: bool,
guppy: String,
puffer: String,
site_name: String,
body_embed: String,
}
#[derive(Template)]
#[template(path = "paste/atomic/dashboard.html")]
struct DashboardTemplate {
pastes: Vec<db::PasteIdentifier>,
// required fields (super::base)
info: String,
auth_state: bool,
guppy: String,
puffer: String,
site_name: String,
body_embed: String,
}
#[get("/dashboard/atomic")]
/// Available at "/dashboard/atomic"
pub async fn dashboard_request(req: HttpRequest, data: web::Data<db::AppData>) -> impl Responder {
// verify auth status
let (set_cookie, _, token_user) = base::check_auth_status(req.clone(), data.clone()).await;
if token_user.is_none() {
// you must have an account to use atomic pastes
return super::errors::error401(req, data).await;
}
// fetch pastes
let pastes = data
.db
.get_atomic_pastes_by_owner(token_user.clone().unwrap().payload.unwrap().user.username)
.await;
// ...
let base = base::get_base_values(token_user.is_some());
return HttpResponse::Ok()
.append_header(("Set-Cookie", set_cookie))
.append_header(("Content-Type", "text/html"))
.body(
DashboardTemplate {
pastes: pastes.payload.unwrap(),
// required fields
info: base.info,
auth_state: base.auth_state,
guppy: base.guppy,
puffer: base.puffer,
site_name: base.site_name,
body_embed: base.body_embed,
}
.render()
.unwrap(),
);
}
#[get("/dashboard/atomic/new")]
/// Available at "/dashboard/atomic/new"
pub async fn new_request(req: HttpRequest, data: web::Data<db::AppData>) -> impl Responder {
// verify auth status
let (set_cookie, _, token_user) = base::check_auth_status(req.clone(), data.clone()).await;
if token_user.is_none() {
// you must have an account to use atomic pastes
return super::errors::error401(req, data).await;
}
// ...
let base = base::get_base_values(token_user.is_some());
return HttpResponse::Ok()
.append_header(("Set-Cookie", set_cookie))
.append_header(("Content-Type", "text/html"))
.body(
NewTemplate {
// required fields
info: base.info,
auth_state: base.auth_state,
guppy: base.guppy,
puffer: base.puffer,
site_name: base.site_name,
body_embed: base.body_embed,
}
.render()
.unwrap(),
);
}
#[get("/dashboard/atomic/{id:.*}")]
/// Available at "/dashboard/atomic/{id}"
pub async fn edit_request(
req: HttpRequest,
data: web::Data<db::AppData>,
info: web::Query<EditQueryProps>,
) -> impl Responder {
// verify auth status
let (set_cookie, _, token_user) = base::check_auth_status(req.clone(), data.clone()).await;
if token_user.is_none() {
// you must have an account to use atomic pastes
// we'll likely track requests used by atomic pastes and limit it in the future, similar to Vibrant
// ...or just migrate to Vibrant
return super::errors::error401(req, data).await;
}
// get paste
let id: String = req.match_info().get("id").unwrap().to_string();
let paste: db::DefaultReturn<Option<FullPaste<PasteMetadata, String>>> =
data.db.get_paste_by_id(id).await;
if paste.success == false {
return super::errors::error404(req, data).await;
}
// make sure paste is an atomic paste
let unwrap = paste.payload.unwrap().paste;
let is_atomic = unwrap.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.content);
if real_content.is_err() {
return HttpResponse::NotAcceptable().body("Paste failed to deserialize");
}
let decoded = real_content.unwrap();
// show file list if path is none
if info.path.is_none() {
let base = base::get_base_values(token_user.is_some());
return HttpResponse::Ok()
.append_header(("Set-Cookie", set_cookie))
.append_header(("Content-Type", "text/html"))
.body(
FSOverviewTemplate {
custom_url: unwrap.custom_url.clone(),
files: decoded.files,
// required fields
info: base.info,
auth_state: base.auth_state,
guppy: base.guppy,
puffer: base.puffer,
site_name: base.site_name,
body_embed: base.body_embed,
}
.render()
.unwrap(),
);
}
let path_unwrap = info.path.clone().unwrap();
// ...
let mut file = decoded.files.iter().find(|f| f.path == path_unwrap);
let blank_file = AtomicPasteFSFile {
path: path_unwrap.clone(),
content: String::from("<!-- New HTML Page -->"),
};
if file.is_none() {
file = Option::Some(&blank_file);
}
// ...
let file = file.unwrap().to_owned();
let file_content = file
.content
.replace("\\", "\\\\")
.replace("`", "\\`")
.replace("$", "\\$")
.replace("/", "\\/");
let base = base::get_base_values(token_user.is_some());
return HttpResponse::Ok()
.append_header(("Set-Cookie", set_cookie))
.append_header(("Content-Type", "text/html"))
.body(
EditorTemplate {
custom_url: unwrap.custom_url,
file,
file_content,
// required fields
site_name: base.site_name,
body_embed: base.body_embed,
}
.render()
.unwrap(),
);
}

View file

@ -7,6 +7,7 @@ pub struct BaseTemplate {
pub auth_state: bool,
pub guppy: String,
pub puffer: String,
pub vibrant: String,
pub site_name: String,
pub body_embed: String,
}
@ -34,6 +35,7 @@ pub fn get_base_values(token_cookie: bool) -> BaseTemplate {
auth_state: token_cookie,
guppy: std::env::var("GUPPY_ROOT").unwrap_or(String::new()),
puffer: std::env::var("PUFFER_ROOT").unwrap_or(String::new()),
vibrant: std::env::var("VIBRANT_ROOT").unwrap_or(String::new()),
site_name: std::env::var("SITE_NAME").unwrap_or("Bundlrs".to_string()),
body_embed,
}

View file

@ -34,6 +34,7 @@ struct DashboardTemplate {
auth_state: bool,
guppy: String,
puffer: String,
vibrant: String,
site_name: String,
body_embed: String,
}
@ -48,6 +49,7 @@ struct InboxTemplate {
auth_state: bool,
guppy: String,
puffer: String,
vibrant: String,
site_name: String,
body_embed: String,
}
@ -180,7 +182,7 @@ Allow: /
Disallow: /api
Disallow: /admin
Disallow: /paste
Disallow: /d/atomic
Disallow: /dashboard
Disallow: /*?",
);
}
@ -219,6 +221,7 @@ pub async fn dashboard_request(req: HttpRequest, data: web::Data<AppData>) -> im
auth_state: base.auth_state,
guppy: base.guppy,
puffer: base.puffer,
vibrant: base.vibrant,
site_name: base.site_name,
body_embed: base.body_embed,
}
@ -267,6 +270,7 @@ pub async fn inbox_request(
auth_state: base.auth_state,
guppy: base.guppy,
puffer: base.puffer,
vibrant: base.vibrant,
site_name: base.site_name,
body_embed: base.body_embed,
}

View file

@ -1,5 +1,4 @@
//! Page Routes ("/...")
pub mod atomic_editor;
pub mod base;
pub mod errors;
pub mod home;

View file

@ -48,6 +48,7 @@ struct DashboardTemplate {
auth_state: bool,
guppy: String,
puffer: String,
vibrant: String,
site_name: String,
body_embed: String,
}
@ -440,6 +441,7 @@ pub async fn dashboard_request(
auth_state: base.auth_state,
guppy: base.guppy,
puffer: base.puffer,
vibrant: base.vibrant,
site_name: base.site_name,
body_embed: base.body_embed,
}

View file

@ -1,689 +0,0 @@
/**
* @file Handle Atomic Paste file editor
* @name AtomicEditor.ts
* @license MIT
*/
// codemirror
import { EditorState } from "@codemirror/state";
import { EditorView, keymap, placeholder } from "@codemirror/view";
import {
syntaxHighlighting,
indentOnInput,
foldKeymap,
HighlightStyle,
indentUnit,
} from "@codemirror/language";
import {
autocompletion,
completionKeymap,
closeBracketsKeymap,
CompletionContext,
} from "@codemirror/autocomplete";
import {
defaultKeymap,
historyKeymap,
indentWithTab,
} from "@codemirror/commands";
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";
import { linter, Diagnostic, lintGutter } from "@codemirror/lint";
// prettier
// @ts-ignore
import * as prettier from "prettier/standalone.mjs";
import type { Options } from "prettier";
import EstreePlugin from "prettier/plugins/estree";
import BabelParser from "prettier/plugins/babel";
import CSSParser from "prettier/plugins/postcss";
import HTMLParser from "prettier/plugins/html";
// create editor theme
export const DefaultHighlight = HighlightStyle.define([
{
tag: tags.keyword,
color: "var(--red3)",
},
{
tag: tags.tagName,
color: "var(--red3)",
textShadow: "0 0 1px var(--red3)",
},
{
tag: tags.variableName,
color: "var(--blue2)",
},
{
tag: tags.propertyName,
color: "var(--red)",
},
{
tag: tags.comment,
color: "var(--text-color-faded)",
},
{
tag: tags.number,
color: "var(--yellow)",
},
{
tag: tags.string,
color: "var(--green)",
},
{
tag: tags.operator,
color: "var(--red3)",
},
{
tag: tags.bool,
color: "var(--blue2)",
},
{
tag: tags.attributeName,
color: "var(--blue2)",
},
{
tag: tags.attributeValue,
color: "var(--green)",
},
]);
// create lint
import { HTMLHint } from "htmlhint";
let LastLint = performance.now();
export const HTMLLint = linter((view) => {
let diagnostics: Diagnostic[] = [];
// get hints
const hints = HTMLHint.verify(
view.state.sliceDoc(0, view.state.doc.length),
{
"doctype-first": true,
// attributes (https://htmlhint.com/docs/user-guide/list-rules#attributes)
"attr-lowercase": true,
"attr-value-not-empty": true,
"attr-value-double-quotes": true,
// tags (https://htmlhint.com/docs/user-guide/list-rules#tags)
"tag-self-close": true,
"tag-pair": true,
// id (https://htmlhint.com/docs/user-guide/list-rules#id)
"id-unique": true,
}
);
// turn hints into diagnostics
if (hints.length > 0 && performance.now() - LastLint > 100) {
LastLint = performance.now(); // can only run lint every 100ms
// ...
for (const hint of hints) {
if (hint.line === view.state.doc.lines) hint.line = 1; // do not add an error to the last line (breaks editor)
const line = view.state.doc.line(hint.line);
diagnostics.push({
from: line.from + hint.col - 1,
to: line.from + hint.col + hint.raw.length - 1,
severity: hint.type,
message: `${hint.message} (${hint.line}:${hint.col})\n${hint.rule.id}: ${hint.rule.description}`,
});
}
}
// return
return diagnostics;
});
export const EmptyLint = linter((view) => {
let diagnostics: Diagnostic[] = [];
// return
return diagnostics;
});
// create completion context
/**
* @function BasicCompletion
*
* @param {CompletionContext} context
* @return {*}
*/
function BasicCompletion(context: CompletionContext): any {
let word = context.matchBefore(/\w*/);
if (!word || (word.from == word.to && !context.explicit)) return null;
return {
from: word.from,
options: [
{
label: "boilerplate",
type: "variable",
apply: `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<span>Hello, world!</span>
</body>
</html>`,
info: "Basic HTML Page Boilerplate",
},
],
};
}
// create editor function
export function create_editor(
element: HTMLElement,
custom_url: string,
path: string
) {
if (globalThis.Bun) return; // must be run from client
const file_type = path.split(".").pop();
const view = new EditorView({
// @ts-ignore
state: EditorState.create({
doc: "",
extensions: [
placeholder(path),
syntaxHighlighting(DefaultHighlight, { fallback: true }),
autocompletion({
override: [
BasicCompletion,
path.endsWith("css")
? cssCompletionSource
: htmlCompletionSource, // html should always be the default
],
activateOnTyping: true,
}),
lintGutter(),
// EditorView.lineWrapping,
EditorView.updateListener.of(async (update) => {
if (update.docChanged) {
const content = update.state.doc.toString();
if (content === "") return;
(globalThis as any).AtomicEditor.Content = content;
}
}),
// keymaps
keymap.of({
...closeBracketsKeymap,
...defaultKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
...indentWithTab,
}),
indentOnInput(),
indentUnit.of(" "),
// language
path.endsWith("css")
? css()
: path.endsWith("js")
? javascript()
: html({ autoCloseTags: true }),
path.endsWith("html") ? HTMLLint : EmptyLint,
// default
basicSetup,
],
}),
parent: element,
});
// global functions
(globalThis as any).AtomicEditor = {
Content: "",
Update: (content: string, clear: boolean = false) => {
const transaction = view.state.update({
changes: {
from: 0,
to: view.state.doc.length,
insert: content,
},
scrollIntoView: true,
});
if (transaction) {
view.dispatch(transaction);
}
},
Format: async () => {
try {
const formatted = await prettier.format(
(globalThis as any).AtomicEditor.Content,
{
parser: "html",
plugins: [
EstreePlugin,
BabelParser,
HTMLParser,
CSSParser,
],
htmlWhitespaceSensitivity: "ignore",
// all from the project's .prettierrc
useTabs: false,
singleQuote: false,
tabWidth: 4,
trailingComma: "es5",
printWidth: 85,
semi: true,
} as Options
);
(globalThis as any).AtomicEditor.Update(formatted);
} catch (err) {
alert(err);
}
},
};
// handle interactions
let view_split: boolean = false;
const preview_button = document.getElementById(
"preview"
) as HTMLButtonElement | null;
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) {
URL.revokeObjectURL(url);
}
// create blob
const blob = new Blob([(globalThis as any).AtomicEditor.Content], {
type: "text/html",
});
// get url
url = URL.createObjectURL(blob);
// load
preview_pane.src = url;
// interactions
preview_pane.addEventListener("load", () => {
// functions
(globalThis as any).update_document_content = () => {
// update content
(globalThis as any).AtomicEditor.Update(
`<!DOCTYPE html>\n\n${preview_pane.contentDocument?.documentElement.outerHTML}`
);
// (globalThis as any).AtomicEditor.Format();
};
// element focus
preview_pane.contentDocument?.addEventListener("click", (e) => {
build_element_property_window(e.target as HTMLElement);
});
});
});
}
const save_button = document.getElementById(
"save"
) as HTMLButtonElement | null;
if (save_button) {
save_button.addEventListener("click", async () => {
const res = await fetch(`/api/atomic/crud/${custom_url}${path}`, {
method: "POST",
body: (globalThis as any).AtomicEditor.Content,
headers: {
"Content-Type": "text/plain",
},
});
const json = await res.json();
if (json.success === false) {
return alert(json.message);
} else {
return alert("File saved");
}
});
}
// prevent exit
window.addEventListener("beforeunload", (e) => {
e.preventDefault();
e.returnValue = true;
});
// return
return view;
}
// ...
function build_element_attribute_field(
property_display: string,
property_name: string,
value: string
): HTMLDivElement {
const field = document.createElement("div");
field.className =
"card less-padding secondary border round full flex flex-column g-2";
field.innerHTML = `<b>${property_display}</b><input
value="${value}"
placeholder="${property_name}"
oninput="current_element.setAttribute('${property_name}', event.target.value); window.update_document_content();"
onchange="window.AtomicEditor.Format();"
class="full round"
style="height: 35px !important;"
/>`;
// return
return field;
}
function build_element_style_field(
property_display: string,
property_name: string,
value: string
): HTMLDivElement {
const field = document.createElement("div");
field.className =
"card less-padding secondary border round full flex flex-column g-2";
field.innerHTML = `<b>${property_display}</b><input
value="${value}"
placeholder="${property_name}"
oninput="current_element.style.setProperty('${property_name}', event.target.value); window.update_document_content();"
onchange="window.AtomicEditor.Format();"
class="full round"
style="height: 35px !important;"
/>`;
// return
return field;
}
function build_element_field(
property_display: string,
property_name: string,
value: string
): HTMLDivElement {
const field = document.createElement("div");
field.className =
"card less-padding secondary border round full flex flex-column g-2";
field.innerHTML = `<b>${property_display}</b><input
value="${value}"
placeholder="${property_name}"
oninput="current_element['${property_name}'] = event.target.value; window.update_document_content();"
onchange="window.AtomicEditor.Format();"
class="full round"
style="height: 35px !important;"
/>`;
// return
return field;
}
function build_element_property_window(element: HTMLElement): void {
if (document.getElementById("property_window")) {
document.getElementById("property_window")!.remove();
(globalThis as any).current_element.style.removeProperty("box-shadow");
}
if (document.getElementById("preview_box")) {
document.getElementById("preview_box")!.remove();
}
(globalThis as any).current_element = element;
// preview box
(globalThis as any).create_preview_box = () => {
const preview_box = document.createElement("div");
const rect = element.getBoundingClientRect();
preview_box.style.position = "absolute";
preview_box.style.top = `${rect.top}px`;
preview_box.style.left = `${rect.left}px`;
preview_box.style.width = `${rect.width}px`;
preview_box.style.height = `${rect.height}px`;
preview_box.style.background = "transparent";
preview_box.style.boxShadow = "0 0 0 4px #00FF00";
preview_box.id = "preview_box";
element.appendChild(preview_box);
};
(globalThis as any).remove_preview_box = () => {
if (element.querySelector("#preview_box")) {
element.querySelector("#preview_box")!.remove();
}
};
// create property window
const property_window = document.createElement("div");
property_window.style.position = "fixed";
property_window.style.top = "0";
property_window.style.left = "0";
property_window.style.width = "25rem";
property_window.style.maxWidth = "100dvw";
property_window.style.maxHeight = "calc(50% - 22px)";
property_window.style.boxShadow = "-2px 2px 4px hsla(0, 0%, 0%, 25%)";
property_window.style.overflow = "auto";
property_window.className = "card border flex flex-column g-4";
property_window.id = "property_window";
// titlebar
const titlebar = document.createElement("div");
titlebar.className =
"bg-0 full flex align-center justify-space-between g-4";
// titlebar.style.position = "sticky";
// titlebar.style.top = "0";
property_window.appendChild(titlebar);
const titlebar_title = document.createElement("b");
titlebar_title.innerText = element.nodeName;
titlebar.appendChild(titlebar_title);
const close_button = document.createElement("button");
close_button.className = "round";
close_button.innerText = "Close";
close_button.addEventListener("click", () => {
property_window.remove();
(globalThis as any).current_element.style.removeProperty("box-shadow");
(globalThis as any).update_document_content();
(globalThis as any).AtomicEditor.Format();
});
titlebar.appendChild(close_button);
// basic fields
property_window.appendChild(
build_element_field("Text Content", "innerText", element.innerText)
);
property_window.appendChild(
build_element_field("Class Name", "className", element.className)
);
property_window.appendChild(build_element_field("ID", "id", element.id));
// attributes
property_window.appendChild(document.createElement("hr"));
const attribute_list = document.createElement("div");
attribute_list.className = "flex flex-column g-2";
attribute_list.id = "attribute_list";
property_window.appendChild(attribute_list);
// "add field" button
const add_attr_button = document.createElement("button");
add_attr_button.innerText = "Add Custom Attribute";
add_attr_button.className = "full round";
add_attr_button.addEventListener("click", () => {
const name = prompt("Attribute Name: ");
if (!name) {
return;
}
attribute_list.appendChild(
build_element_attribute_field(name, name, "")
);
});
attribute_list.appendChild(add_attr_button);
// from existing attributes
const attributes = element.attributes;
for (const attr of Object.values(attributes)) {
attribute_list.appendChild(
build_element_attribute_field(attr.name, attr.name, attr.value)
);
}
// style fields
property_window.appendChild(document.createElement("hr"));
property_window.appendChild(
build_element_style_field(
"Background",
"background",
element.style.background
)
);
property_window.appendChild(
build_element_style_field("Color", "color", element.style.color)
);
property_window.appendChild(
build_element_style_field("Border", "border", element.style.border)
);
property_window.appendChild(
build_element_style_field("Width", "width", element.style.width)
);
property_window.appendChild(
build_element_style_field("Padding", "padding", element.style.padding)
);
property_window.appendChild(
build_element_style_field("Margin", "margin", element.style.margin)
);
property_window.appendChild(
build_element_style_field("Display", "display", element.style.display)
);
property_window.appendChild(document.createElement("hr"));
// "add field" button
const add_button = document.createElement("button");
add_button.innerText = "Add Custom Style";
add_button.className = "full round";
add_button.addEventListener("click", () => {
const name = prompt("Property Name: ");
if (!name) {
return;
}
property_window.appendChild(build_element_style_field(name, name, ""));
});
property_window.appendChild(add_button);
// from style attribute
const styles_list = document.createElement("div");
styles_list.className = "flex flex-column g-2";
styles_list.id = "styles_list";
property_window.appendChild(styles_list);
const styles_from_attribute = element.style;
if (styles_from_attribute) {
for (const style of Object.values(styles_from_attribute)) {
const value = element.style.getPropertyValue(style);
if (!value) {
continue;
}
styles_list.appendChild(
build_element_style_field(style, style, value)
);
}
}
// append
document.body.appendChild(property_window);
}
// default export
export default {
DefaultHighlight,
create_editor,
};

View file

@ -420,6 +420,13 @@ export default function CreateEditor(ElementID: string, content: string) {
window.localStorage.setItem("LastEditURL", window.location.href);
// vibrant warning
if (content && content.includes('"_is_atomic":true')) {
alert(
'This paste needs to be moved to a Vibrant project. Please check the "Vibrant" tab on your user dashboard for more information.'
);
}
// create editor
const view = new EditorView({
// @ts-ignore

View file

@ -1,114 +0,0 @@
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>