From cacd992f534b4765c466fcc65ec3cfe952308844 Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 16 Aug 2025 23:24:20 -0400 Subject: [PATCH] add: cleaner ui, prior view check, audio element, rendering fixes --- app/public/app.js | 45 ++++++++++++++---- app/public/style.css | 76 ++++++++++++++++++++++++++++-- app/templates_src/edit.lisp | 90 +++++++++++++++++++++++++++++------- app/templates_src/index.lisp | 33 ++++++++----- src/markdown.rs | 42 +++++++++++++++-- src/model.rs | 42 ++++++++++++++++- src/routes.rs | 49 ++++++++++++++++---- 7 files changed, 320 insertions(+), 57 deletions(-) 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("", ":temp_audio"), allowed_attributes, ) .replace(":temp_style", "") + .replace(":temp_audio:temp_audio", "") } 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()), + ) } }