diff --git a/app/public/app.js b/app/public/app.js
index 1689f5d..fe47cf1 100644
--- a/app/public/app.js
+++ b/app/public/app.js
@@ -147,6 +147,19 @@ globalThis.tab_editor = () => {
}
};
+globalThis.get_preview = async () => {
+ return await (
+ await fetch("/api/v1/render", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ content: globalThis.editor.getValue(),
+ metadata: globalThis.metadata_editor.getValue(),
+ }),
+ })
+ ).text();
+};
+
globalThis.tab_preview = async () => {
if (
!document
@@ -157,16 +170,7 @@ globalThis.tab_preview = async () => {
}
// render
- const res = await (
- await fetch("/api/v1/render", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- content: globalThis.editor.getValue(),
- metadata: globalThis.metadata_editor.getValue(),
- }),
- })
- ).text();
+ const res = await get_preview();
document.getElementById("preview_tab").innerHTML = res;
hljs.highlightAll();
@@ -366,3 +370,24 @@ setTimeout(() => {
// run initial hash check
hash_check(window.location.hash);
}, 150);
+
+globalThis.submitter_load = (submitter) => {
+ return {
+ load() {
+ submitter.querySelector("[ui_ident=text]").classList.add("hidden");
+ submitter
+ .querySelector("[ui_ident=loader]")
+ .classList.remove("hidden");
+ submitter.setAttribute("disabled", "true");
+ },
+ failed() {
+ submitter
+ .querySelector("[ui_ident=text]")
+ .classList.remove("hidden");
+ submitter
+ .querySelector("[ui_ident=loader]")
+ .classList.add("hidden");
+ submitter.removeAttribute("disabled");
+ },
+ };
+};
diff --git a/app/public/style.css b/app/public/style.css
index 9c219c6..52323f1 100644
--- a/app/public/style.css
+++ b/app/public/style.css
@@ -14,6 +14,8 @@
--color-green: hsl(100, 84%, 20%);
--color-yellow: oklch(47% 0.157 37.304);
--color-purple: hsl(284, 84%, 20%);
+ --color-green-lowered: hsl(100, 84%, 15%);
+ --color-red-lowered: hsl(0, 84%, 35%);
--shadow-x-offset: 0;
--shadow-y-offset: 0.125rem;
@@ -207,6 +209,11 @@ video {
appearance: none;
}
+.button:disabled {
+ opacity: 50%;
+ cursor: not-allowed;
+}
+
.button.small {
--h: 28px;
}
@@ -243,6 +250,24 @@ video {
background: var(--color-super-raised);
}
+.button.green:not(.dark *) {
+ background: var(--color-green);
+ color: white !important;
+
+ &:hover {
+ background: var(--color-green-lowered) !important;
+ }
+}
+
+.button.red:not(.dark *) {
+ background: var(--color-red);
+ color: white !important;
+
+ &:hover {
+ background: var(--color-red-lowered) !important;
+ }
+}
+
/* dropdown */
.dropdown {
position: relative;
@@ -272,9 +297,8 @@ video {
}
.dropdown .inner .title {
- font-weight: 500;
- border-top: solid 1px var(--color-super-lowered);
- margin-top: var(--pad-2);
+ font-weight: 600;
+ font-size: 14px;
}
.dropdown:has(.inner.open) .button:nth-child(1):not(.inner *) {
@@ -710,6 +734,23 @@ span {
}
}
+.loader {
+ animation: spin linear infinite 2s forwards running;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+@keyframes spin {
+ from {
+ transform: rotateZ(0deg);
+ }
+
+ to {
+ transform: rotateZ(360deg);
+ }
+}
+
.items-end {
align-items: flex-end;
}
@@ -753,3 +794,32 @@ details .content {
padding: var(--pad-4);
background: var(--color-surface);
}
+
+/* dialog */
+dialog {
+ background: var(--color-surface);
+ color: var(--color-text);
+ box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size)
+ var(--color-shadow);
+ animation: fadein ease-in-out 1 0.25s forwards running;
+ max-width: 95%;
+ width: 30rem;
+ margin: auto;
+ padding: var(--pad-4);
+ border: 0;
+}
+
+dialog.inner {
+ display: flex;
+ flex-direction: column;
+ gap: var(--pad-2);
+}
+
+dialog::backdrop {
+ background: hsla(0, 0%, 0%, 25%);
+ backdrop-filter: blur(2px);
+}
+
+dialog:is(.dark *)::backdrop {
+ background: hsla(0, 0%, 100%, 15%);
+}
diff --git a/app/templates_src/edit.lisp b/app/templates_src/edit.lisp
index 4bd3901..adde02e 100644
--- a/app/templates_src/edit.lisp
+++ b/app/templates_src/edit.lisp
@@ -27,17 +27,19 @@
("title" "Info")
(text "i"))))
(div
- ("class" "card tab tabs container")
- ("id" "tabs_group")
+ ("class" "flex justify_center tab")
(div
- ("id" "editor_tab")
- ("class" "tab fadein"))
- (div
- ("id" "preview_tab")
- ("class" "tab fadein hidden"))
- (div
- ("id" "metadata_tab")
- ("class" "tab fadein hidden")))
+ ("class" "card tab tabs container w_full")
+ ("id" "tabs_group")
+ (div
+ ("id" "editor_tab")
+ ("class" "tab fadein w_full"))
+ (div
+ ("id" "preview_tab")
+ ("class" "tab fadein hidden w_full"))
+ (div
+ ("id" "metadata_tab")
+ ("class" "tab fadein hidden w_full"))))
(form
("class" "w_full flex flex_col gap_2")
("style" "margin-top: var(--pad-2)")
@@ -80,7 +82,8 @@
("class" "flex gap_2")
(button
("class" "button green")
- (text "Save"))
+ (span ("ui_ident" "text") (text "Save"))
+ (span ("class" "hidden loader no_fill") ("ui_ident" "loader") (text "{{ icon \"loader-circle\" }}")))
(a
("href" "/{{ entry.slug }}")
("class" "button")
@@ -88,12 +91,37 @@
(button
("class" "button red")
- ("ui_ident" "delete")
- (text "Delete"))))
+ ("type" "button")
+ ("onclick" "document.getElementById('delete_modal').showModal()")
+ ("id" "fake_delete_button")
+ (span ("ui_ident" "text") (text "Delete"))
+ (span ("class" "hidden loader no_fill") ("ui_ident" "loader") (text "{{ icon \"loader-circle\" }}")))
+
+ (dialog
+ ("id" "delete_modal")
+ (div
+ ("class" "inner")
+ (h2 ("class" "text_center w_full") (text "Delete {{ entry.slug }}?"))
+ (p (text "Deleting this entry will make its custom slug claimable by anyone."))
+ (p (text "Please ensure that you understand the consequences of deleting this entry before continuing."))
+ (hr ("class" "margin"))
+ (div
+ ("class" "w_full flex gap_2 justify_between")
+ (button
+ ("class" "button")
+ ("type" "button")
+ ("onclick" "document.getElementById('delete_modal').close()")
+ (text "Cancel"))
+ (button
+ ("class" "button red")
+ ("ui_ident" "delete")
+ ("onclick" "document.getElementById('delete_modal').close()")
+ (text "Delete")))))))
; editor
(script ("src" "https://unpkg.com/codemirror@5.39.2/lib/codemirror.js"))
(script ("src" "https://unpkg.com/codemirror@5.39.2/mode/markdown/markdown.js"))
+(script ("src" "https://unpkg.com/codemirror@5.39.2/mode/toml/toml.js"))
(script ("src" "https://unpkg.com/codemirror@5.39.2/addon/display/placeholder.js"))
(link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/lib/codemirror.css"))
(script ("src" "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"))
@@ -105,16 +133,15 @@
(script
(text "setTimeout(() => {
globalThis.init_editor();
- globalThis.init_editor(\"metadata_editor\", \"plain\", \"metadata_tab\", \"editor_metadata_content\");
+ globalThis.init_editor(\"metadata_editor\", \"toml\", \"metadata_tab\", \"editor_metadata_content\");
}, 150);
globalThis.edit_entry = (e) => {
e.preventDefault();
const rm = e.submitter.getAttribute(\"ui_ident\") === \"delete\";
- if (rm && !confirm(\"Are you sure you want to do this?\")) {
- return;
- }
+ const { load, failed } = submitter_load(rm ? document.getElementById(\"fake_delete_button\") : e.submitter);
+ load();
fetch(\"/api/v1/entries/{{ entry.slug }}\", {
method: \"POST\",
@@ -147,7 +174,36 @@
}
} else {
show_message(res.message, false);
+ failed();
}
})
+ }
+
+ globalThis.download = (content, type, name) => {
+ const blob = new Blob([content], { type });
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement(\"a\");
+
+ anchor.setAttribute(\"download\", name);
+ anchor.href = url;
+ anchor.click();
+ anchor.remove();
}"))
(text "{% endblock %}")
+
+(text "{% block dropdown %}")
+(hr)
+(span ("class" "title") (text "export"))
+(button
+ ("class" "button")
+ ("onclick" "download(globalThis.editor.getValue(), 'text/markdown', '{{ entry.slug }}.md')")
+ (text "markdown"))
+(button
+ ("class" "button")
+ ("onclick" "download(globalThis.metadata_editor.getValue(), 'application/toml', '{{ entry.slug }}.toml')")
+ (text "metadata"))
+(button
+ ("class" "button")
+ ("onclick" "(async () => { download(await get_preview(), 'text/html', '{{ entry.slug }}.html') })();")
+ (text "html"))
+(text "{%- endblock %}")
diff --git a/app/templates_src/index.lisp b/app/templates_src/index.lisp
index a55cf42..f425ee0 100644
--- a/app/templates_src/index.lisp
+++ b/app/templates_src/index.lisp
@@ -30,24 +30,27 @@
("title" "Info")
(text "i"))))
(div
- ("class" "card tab tabs container")
- ("id" "tabs_group")
+ ("class" "flex justify_center tab")
(div
- ("id" "editor_tab")
- ("class" "tab fadein"))
- (div
- ("id" "preview_tab")
- ("class" "tab fadein hidden"))
- (div
- ("id" "metadata_tab")
- ("class" "tab fadein hidden")))
+ ("class" "card tab tabs container w_full")
+ ("id" "tabs_group")
+ (div
+ ("id" "editor_tab")
+ ("class" "tab fadein w_full"))
+ (div
+ ("id" "preview_tab")
+ ("class" "tab fadein hidden w_full"))
+ (div
+ ("id" "metadata_tab")
+ ("class" "tab fadein hidden w_full"))))
(form
("class" "w_full flex justify_between gap_2 flex_collapse_rev")
("style" "margin-top: var(--pad-2)")
("onsubmit" "create_entry(event)")
(button
("class" "button")
- (text "Go"))
+ (span ("ui_ident" "text") (text "Go"))
+ (span ("class" "hidden loader no_fill") ("ui_ident" "loader") (text "{{ icon \"loader-circle\" }}")))
(div
("class" "flex gap_2")
(input
@@ -68,6 +71,7 @@
; editor
(script ("src" "https://unpkg.com/codemirror@5.39.2/lib/codemirror.js"))
(script ("src" "https://unpkg.com/codemirror@5.39.2/mode/markdown/markdown.js"))
+(script ("src" "https://unpkg.com/codemirror@5.39.2/mode/toml/toml.js"))
(script ("src" "https://unpkg.com/codemirror@5.39.2/addon/display/placeholder.js"))
(link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/lib/codemirror.css"))
(script ("src" "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"))
@@ -76,11 +80,15 @@
(script
(text "setTimeout(() => {
globalThis.init_editor();
- globalThis.init_editor(\"metadata_editor\", \"plain\", \"metadata_tab\");
+ globalThis.init_editor(\"metadata_editor\", \"toml\", \"metadata_tab\");
}, 150);
globalThis.create_entry = (e) => {
e.preventDefault();
+
+ const { load, failed } = submitter_load(e.submitter);
+ load();
+
fetch(\"/api/v1/entries\", {
method: \"POST\",
headers: {
@@ -102,6 +110,7 @@
window.location.href = `/${res.payload[0]}`;
} else {
show_message(res.message, false);
+ failed();
}
})
}"))
diff --git a/src/markdown.rs b/src/markdown.rs
index 13b7b4e..0a48004 100644
--- a/src/markdown.rs
+++ b/src/markdown.rs
@@ -20,14 +20,21 @@ pub fn render_markdown(input: &str) -> String {
allowed_attributes.insert("align");
allowed_attributes.insert("src");
allowed_attributes.insert("style");
+ allowed_attributes.insert("controls");
+ allowed_attributes.insert("autoplay");
+ allowed_attributes.insert("loop");
tetratto_shared::markdown::clean_html(
html.replace("", ":temp_style"),
+ .replace("", ":temp_style")
+ .replace("")
}
pub(crate) fn is_numeric(value: &str) -> bool {
@@ -138,6 +145,12 @@ fn parse_highlight_line(output: &mut String, buffer: &mut String, line: &str) {
close_1 = false;
}
+ if open_1 && char != '=' {
+ buffer.push('=');
+ open_1 = false;
+ is_open = false;
+ }
+
match char {
'=' => {
if !is_open {
@@ -195,7 +208,13 @@ fn parse_underline_line(output: &mut String, buffer: &mut String, line: &str) {
if open_1 && char != '~' {
is_open = false;
open_1 = false;
- buffer.push('!');
+
+ if char == '[' {
+ // image
+ buffer.push('!');
+ } else {
+ buffer.push_str("!");
+ }
}
if close_1 && char != '!' {
@@ -897,6 +916,7 @@ pub fn get_toc_list(input: &str) -> (String, String) {
let mut output = String::new();
let mut toc = String::new();
let mut in_pre = false;
+ let mut hc_offset: Option = None;
for line in input.split("\n") {
if line.starts_with("```") || line.starts_with("") {
@@ -914,6 +934,7 @@ pub fn get_toc_list(input: &str) -> (String, String) {
if line.starts_with("#") {
// get heading count
let mut hc = 0;
+ let real_hc;
for x in line.chars() {
if x != '#' {
@@ -923,8 +944,21 @@ pub fn get_toc_list(input: &str) -> (String, String) {
hc += 1;
}
+ real_hc = hc.clone();
+ if hc_offset.is_none() {
+ if hc > 1 {
+ // offset this count to 1 so the list renders properly
+ hc_offset = Some(hc - 1);
+ hc = 1;
+ } else {
+ hc_offset = Some(0);
+ }
+ } else if let Some(offset) = hc_offset {
+ hc -= offset;
+ }
+
// add heading with id
- let x = line.replacen(&"#".repeat(hc), "", 1);
+ let x = line.replacen(&"#".repeat(real_hc), "", 1);
let htext = x.trim();
let id = underscore_chars(
@@ -933,7 +967,7 @@ pub fn get_toc_list(input: &str) -> (String, String) {
);
output.push_str(&format!(
- "{}\n",
+ "{}\n\n",
render_markdown(&htext)
));
diff --git a/src/model.rs b/src/model.rs
index 72db41f..0351f2b 100644
--- a/src/model.rs
+++ b/src/model.rs
@@ -122,12 +122,24 @@ pub struct EntryMetadata {
#[serde(default, alias = "CONTAINER_PADDING")]
#[validate(max_length = 32)]
pub container_padding: String,
+ /// The padding of the container on mobile devices.
+ ///
+ /// Syntax:
+ #[serde(default, alias = "CONTAINER_MOBILE_PADDING")]
+ #[validate(max_length = 32)]
+ pub container_mobile_padding: String,
/// The maximum width of the container.
///
/// Syntax:
#[serde(default, alias = "CONTAINER_MAX_WIDTH")]
#[validate(max_length = 16)]
pub container_max_width: String,
+ /// The maximum width of the container on mobile devices.
+ ///
+ /// Syntax:
+ #[serde(default, alias = "CONTAINER_MOBILE_MAX_WIDTH")]
+ #[validate(max_length = 16)]
+ pub container_mobile_max_width: String,
/// The padding of the container.
/// The color of the text in the inner container.
#[serde(default, alias = "CONTAINER_INNER_FOREGROUND_COLOR")]
@@ -257,6 +269,12 @@ pub struct EntryMetadata {
#[serde(default, alias = "CONTAINER_BORDER_WIDTH")]
#[validate(max_length = 16)]
pub container_border_width: String,
+ /// The border around the container on mobile devices.
+ ///
+ /// Syntax:
+ #[serde(default, alias = "CONTAINER_MOBILE_BORDER_WIDTH")]
+ #[validate(max_length = 16)]
+ pub container_mobile_border_width: String,
/// The border around the container.
///
/// Syntax:
@@ -269,6 +287,12 @@ pub struct EntryMetadata {
#[serde(default, alias = "CONTAINER_BORDER_RADIUS")]
#[validate(max_length = 16)]
pub container_border_radius: String,
+ /// The border around the container on mobile devices.
+ ///
+ /// Syntax:
+ #[serde(default, alias = "CONTAINER_MOBILE_BORDER_RADIUS")]
+ #[validate(max_length = 16)]
+ pub container_mobile_border_radius: String,
/// The shadow around the container.
///
/// Syntax:
@@ -392,6 +416,18 @@ macro_rules! metadata_css {
}
};
+ ($selector:expr, $property:literal, $self:ident.$field:ident->$output:ident, $media_query:literal) => {
+ if !$self.$field.is_empty() {
+ $output.push_str(&format!(
+ "@media screen and ({}) {{ {} {{ {}: {}; }} }}\n",
+ $media_query,
+ $selector,
+ $property,
+ EntryMetadata::css_escape(&$self.$field)
+ ));
+ }
+ };
+
($selector:expr, $property:literal, $field:ident->$output:ident) => {
if !$field.is_empty() {
$output.push_str(&format!(
@@ -526,7 +562,9 @@ impl EntryMetadata {
let mut output = ""
diff --git a/src/routes.rs b/src/routes.rs
index 553ee01..d2290aa 100644
--- a/src/routes.rs
+++ b/src/routes.rs
@@ -146,6 +146,10 @@ async fn view_request(
};
slug = slug.to_lowercase();
+ let viewed_header = (
+ "Set-Cookie".to_string(),
+ format!("Atto-Viewed=true; Path=/{slug}; Max-Age=86400"),
+ );
let entry = match data
.query(&SimplifiedQuery {
@@ -165,7 +169,10 @@ async fn view_request(
&Error::GeneralNotFound("entry".to_string()).to_string(),
);
- return Html(tera.render("error.lisp", &ctx).unwrap());
+ return (
+ [viewed_header],
+ Html(tera.render("error.lisp", &ctx).unwrap()),
+ );
}
};
@@ -179,7 +186,10 @@ async fn view_request(
if let Err(e) = metadata.validate() {
let mut ctx = default_context(&data, &build_code);
ctx.insert("error", &e.to_string());
- return Html(tera.render("error.lisp", &ctx).unwrap());
+ return (
+ [viewed_header],
+ Html(tera.render("error.lisp", &ctx).unwrap()),
+ );
}
// ...
@@ -188,7 +198,10 @@ async fn view_request(
{
let mut ctx = default_context(&data, &build_code);
ctx.insert("entry", &entry);
- return Html(tera.render("password.lisp", &ctx).unwrap());
+ return (
+ [viewed_header],
+ Html(tera.render("password.lisp", &ctx).unwrap()),
+ );
}
// pull views
@@ -205,11 +218,18 @@ async fn view_request(
// count view
let views = r.value.parse::().unwrap();
- if let Err(e) = data.update(r.id, (views + 1).to_string()).await {
- let mut ctx = default_context(&data, &build_code);
- ctx.insert("error", &e.to_string());
+ if jar.get("Atto-Viewed").is_none() {
+ // the Atto-Viewed cookie tells us if we've already viewed this
+ // entry recently (at all in the past week)
+ if let Err(e) = data.update(r.id, (views + 1).to_string()).await {
+ let mut ctx = default_context(&data, &build_code);
+ ctx.insert("error", &e.to_string());
- return Html(tera.render("error.lisp", &ctx).unwrap());
+ return (
+ [viewed_header],
+ Html(tera.render("error.lisp", &ctx).unwrap()),
+ );
+ }
}
views
@@ -220,7 +240,10 @@ async fn view_request(
let mut ctx = default_context(&data, &build_code);
ctx.insert("error", &e.to_string());
- return Html(tera.render("error.lisp", &ctx).unwrap());
+ return (
+ [viewed_header],
+ Html(tera.render("error.lisp", &ctx).unwrap()),
+ );
}
}
} else {
@@ -240,10 +263,16 @@ async fn view_request(
if metadata.safety_content_warning.is_empty() | qflags.contains(&QuickFlag::AcceptWarning) {
// regular view
- Html(tera.render("view.lisp", &ctx).unwrap())
+ (
+ [viewed_header],
+ Html(tera.render("view.lisp", &ctx).unwrap()),
+ )
} else {
// warning
- Html(tera.render("warning.lisp", &ctx).unwrap())
+ (
+ [viewed_header],
+ Html(tera.render("warning.lisp", &ctx).unwrap()),
+ )
}
}