diff --git a/Cargo.lock b/Cargo.lock index fac46dd..9bde7fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,7 +84,7 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "attobin" -version = "0.1.1" +version = "0.2.0" dependencies = [ "axum", "axum-extra", @@ -94,10 +94,12 @@ dependencies = [ "pathbufd", "serde", "serde_json", + "serde_valid", "tera", "tetratto-core", "tetratto-shared", "tokio", + "toml 0.9.2", "tower-http", "tracing", "tracing-subscriber", @@ -555,6 +557,12 @@ dependencies = [ "dtoa", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "emojis" version = "0.7.0" @@ -1163,6 +1171,7 @@ checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" dependencies = [ "equivalent", "hashbrown", + "serde", ] [[package]] @@ -1192,6 +1201,15 @@ dependencies = [ "serde", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -1579,7 +1597,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1db05f56d34358a8b1066f67cbb203ee3e7ed2ba674a6263a1d5ec6db2204323" dependencies = [ "memchr", - "thiserror", + "thiserror 2.0.12", "ucd-trie", ] @@ -1776,6 +1794,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -2185,6 +2224,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.0.0" @@ -2206,6 +2254,52 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_valid" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b615bed66931a7a9809b273937adc8a402d038b1e509d027fcaf62f084d33d1" +dependencies = [ + "indexmap", + "itertools", + "num-traits", + "once_cell", + "paste", + "regex", + "serde", + "serde_json", + "serde_valid_derive", + "serde_valid_literal", + "thiserror 1.0.69", + "toml 0.8.23", + "unicode-segmentation", +] + +[[package]] +name = "serde_valid_derive" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa1a5a21ea5aab06d2e6a6b59837d450fb2be9695be97735a711edfbe79ea07" +dependencies = [ + "itertools", + "paste", + "proc-macro-error2", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "serde_valid_literal" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd07331596ea967dccf9a35bde71ecd757490e09827b938a5c6226c648e3a25e" +dependencies = [ + "paste", + "regex", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2344,6 +2438,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -2469,7 +2569,7 @@ dependencies = [ "serde_json", "tetratto-l10n", "tetratto-shared", - "toml", + "toml 0.9.2", "totp-rs", ] @@ -2481,7 +2581,7 @@ checksum = "d96f5e41633c757e3519efb47c9b85d00d14322c1961360e126d0ecc0ea79b86" dependencies = [ "pathbufd", "serde", - "toml", + "toml 0.9.2", ] [[package]] @@ -2502,13 +2602,33 @@ dependencies = [ "uuid", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.12", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -2688,6 +2808,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit", +] + [[package]] name = "toml" version = "0.9.2" @@ -2696,13 +2828,22 @@ checksum = "ed0aee96c12fa71097902e0bb061a5e1ebd766a6636bb605ba401c45c1650eac" dependencies = [ "indexmap", "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.0.0", + "toml_datetime 0.7.0", "toml_parser", "toml_writer", "winnow", ] +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + [[package]] name = "toml_datetime" version = "0.7.0" @@ -2712,6 +2853,20 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow", +] + [[package]] name = "toml_parser" version = "1.0.1" @@ -2721,6 +2876,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" version = "1.0.2" @@ -2881,7 +3042,7 @@ dependencies = [ "log", "rand 0.9.1", "sha1", - "thiserror", + "thiserror 2.0.12", "utf-8", ] @@ -2980,6 +3141,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unicode-width" version = "0.2.1" @@ -3448,6 +3615,9 @@ name = "winnow" version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" +dependencies = [ + "memchr", +] [[package]] name = "wit-bindgen-rt" diff --git a/Cargo.toml b/Cargo.toml index 7159ec2..2b70eb9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "attobin" -version = "0.1.1" +version = "0.2.0" edition = "2024" authors = ["trisuaso"] repository = "https://trisua.com/t/tetratto" @@ -28,3 +28,5 @@ nanoneo = "0.2.0" dotenv = "0.15.0" glob = "0.3.2" serde_json = "1.0.141" +toml = "0.9.2" +serde_valid = { version = "1.0.5", features = ["toml"] } diff --git a/app/public/app.js b/app/public/app.js index 6724b3f..ba63155 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -35,6 +35,18 @@ function media_theme_pref() { } } +globalThis.temporary_set_theme = (theme) => { + document.documentElement.className = theme.toLowerCase(); + + if (theme === "Light") { + document.getElementById("switch_light").classList.remove("hidden"); + document.getElementById("switch_dark").classList.add("hidden"); + } else { + document.getElementById("switch_light").classList.add("hidden"); + document.getElementById("switch_dark").classList.remove("hidden"); + } +}; + globalThis.set_theme = (theme) => { window.localStorage.setItem("attobin:theme", theme); document.documentElement.className = theme; @@ -67,14 +79,26 @@ function check_message() { } } +globalThis.show_message = (message, message_good = true) => { + const element = document.getElementById("messages"); + element.style.marginBottom = "1rem"; + element.style.paddingLeft = "1rem"; + element.innerHTML += `
  • ${message.replaceAll('"', "")}
  • `; +}; + check_message(); // editor -globalThis.init_editor = () => { - globalThis.editor = CodeMirror(document.getElementById("editor_tab"), { - value: (document.getElementById("editor_content") || { innerHTML: "" }) +globalThis.init_editor = ( + name = "editor", + mode = "markdown", + element = "editor_tab", + content_element = "editor_content", +) => { + globalThis[name] = CodeMirror(document.getElementById(element), { + value: (document.getElementById(content_element) || { innerHTML: "" }) .innerHTML, - mode: "markdown", + mode, lineWrapping: true, lineNumbers: false, autoCloseBrackets: true, @@ -99,20 +123,24 @@ globalThis.init_editor = () => { }, }); - window.addEventListener("beforeunload", (e) => { - if (!globalThis.ALLOW_LEAVE) { - e.preventDefault(); - return null; - } - }); + if (name === "editor") { + window.addEventListener("beforeunload", (e) => { + if (!globalThis.ALLOW_LEAVE) { + e.preventDefault(); + return null; + } + }); + } }; globalThis.tab_editor = () => { document.getElementById("editor_tab").classList.remove("hidden"); document.getElementById("preview_tab").classList.add("hidden"); + document.getElementById("metadata_tab").classList.add("hidden"); document.getElementById("editor_tab_button").classList.remove("camo"); document.getElementById("preview_tab_button").classList.add("camo"); + document.getElementById("metadata_tab_button").classList.add("camo"); }; globalThis.tab_preview = async () => { @@ -133,9 +161,28 @@ globalThis.tab_preview = async () => { // ... document.getElementById("editor_tab").classList.add("hidden"); document.getElementById("preview_tab").classList.remove("hidden"); + document.getElementById("metadata_tab").classList.add("hidden"); document.getElementById("editor_tab_button").classList.add("camo"); document.getElementById("preview_tab_button").classList.remove("camo"); + document.getElementById("metadata_tab_button").classList.add("camo"); +}; + +globalThis.first_time_on_metadata_tab = true; +globalThis.tab_metadata = () => { + document.getElementById("editor_tab").classList.add("hidden"); + document.getElementById("preview_tab").classList.add("hidden"); + document.getElementById("metadata_tab").classList.remove("hidden"); + + document.getElementById("editor_tab_button").classList.add("camo"); + document.getElementById("preview_tab_button").classList.add("camo"); + document.getElementById("metadata_tab_button").classList.remove("camo"); + + if (globalThis.first_time_on_metadata_tab) { + globalThis.metadata_editor.refresh(); + } + + globalThis.first_time_on_metadata_tab = false; }; let exists_timeout = null; diff --git a/app/public/style.css b/app/public/style.css index ffe2463..d8b6360 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -178,6 +178,10 @@ video { appearance: none; } +.button.small { + --h: 28px; +} + .button:hover { background: var(--color-super-raised); } diff --git a/app/templates_src/edit.lisp b/app/templates_src/edit.lisp index 3b28730..9de0787 100644 --- a/app/templates_src/edit.lisp +++ b/app/templates_src/edit.lisp @@ -1,6 +1,7 @@ (text "{% extends \"root.lisp\" %} {% block head %}") (title (text "{{ entry.slug }}")) +(link ("rel" "icon") ("href" "/public/favicon.svg")) (text "{% endblock %} {% block body %}") (div ("class" "flex items-center bar") @@ -13,7 +14,12 @@ ("class" "button camo tab_button") ("id" "preview_tab_button") ("onclick" "tab_preview()") - (text "Preview"))) + (text "Preview")) + (button + ("class" "button camo tab_button") + ("id" "metadata_tab_button") + ("onclick" "tab_metadata()") + (text "Metadata"))) (div ("class" "card tab tabs") (div @@ -21,6 +27,9 @@ ("class" "tab fadein")) (div ("id" "preview_tab") + ("class" "tab fadein hidden container")) + (div + ("id" "metadata_tab") ("class" "tab fadein hidden"))) (form ("class" "w-full flex flex-col gap-2") @@ -75,10 +84,12 @@ (link ("rel" "stylesheet") ("href" "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/default.min.css")) (script ("id" "editor_content") ("type" "text/markdown") (text "{{ entry.content|remove_script_tags|safe }}")) +(script ("id" "editor_metadata_content") ("type" "text/markdown") (text "{{ entry.metadata|remove_script_tags|safe }}")) (script (text "setTimeout(() => { globalThis.init_editor(); + globalThis.init_editor(\"metadata_editor\", \"plain\", \"metadata_tab\", \"editor_metadata_content\"); }, 150); globalThis.edit_entry = (e) => { @@ -99,6 +110,7 @@ edit_code: e.target.edit_code.value, new_slug: e.target.new_slug.value || undefined, new_edit_code: e.target.new_edit_code.value || undefined, + metadata: globalThis.metadata_editor.getValue(), \"delete\": rm, }), }) @@ -117,8 +129,7 @@ window.location.href = \"/\"; } } else { - document.cookie = `Atto-Message=\"${res.message}\"; path=/`; - check_message(); + show_message(res.message, false); } }) }")) diff --git a/app/templates_src/error.lisp b/app/templates_src/error.lisp index 1a02116..4cca2cb 100644 --- a/app/templates_src/error.lisp +++ b/app/templates_src/error.lisp @@ -1,6 +1,7 @@ (text "{% extends \"root.lisp\" %} {% block head %}") (title (text "Error - {{ name }}")) +(link ("rel" "icon") ("href" "/public/favicon.svg")) (text "{% endblock %} {% block body %}") (div ("class" "card") diff --git a/app/templates_src/index.lisp b/app/templates_src/index.lisp index 3256d50..f5ee66f 100644 --- a/app/templates_src/index.lisp +++ b/app/templates_src/index.lisp @@ -1,6 +1,7 @@ (text "{% extends \"root.lisp\" %} {% block head %}") (title (text "{{ name }}")) +(link ("rel" "icon") ("href" "/public/favicon.svg")) (text "{% endblock %} {% block body %}") (div ("class" "flex items-center bar") @@ -13,7 +14,12 @@ ("class" "button camo tab_button") ("id" "preview_tab_button") ("onclick" "tab_preview()") - (text "Preview"))) + (text "Preview")) + (button + ("class" "button camo tab_button") + ("id" "metadata_tab_button") + ("onclick" "tab_metadata()") + (text "Metadata"))) (div ("class" "card tab tabs") (div @@ -21,6 +27,9 @@ ("class" "tab fadein")) (div ("id" "preview_tab") + ("class" "tab fadein hidden container")) + (div + ("id" "metadata_tab") ("class" "tab fadein hidden"))) (form ("class" "w-full flex justify-between gap-2 flex-collapse-rev") @@ -58,6 +67,7 @@ (script (text "setTimeout(() => { globalThis.init_editor(); + globalThis.init_editor(\"metadata_editor\", \"plain\", \"metadata_tab\"); }, 150); globalThis.create_entry = (e) => { @@ -71,6 +81,7 @@ content: globalThis.editor.getValue(), slug: e.target.slug.value || undefined, edit_code: e.target.edit_code.value || undefined, + metadata: globalThis.metadata_editor.getValue(), }), }) .then(res => res.json()) @@ -81,8 +92,7 @@ document.cookie = \"Atto-Message-Good=true; path=/\"; window.location.href = `/${res.payload[0]}`; } else { - document.cookie = `Atto-Message=\"${res.message}\"; path=/`; - check_message(); + show_message(res.message, false); } }) }")) diff --git a/app/templates_src/root.lisp b/app/templates_src/root.lisp index 8c59b81..fc016b7 100644 --- a/app/templates_src/root.lisp +++ b/app/templates_src/root.lisp @@ -7,12 +7,10 @@ (meta ("name" "viewport") ("content" "width=device-width, initial-scale=1.0")) (meta ("http-equiv" "X-UA-Compatible") ("content" "ie=edge")) - (link ("rel" "icon") ("href" "/public/favicon.svg")) (link ("rel" "stylesheet") ("href" "{{ tetratto }}/css/utility.css?v={{ build_code }}")) (link ("rel" "stylesheet") ("href" "/public/style.css?v={{ build_code }}")) (meta ("name" "theme-color") ("content" "#fbc27f")) - (meta ("name" "description") ("content" "{{ name }}")) (meta ("property" "og:type") ("content" "website")) (meta ("property" "og:site_name") ("content" "{{ name }}")) diff --git a/app/templates_src/view.lisp b/app/templates_src/view.lisp index c4b1e5f..b6cb949 100644 --- a/app/templates_src/view.lisp +++ b/app/templates_src/view.lisp @@ -1,11 +1,14 @@ (text "{% extends \"root.lisp\" %} {% block head %}") +(text "{% if not metadata.page_title -%}") (title (text "{{ entry.slug }}")) +(text "{%- endif %} {{ metadata_head|safe }}") +(link ("rel" "icon") ("href" "/public/favicon.svg")) (text "{% endblock %} {% block body %}") (div ("class" "flex flex-col gap-2") (div - ("class" "card") + ("class" "card container") ("style" "min-height: 15rem") (text "{{ entry.content|markdown|safe }}")) (div @@ -17,9 +20,27 @@ (div ("class" "flex flex-col gap-1 items-end fade") + ; dates (span (text "Pub: {{ entry.created / 1000|int|date(format=\"%Y-%m-%d %H:%M\", timezone=\"Etc/UTC\") }} UTC")) (span (text "Edit: {{ entry.edited / 1000|int|date(format=\"%Y-%m-%d %H:%M\", timezone=\"Etc/UTC\") }} UTC")) - (span (text "Views: {{ views }}"))))) + + ; auto theme + (text "{% if metadata.access_recommended_theme != 'None' -%}") + (span (text "Auto theme: {{ metadata.access_recommended_theme }}")) + (script ("defer" "true") (text "setTimeout(() => { temporary_set_theme('{{ metadata.access_recommended_theme }}') }, 150);")) + (text "{%- endif %}") + + ; views + (text "{% if not metadata.option_disable_views -%}") + (span (text "Views: {{ views }}")) + (text "{%- endif %}") + + ; easy-to-read + (text "{% if metadata.access_easy_read|length > 0 -%}") + (a ("class" "button small") ("href" "/{{ metadata.access_easy_read }}") (b (text "E2R"))) + (text "{%- endif %}")))) + +(text "{{ metadata_css|safe }}") (link ("rel" "stylesheet") ("href" "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/default.min.css")) (script ("src" "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js")) diff --git a/src/model.rs b/src/model.rs index 04695ed..2318e6d 100644 --- a/src/model.rs +++ b/src/model.rs @@ -1,4 +1,6 @@ use serde::{Deserialize, Serialize}; +use serde_valid::Validate; +use std::fmt::Display; #[derive(Serialize, Deserialize)] pub struct Entry { @@ -8,4 +10,266 @@ pub struct Entry { pub created: usize, pub edited: usize, pub content: String, + pub metadata: String, +} + +#[derive(Serialize, Deserialize, PartialEq, Eq)] +pub enum RecommendedTheme { + #[serde(alias = "none")] + None, + #[serde(alias = "light")] + Light, + #[serde(alias = "dark")] + Dark, +} + +impl Default for RecommendedTheme { + fn default() -> Self { + Self::None + } +} + +#[derive(Serialize, Deserialize, PartialEq, Eq)] +pub enum TextAlignment { + #[serde(alias = "left")] + Left, + #[serde(alias = "center")] + Center, + #[serde(alias = "right")] + Right, + #[serde(alias = "justify")] + Justify, +} + +impl Default for TextAlignment { + fn default() -> Self { + Self::Left + } +} + +impl Display for TextAlignment { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Left => "left", + Self::Center => "center", + Self::Right => "right", + Self::Justify => "justify", + }) + } +} + +#[derive(Serialize, Deserialize, Validate, Default)] +pub struct EntryMetadata { + /// The title of the page. + #[serde(default, alias = "PAGE_TITLE")] + #[validate(max_length = 128)] + pub page_title: String, + /// The description of the page. + #[serde(default, alias = "PAGE_DESCRIPTION")] + #[validate(max_length = 256)] + pub page_description: String, + /// The favicon of the page. + #[serde(default, alias = "PAGE_icon")] + #[validate(max_length = 128)] + pub page_icon: String, + /// The title of the page shown in external embeds. + #[serde(default, alias = "SHARE_TITLE")] + #[validate(max_length = 128)] + pub share_title: String, + /// The description of the page shown in external embeds. + #[serde(default, alias = "SHARE_DESCRIPTION")] + #[validate(max_length = 256)] + pub share_description: String, + /// The image shown in external embeds. + #[serde(default, alias = "SHARE_IMAGE")] + #[validate(max_length = 128)] + pub share_image: String, + /// If views are counted and shown for this entry. + #[serde(default, alias = "OPTION_DISABLE_VIEWS")] + pub option_disable_views: bool, + /// If this entry shows up in search engines. + #[serde(default, alias = "OPTION_DISABLE_SEARCH_ENGINE")] + pub option_disable_search_engine: bool, + /// The theme that is automatically used when this entry is viewed. + #[serde(default, alias = "ACCESS_RECOMMENDED_THEME")] + pub access_recommended_theme: RecommendedTheme, + /// The slug of the easy-to-read (no metadata) version of this entry. + /// + /// Should not begin with "/". + #[serde(default, alias = "ACCESS_EASY_READ")] + #[validate(max_length = 32)] + pub access_easy_read: String, + /// The color of the text in the inner container. + #[serde(default, alias = "CONTAINER_INNER_FOREGROUND")] + #[validate(max_length = 32)] + pub container_inner_foreground: String, + /// The background of the inner container. + /// + /// Syntax: + #[serde(default, alias = "CONTAINER_INNER_BACKGROUND")] + #[validate(max_length = 256)] + pub container_inner_background: String, + /// The color of the text in the outer container. + #[serde(default, alias = "CONTAINER_OUTER_FOREGROUND")] + #[validate(max_length = 32)] + pub container_outer_foreground: String, + /// The background of the outer container. + /// + /// Syntax: + #[serde(default, alias = "CONTAINER_OUTER_BACKGROUND")] + #[validate(max_length = 256)] + pub container_outer_background: String, + /// The border around the container. + /// + /// Syntax: + #[serde(default, alias = "CONTAINER_BORDER")] + #[validate(max_length = 256)] + pub container_border: String, + /// The shadow around the container. + /// + /// Syntax: + #[serde(default, alias = "CONTAINER_SHADOW")] + #[validate(max_length = 32)] + pub container_shadow: String, + /// The name of a font from Google Fonts to use. + #[serde(default, alias = "CONTENT_FONT")] + #[validate(max_length = 32)] + pub content_font: String, + /// The weight to use for the body text. + #[serde(default, alias = "CONTENT_FONT_WEIGHT")] + pub content_font_weight: u32, + /// The text size of elements by element tag. + /// + /// # Example + /// ```toml + /// # ... + /// content_text_size = [["h1", "16px"]] + /// ``` + #[serde(default, alias = "CONTENT_TEXT_SIZE")] + pub content_text_size: Vec<(String, String)>, + /// The default text alignment. + #[serde(default, alias = "CONTENT_TEXT_ALIGN")] + pub content_text_align: TextAlignment, + /// The color of links. + #[serde(default, alias = "CONTENT_TEXT_LINK_COLOR")] + pub content_text_link_color: String, +} + +macro_rules! metadata_css { + ($selector:literal, $property:literal, $self:ident.$field:ident->$output:ident) => { + if !$self.$field.is_empty() { + $output.push_str(&format!( + "{} {{ {}: {}; }}\n", + $selector, + $property, + EntryMetadata::css_escape(&$self.$field) + )); + } + }; +} + +impl EntryMetadata { + pub fn head_tags(&self) -> String { + let mut output = String::new(); + + if !self.page_title.is_empty() { + output.push_str(&format!("{}", self.page_title)); + } + + if !self.page_description.is_empty() { + output.push_str(&format!( + "", + self.page_description.replace("\"", "\\\"") + )); + } + + if !self.page_icon.is_empty() { + output.push_str(&format!( + "", + self.page_icon.replace("\"", "\\\"") + )); + } + + if !self.share_title.is_empty() { + output.push_str(&format!( + "", + self.share_title.replace("\"", "\\\""), + self.share_title.replace("\"", "\\\"") + )); + } + + if !self.share_description.is_empty() { + output.push_str(&format!( + "", + self.share_description.replace("\"", "\\\""), + self.share_description.replace("\"", "\\\"") + )); + } + + if !self.share_image.is_empty() { + output.push_str(&format!( + "", + self.share_image.replace("\"", "\\\""), + self.share_image.replace("\"", "\\\"") + )); + } + + if !self.content_font.is_empty() { + output.push_str(&format!( + " + + ", + self.content_font.replace(" ", "+"), + )); + } + + output + } + + pub fn css_escape(input: &str) -> String { + input.replace("}", "").replace(";", "").replace("/*", "") + } + + pub fn css(&self) -> String { + let mut output = "" + } } diff --git a/src/routes.rs b/src/routes.rs index 9b001de..18f674f 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,4 +1,7 @@ -use crate::{State, model::Entry}; +use crate::{ + State, + model::{Entry, EntryMetadata}, +}; use axum::{ Extension, Json, Router, extract::Path, @@ -6,6 +9,7 @@ use axum::{ routing::{get, get_service, post}, }; use serde::Deserialize; +use serde_valid::Validate; use tera::Context; use tetratto_core::{ model::{ @@ -99,38 +103,66 @@ async fn view_request( } }; - let (views_id, views) = match data - .query(&SimplifiedQuery { - query: AppDataSelectQuery::KeyIs(format!("entries.views('{}')", slug)), - mode: AppDataSelectMode::One(0), - }) - .await - { - Ok(r) => match r { - AppDataQueryResult::One(r) => (r.id, r.value.parse::().unwrap()), - AppDataQueryResult::Many(_) => unreachable!(), - }, + // check metadata + let metadata: EntryMetadata = match toml::from_str(&entry.metadata) { + Ok(x) => x, Err(e) => { let mut ctx = default_context(&data, &build_code); ctx.insert("error", &e.to_string()); - return Html(tera.render("error.lisp", &ctx).unwrap()); } }; - // count view - if let Err(e) = data.update(views_id, (views + 1).to_string()).await { + 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()); } + // pull views + let views = if !metadata.option_disable_views { + match data + .query(&SimplifiedQuery { + query: AppDataSelectQuery::KeyIs(format!("entries.views('{}')", slug)), + mode: AppDataSelectMode::One(0), + }) + .await + { + Ok(r) => match r { + AppDataQueryResult::One(r) => { + // 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()); + + return Html(tera.render("error.lisp", &ctx).unwrap()); + } + + views + } + AppDataQueryResult::Many(_) => unreachable!(), + }, + Err(e) => { + let mut ctx = default_context(&data, &build_code); + ctx.insert("error", &e.to_string()); + + return Html(tera.render("error.lisp", &ctx).unwrap()); + } + } + } else { + 0 + }; + // ... let mut ctx = default_context(&data, &build_code); ctx.insert("entry", &entry); ctx.insert("views", &views); + ctx.insert("metadata", &metadata); + ctx.insert("metadata_head", &metadata.head_tags()); + ctx.insert("metadata_css", &metadata.css()); Html(tera.render("view.lisp", &ctx).unwrap()) } @@ -201,6 +233,8 @@ async fn exists_request( #[derive(Deserialize)] struct CreateEntry { content: String, + #[serde(default)] + metadata: String, #[serde(default = "default_random")] slug: String, #[serde(default = "default_random")] @@ -234,6 +268,16 @@ async fn create_request( return Json(Error::DataTooLong("content".to_string()).into()); } + // check metadata + let metadata: EntryMetadata = match toml::from_str(&req.metadata) { + Ok(x) => x, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }; + + if let Err(e) = metadata.validate() { + return Json(Error::MiscError(e.to_string()).into()); + } + // check for existing if data .query(&SimplifiedQuery { @@ -260,6 +304,7 @@ async fn create_request( created, edited: created, content: req.content, + metadata: req.metadata, }) .unwrap(), ) @@ -292,6 +337,8 @@ struct EditEntry { #[serde(default)] new_edit_code: Option, #[serde(default)] + metadata: String, + #[serde(default)] delete: bool, } @@ -311,6 +358,16 @@ async fn edit_request( return Json(Error::DataTooLong("content".to_string()).into()); } + // check metadata + let metadata: EntryMetadata = match toml::from_str(&req.metadata) { + Ok(x) => x, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }; + + if let Err(e) = metadata.validate() { + return Json(Error::MiscError(e.to_string()).into()); + } + // ... let (id, mut entry) = match data .query(&SimplifiedQuery { @@ -418,6 +475,7 @@ async fn edit_request( // update entry.content = req.content; + entry.metadata = req.metadata; entry.edited = unix_epoch_timestamp(); if let Err(e) = data