add: cleaner ui, prior view check, audio element, rendering fixes

This commit is contained in:
trisua 2025-08-16 23:24:20 -04:00
parent f215d038b3
commit cacd992f53
7 changed files with 320 additions and 57 deletions

View file

@ -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");
},
};
};

View file

@ -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%);
}

View file

@ -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 %}")

View file

@ -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();
}
})
}"))

View file

@ -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("<style>", "<span>:temp_style")
.replace("</style>", "</span>:temp_style"),
.replace("</style>", "</span>:temp_style")
.replace("<audio", ":temp_audio<span")
.replace("</audio>", "</span>:temp_audio"),
allowed_attributes,
)
.replace("<span>:temp_style", "<style>")
.replace("</span>:temp_style", "</style>")
.replace(":temp_audio<span", "<audio")
.replace("</span>:temp_audio", "</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("&excl;");
}
}
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<usize> = None;
for line in input.split("\n") {
if line.starts_with("```") || line.starts_with("<style>") || line.starts_with("</style>") {
@ -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!(
"<h{hc} id=\"{id}\">{}</h{hc}>\n",
"<h{real_hc} id=\"{id}\">{}</h{real_hc}>\n\n",
render_markdown(&htext)
));

View file

@ -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: <https://developer.mozilla.org/en-US/docs/Web/CSS/padding>
#[serde(default, alias = "CONTAINER_MOBILE_PADDING")]
#[validate(max_length = 32)]
pub container_mobile_padding: String,
/// The maximum width of the container.
///
/// Syntax: <https://developer.mozilla.org/en-US/docs/Web/CSS/max-width>
#[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: <https://developer.mozilla.org/en-US/docs/Web/CSS/max-width>
#[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: <https://developer.mozilla.org/en-US/docs/Web/CSS/border-width>
#[serde(default, alias = "CONTAINER_MOBILE_BORDER_WIDTH")]
#[validate(max_length = 16)]
pub container_mobile_border_width: String,
/// The border around the container.
///
/// Syntax: <https://developer.mozilla.org/en-US/docs/Web/CSS/border-style>
@ -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: <https://developer.mozilla.org/en-US/docs/Web/CSS/border-radius>
#[serde(default, alias = "CONTAINER_MOBILE_BORDER_RADIUS")]
#[validate(max_length = 16)]
pub container_mobile_border_radius: String,
/// The shadow around the container.
///
/// Syntax: <https://developer.mozilla.org/en-US/docs/Web/CSS/box-shadow>
@ -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 = "<style>".to_string();
metadata_css!(".container", "padding", self.container_padding->output);
metadata_css!(".container", "padding", self.container_mobile_padding->output, "max-width: 900px");
metadata_css!(".container", "max-width", self.container_max_width->output);
metadata_css!(".container", "max-width", self.container_mobile_max_width->output, "max-width: 900px");
metadata_css!(".container", "color", self.container_inner_foreground_color->output);
metadata_css!(".container", "background", self.container_inner_background->output);
metadata_css!(".container", "background-color", self.container_inner_background_color->output);
@ -550,7 +588,9 @@ impl EntryMetadata {
metadata_css!(".container", "border-color", self.container_border_color->output);
metadata_css!(".container", "border-style", self.container_border_style->output);
metadata_css!(".container", "border-width", self.container_border_width->output);
metadata_css!(".container", "border-width", self.container_mobile_border_width->output, "max-width: 900px");
metadata_css!(".container", "border-radius", self.container_border_radius->output);
metadata_css!(".container", "border-radius", self.container_mobile_border_radius->output, "max-width: 900px");
metadata_css!(".container", "box-shadow", self.container_shadow->output);
metadata_css!(".container", "text-shadow", self.content_text_shadow->output);
metadata_css!("*, html *", "--color-link" !important, self.content_link_color->output);
@ -641,7 +681,7 @@ impl EntryMetadata {
}
if self.content_disable_paragraph_margin {
output.push_str(".container p { margin: 0; }");
output.push_str(".container p { margin: 0 !important; }");
}
output + "</style>"

View file

@ -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::<usize>().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()),
)
}
}