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>
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

@ -1,44 +0,0 @@
const error: HTMLElement = document.getElementById("error")!;
const create_form: HTMLFormElement | null = document.getElementById(
"create-site"
) as HTMLFormElement | null;
if (create_form) {
// create site
create_form.addEventListener("submit", async (e) => {
e.preventDefault();
const res = await fetch("/api/new", {
method: "POST",
body: JSON.stringify({
custom_url: create_form.custom_url.value,
edit_password: crypto.randomUUID(),
group_name: "",
content: JSON.stringify({
// db::bundlesdb::AtomicPaste
_is_atomic: true,
files: [
{
path: "/index.html",
content: "<!-- New HTML Page -->",
},
],
}),
}),
headers: {
"Content-Type": "application/json",
},
});
const json = await res.json();
if (json.success === false) {
error.style.display = "block";
error.innerHTML = `<div class="mdnote-title">${json.message}</div>`;
} else {
window.location.href = `/d/atomic/${json.payload.id}`;
}
});
}
// default export
export default {};

View file

@ -9,15 +9,12 @@ if (process.env.DO_NOT_CLEAR_DIST === undefined)
const output = await build({
entrypoints: [
"./static/ts/editors/AtomicEditor.ts",
"./static/ts/editors/MarkdownEditor.ts",
"./static/ts/editors/ClientFixMarkdown.ts",
"./static/ts/editors/SettingsEditor.ts",
"./static/ts/pages/Footer.ts",
"./static/ts/pages/NewAtomic.ts",
"./static/ts/pages/ManageBoardPost.ts",
"./static/ts/pages/SDManageUser.ts",
"./static/ts/pages/AtomicOverview.ts",
],
minify: {
identifiers: true,

View file

@ -1,55 +0,0 @@
{% extends "../../toolbar_base.html" %}
{% block title %}Dashboard - {{ site_name }}{% endblock %}
{% block main_stuff %}style="overflow: hidden; max-height: 100%;"{% endblock %}
{% block content %}
<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">Dashboard</h1>
</div>
<div class="link-header-bottom">
<a href="/dashboard" class="button">Home</a>
<a href="/dashboard/pastes" class="button">Pastes</a>
<a href="/dashboard/atomic" class="button active">Atomic</a>
<a href="{{ puffer }}/dashboard" class="button">Boards</a>
</div>
</div>
<main class="small flex flex-column g-4">
<div class="flex justify-space-between align-center">
<b>Atomic Pastes</b>
<a class="button theme:primary round" href="/dashboard/atomic/new">
<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-plus-square">
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M8 12h8" />
<path d="M12 8v8" />
</svg>
New
</a>
</div>
<div class="card round secondary flex g-4 flex-column justify-center" id="pastes_list">
{% for p in pastes.iter() %}
<a class="button secondary round full justify-start no-shadow" href="/dashboard/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>
{% endfor %}
</div>
</main>
{% endblock %}

View file

@ -1,105 +0,0 @@
{% extends "../../base.html" %}
{% block title %}{{ file.path }} - {{ custom_url }}{% endblock %}
{% block base_content %}
<div class="flex flex-column" style="height: 100dvh;">
<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);
padding: var(--u-04);
height: 47.8px;
">
<b style="min-width: max-content;" class="device:desktop">{{ file.path }}</b>
<div class="flex g-4">
<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"></div>
<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>
<script type="module">
import { create_editor } from "/static/js/AtomicEditor.js";
create_editor(document.getElementById('_doc'), '{{ custom_url }}', '{{ file.path }}');
globalThis.AtomicEditor.Update(`{{ file_content|safe }}`)
</script>
<style>
.cm-editor,
.cm-line,
.cm-line span {
font-family: monospace !important;
}
</style>
</div>
{% endblock %}

View file

@ -1,49 +0,0 @@
{% extends "../../toolbar_base.html" %}
{% block title %}Dashboard - {{ site_name }}{% endblock %}
{% block main_stuff %}style="overflow: hidden; max-height: 100%;"{% endblock %}
{% block content %}
<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">Dashboard</h1>
</div>
<div class="link-header-bottom">
<a href="/dashboard" class="button">Home</a>
<a href="/dashboard/pastes" class="button">Pastes</a>
<a href="/dashboard/atomic" class="button active">Atomic</a>
<a href="{{ puffer }}/dashboard" class="button">Boards</a>
</div>
</div>
<main class="flex align-center flex-column g-4 small">
<div class="card secondary round border" style="width: 25rem;" id="forms">
<div id="error" class="mdnote note-error full" style="display: none;"></div>
<form class="full flex flex-column g-4" action="/api/auth/register" id="create-site">
<label for="custom_url"><b>Custom URL</b></label>
<input type="text" name="custom_url" id="custom_url" placeholder="Custom URL" class="full round"
minlength="4" maxlength="32" required="true" />
<hr />
<button class="theme:primary full round">
<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-plus">
<path d="M5 12h14" />
<path d="M12 5v14" />
</svg>
Create Site
</button>
</form>
</div>
<script type="module">
import "/static/js/NewAtomic.js";
</script>
</main>
{% endblock %}

View file

@ -1,137 +0,0 @@
{% extends "../../toolbar_base.html" %}
{% block title %}{{ custom_url }} - {{ site_name }}{% endblock %}
{% block main_stuff %}style="overflow: hidden; max-height: 100%;"{% endblock %}
{% block content %}
<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">{{ custom_url }}</h1>
</div>
<div class="link-header-bottom">
<a href="/dashboard" class="button">Home</a>
<a href="/dashboard/pastes" class="button">Pastes</a>
<a href="/dashboard/atomic" class="button active">Atomic</a>
<a href="{{ puffer }}/dashboard" 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>
<div id="success" class="mdnote note-note full" style="display: none;"></div>
<div id="custom_url" style="display: none;">{{ 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 theme: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 p in files.iter() %}
<tr>
<td><a href="?path={{ p.path }}">{{ p.path }}</a></td>
<td class="flex g-4 flex-wrap">
<a class="button secondary round no-shadow" href="?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 no-shadow" data-suffix="{{ 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>
{% endfor %}
</tbody>
</table>
<hr />
<h6 class="no-margin">Paste Options</h6>
<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 no-shadow" target="_blank" href="/{{ 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 no-shadow" 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>
</main>
{% endblock %}

View file

@ -14,7 +14,7 @@
<div class="link-header-bottom">
<a href="/dashboard" class="button">Home</a>
<a href="/dashboard/pastes" class="button active">Pastes</a>
<a href="/dashboard/atomic" class="button">Atomic</a>
<a href="{{ vibrant }}/dashboard" class="button">Vibrant</a>
<a href="{{ puffer }}/dashboard" class="button">Boards</a>
</div>
</div>

View file

@ -14,7 +14,7 @@
<div class="link-header-bottom">
<a href="/dashboard" class="button active">Home</a>
<a href="/dashboard/pastes" class="button">Pastes</a>
<a href="/dashboard/atomic" class="button">Atomic</a>
<a href="{{ vibrant }}/dashboard" class="button">Vibrant</a>
<a href="{{ puffer }}/dashboard" class="button">Boards</a>
</div>
</div>

View file

@ -44,7 +44,7 @@ padding-top: var(--u-04);" class="flex flex-column align-center"{% endblock %}
<form class="flex flex-wrap mobile:justify-center justify-space-between g-4 align-center" id="save-changes">
{% if edit_mode == false %}
<div class="mobile:justify-center flex g-4 justify-start">
<div class="device:desktop mobile:justify-center flex g-4 justify-start">
<button class="round">
<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"
@ -60,13 +60,42 @@ padding-top: var(--u-04);" class="flex flex-column align-center"{% endblock %}
</a>
</div>
<div class="mobile:justify-center flex-wrap flex g-4 justify-start">
<div class="flex g-4 justify-start">
<input class="secondary round" type="text" placeholder="Custom URL" minlength="2" maxlength="500"
name="custom_url" id="custom_url" autocomplete="off" />
<input class="secondary round" type="text" placeholder="Edit Password" minlength="5" name="edit_password" />
<input class="secondary round" type="text" placeholder="Edit Password" minlength="5" name="edit_password"
id="edit_password" />
</div>
<div class="full device:mobile mobile:flex mobile:justify-center flex g-4 justify-start">
<button class="round">
<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-plus">
<path d="M5 12h14" />
<path d="M12 5v14" />
</svg>
Publish
</button>
<a class="button round border" href="javascript:document.getElementById('more-modal').showModal();">
More
</a>
</div>
<style>
@media screen and (max-width: 900px) {
#custom_url {
width: 50%;
}
#edit_password {
width: 50%;
}
}
</style>
<dialog id="more-modal">
<div style="width: 25rem; max-width: 100%;">
<h2 class="no-margin full text-center">More Options</h2>

View file

@ -14,7 +14,7 @@
<div class="link-header-bottom">
<a href="/dashboard" class="button">Home</a>
<a href="/dashboard/pastes" class="button">Pastes</a>
<a href="/dashboard/atomic" class="button">Atomic</a>
<a href="{{ vibrant }}/dashboard" class="button">Vibrant</a>
<a href="{{ puffer }}/dashboard" class="button">Boards</a>
</div>
</div>