diff --git a/Cargo.lock b/Cargo.lock
index 079561e..8488b22 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2551,9 +2551,9 @@ dependencies = [
[[package]]
name = "tetratto-core"
-version = "12.0.1"
+version = "12.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2c1b01499f7471ee6f05299c06ebb18760440452714c6f9a6c0c9e0cf9a663bd"
+checksum = "a367ac3ced8ff302080e1b4a82a67acd24fa606245c4381a6f77dbaaf6ef4b58"
dependencies = [
"async-recursion",
"base16ct",
@@ -2570,6 +2570,7 @@ dependencies = [
"serde_json",
"tetratto-l10n",
"tetratto-shared",
+ "tokio",
"toml 0.9.2",
"totp-rs",
]
@@ -2587,9 +2588,9 @@ dependencies = [
[[package]]
name = "tetratto-shared"
-version = "12.0.5"
+version = "12.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "11c2ba2be9c92a4ac566b9c3b615d7311b3d0e98b175ad84e81b44644b34dd8f"
+checksum = "286290ad09be3c507f9a47d38e92b024e6fcde34dbb515113f5bdb6b926cbee3"
dependencies = [
"ammonia",
"chrono",
diff --git a/Cargo.toml b/Cargo.toml
index de66da2..75e7458 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,8 +8,8 @@ license = "AGPL-3.0-or-later"
homepage = "https://tetratto.com"
[dependencies]
-tetratto-core = "12.0.1"
-tetratto-shared = "12.0.5"
+tetratto-core = "12.0.2"
+tetratto-shared = "12.0.6"
tokio = { version = "1.46.1", features = ["macros", "rt-multi-thread"] }
pathbufd = "0.1.4"
serde = { version = "1.0.219", features = ["derive"] }
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..01c9893
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1,3 @@
+public/docs/**/*
+!public/docs/metadata.md
+icons/
diff --git a/app/docs/metadata.md b/app/docs/metadata.md
new file mode 100644
index 0000000..18e8f46
--- /dev/null
+++ b/app/docs/metadata.md
@@ -0,0 +1,11 @@
+# What is metadata?
+
+Each entry also includes a separate metadata content. Metadata is used to further customize the look and display of your entries to a finer degree than you can through just Markdown.
+
+Metadata includes options to customize how your entries look when shared to other platforms, as well as to customize how your entries render when viewed.
+
+All option names can either be in all lowercase, or all uppercase. While option values for `String` type metadata options _should_ be encased in double quotes, that will be done for you if you leave them out. This is included just for compatibility.
+
+Metadata options go in the "Metadata" tab in the entry editor page. Each option should be on a new line, and should be formatted as `NAME = value`. If you're familiar with TOML, you should be comfortable with metadata formatting.
+
+You can view a list of all options and what they do [here](http://localhost:9119/public/reference/attobin/model/struct.EntryMetadata.html#fields).
diff --git a/app/public/app.js b/app/public/app.js
index c5305b3..e6ccff9 100644
--- a/app/public/app.js
+++ b/app/public/app.js
@@ -224,3 +224,109 @@ globalThis.check_exists_input = (e) => {
e.target.reportValidity();
}, 1000);
};
+
+// components
+function close_dropdowns() {
+ for (const dropdown of Array.from(
+ document.querySelectorAll(".inner.open"),
+ )) {
+ dropdown.classList.remove("open");
+ }
+}
+
+globalThis.open_dropdown = (event) => {
+ event.stopImmediatePropagation();
+ let target = event.target;
+
+ while (!target.matches(".dropdown")) {
+ target = target.parentElement;
+ }
+
+ // close all others
+ close_dropdowns();
+
+ // open
+ setTimeout(() => {
+ for (const dropdown of Array.from(target.querySelectorAll(".inner"))) {
+ // check y
+ const box = target.getBoundingClientRect();
+
+ let parent = dropdown.parentElement;
+
+ while (!parent.matches("html, .window")) {
+ parent = parent.parentElement;
+ }
+
+ let parent_height = parent.getBoundingClientRect().y;
+
+ if (parent.nodeName === "HTML") {
+ parent_height = window.screen.height;
+ }
+
+ const scroll = window.scrollY;
+ const height = parent_height;
+ const y = box.y + scroll;
+
+ if (y > height - scroll - 375) {
+ dropdown.classList.add("top");
+ } else {
+ dropdown.classList.remove("top");
+ }
+
+ // open
+ dropdown.classList.add("open");
+
+ if (dropdown.classList.contains("open")) {
+ dropdown.removeAttribute("aria-hidden");
+ } else {
+ dropdown.setAttribute("aria-hidden", "true");
+ }
+ }
+ }, 5);
+};
+
+globalThis.init_dropdowns = (bind_to) => {
+ for (const dropdown of Array.from(document.querySelectorAll(".inner"))) {
+ dropdown.setAttribute("aria-hidden", "true");
+ }
+
+ bind_to.addEventListener("click", (event) => {
+ if (
+ event.target.matches(".dropdown") ||
+ event.target.matches("[exclude=dropdown]")
+ ) {
+ return;
+ }
+
+ for (const dropdown of Array.from(
+ document.querySelectorAll(".inner.open"),
+ )) {
+ dropdown.classList.remove("open");
+ }
+ });
+};
+
+globalThis.METADATA_CSS_ENABLED = true;
+globalThis.toggle_metadata_css = (e) => {
+ e.target.classList.add("yellow");
+
+ METADATA_CSS_ENABLED = !METADATA_CSS_ENABLED;
+ if (!METADATA_CSS_ENABLED) {
+ media_theme_pref(); // user user theme
+ document.getElementById("metadata_css").remove(); // remove css
+
+ // reset colored text
+ for (const element of Array.from(
+ document.querySelectorAll(".color_block"),
+ )) {
+ element.removeAttribute("style");
+ element.classList.remove("color_block");
+ }
+
+ // strikethrough auto theme since it's disabled
+ document.getElementById("auto_theme").style.textDecoration =
+ "line-through";
+ } else {
+ window.location.reload();
+ }
+};
diff --git a/app/public/reference b/app/public/reference
new file mode 120000
index 0000000..bb0525b
--- /dev/null
+++ b/app/public/reference
@@ -0,0 +1 @@
+../../target/doc
\ No newline at end of file
diff --git a/app/public/style.css b/app/public/style.css
index 4afe582..9b60cf3 100644
--- a/app/public/style.css
+++ b/app/public/style.css
@@ -12,7 +12,7 @@
--color-shadow: rgba(0, 0, 0, 0.08);
--color-red: hsl(0, 84%, 40%);
--color-green: hsl(100, 84%, 20%);
- --color-yellow: hsl(41, 63%, 75%);
+ --color-yellow: oklch(47% 0.157 37.304);
--color-purple: hsl(284, 84%, 20%);
--color-primary: oklch(67.3% 0.182 276.935);
@@ -24,6 +24,9 @@
--pad-2: 0.35rem;
--pad-3: 0.5rem;
--pad-4: 1rem;
+
+ --radius: 0.2rem;
+ --nav-height: 36px;
}
* {
@@ -45,7 +48,7 @@
--color-link: #93c5fd;
--color-red: hsl(0, 94%, 82%);
--color-green: hsl(100, 94%, 82%);
- --color-yellow: hsl(41, 63%, 65%);
+ --color-yellow: oklch(90.1% 0.076 70.697);
--color-purple: hsl(284, 94%, 82%);
}
@@ -73,7 +76,7 @@ main {
article {
margin: var(--pad-2) 0;
- height: calc(100dvh - var(--pad-4) * 2);
+ height: calc(100dvh - var(--pad-4) - var(--nav-height) * 2);
}
.tab {
@@ -89,6 +92,14 @@ article {
animation: fadein ease-in-out 1 0.5s forwards running;
}
+nav {
+ /* background: var(--color-raised); */
+ height: var(--nav-height);
+ /* position: sticky;
+ z-index: 2;
+ top: 2; */
+}
+
@media screen and (max-width: 900px) {
main,
article,
@@ -117,7 +128,7 @@ article {
}
.content_container {
- margin: var(--pad-2) auto;
+ margin: 0 auto var(--pad-2);
width: 100%;
}
@@ -167,7 +178,7 @@ video {
display: flex;
justify-content: center;
align-items: center;
- gap: var(--gap-2);
+ gap: var(--pad-2);
padding: var(--pad-2) calc(var(--pad-3) * 1.5);
cursor: pointer;
background: var(--color-raised);
@@ -187,7 +198,7 @@ video {
--h: 28px;
}
-.button:hover {
+.button:not(:has(.button:hover)):not(.camo):hover {
background: var(--color-super-raised);
}
@@ -196,10 +207,70 @@ video {
color: inherit;
}
-.bar .button.camo:hover {
+.bar .button:not(.simple).camo:hover {
color: var(--color-link);
}
+.button.simple {
+ --size: 18px;
+ font-weight: 600;
+ padding: var(--pad-2) !important;
+ border-radius: var(--radius);
+ width: var(--size);
+ height: var(--size);
+ aspect-ratio: 1 / 1;
+ font-size: 12px;
+}
+
+.button.surface {
+ background: var(--color-surface);
+}
+
+.button.surface.simple:is(.camo *) {
+ background: var(--color-super-raised);
+}
+
+/* dropdown */
+.dropdown {
+ position: relative;
+}
+
+.dropdown .inner {
+ display: none;
+ flex-direction: column;
+ box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size)
+ var(--color-shadow);
+ background: var(--color-raised);
+ color: inherit;
+ position: absolute;
+ z-index: 2;
+ top: 100%;
+}
+
+.dropdown .inner.open {
+ display: flex;
+}
+
+.dropdown .inner .button {
+ padding: var(--pad-3) var(--pad-4);
+ justify-content: flex-start;
+ width: 100%;
+}
+
+.dropdown:has(.inner.open) .button:nth-child(1):not(.inner *) {
+ background: var(--color-raised);
+}
+
+.dropdown .inner.top {
+ top: unset;
+ bottom: 100%;
+}
+
+.dropdown .inner.left {
+ left: 0;
+ right: unset;
+}
+
/* input */
input {
--h: 36px;
@@ -276,6 +347,7 @@ pre {
padding: var(--pad-2) var(--pad-4);
border-left: solid 5px var(--color-primary);
background: var(--color-surface);
+ border-radius: var(--radius);
}
code {
@@ -297,8 +369,22 @@ code * {
font-size: 0.8rem !important;
}
+code:not(pre *) {
+ padding: var(--pad-1) var(--pad-2);
+ background: oklch(98% 0.016 73.684 / 25%);
+ color: oklch(90.1% 0.076 70.697);
+ border-radius: var(--radius);
+ white-space: break-spaces;
+}
+
+code:not(pre *):not(.dark *) {
+ background: oklch(83.7% 0.128 66.29 / 25%);
+ color: oklch(47% 0.157 37.304);
+}
+
svg.icon {
stroke: currentColor;
+ fill: currentColor;
width: 18px;
height: 1em;
}
@@ -307,6 +393,10 @@ svg.icon.filled {
fill: currentColor;
}
+.no_fill svg.icon {
+ fill: transparent;
+}
+
button svg {
pointer-events: none;
}
@@ -384,6 +474,10 @@ a {
color: var(--color-link);
}
+.color_block a {
+ color: inherit;
+}
+
a.flush {
color: inherit;
}
@@ -477,12 +571,12 @@ blockquote {
.cm-comment,
.hljs-keyword {
- color: rgb(153 27 27) !important;
+ color: oklch(47% 0.157 37.304) !important;
}
.cm-comment:is(.dark *),
.hljs-keyword:is(.dark *) {
- color: rgb(254, 202, 202) !important;
+ color: oklch(90.1% 0.076 70.697) !important;
}
.cm-link {
diff --git a/app/templates_src/components.lisp b/app/templates_src/components.lisp
deleted file mode 100644
index 75b6938..0000000
--- a/app/templates_src/components.lisp
+++ /dev/null
@@ -1,40 +0,0 @@
-(text "{% macro footer() -%}")
-(footer
- ("class" "flex flex-col items-center gap-2")
- (hr ("style" "min-width: 20rem; margin-top: calc(var(--pad-4) * 4)"))
- (div
- ("class" "w-full flex justify-between")
- (div ("style" "width: 50px"))
- (div
- ("class" "flex flex-col gap-2 items-center")
- (div
- ("class" "flex gap-2 flex-wrap")
- (a
- ("href" "/")
- (text "new"))
-
- (a
- ("href" "/{{ what_page_slug }}")
- (text "what"))
-
- (a
- ("href" "https://trisua.com/t/attobin")
- (text "source")))
-
- (span ("style" "font-size: 14px") ("class" "fade") (text "{{ name }}")))
-
- ; theme switches
- (button
- ("class" "button camo fade")
- ("id" "switch_light")
- ("title" "Switch theme")
- ("onclick" "set_theme('Dark')")
- (text ""))
-
- (button
- ("class" "button camo fade hidden")
- ("id" "switch_dark")
- ("title" "Switch theme")
- ("onclick" "set_theme('Light')")
- (text ""))))
-(text "{%- endmacro %}")
diff --git a/app/templates_src/doc.lisp b/app/templates_src/doc.lisp
new file mode 100644
index 0000000..7c1f541
--- /dev/null
+++ b/app/templates_src/doc.lisp
@@ -0,0 +1,9 @@
+(text "{% extends \"root.lisp\" %} {% block head %}")
+(title
+ (text "{{ file_name }} - {{ name }}"))
+(link ("rel" "icon") ("href" "/public/favicon.svg"))
+(text "{% endblock %} {% block body %}")
+(div
+ ("class" "card container")
+ (p (text "{{ text|markdown|safe }}")))
+(text "{% endblock %}")
diff --git a/app/templates_src/edit.lisp b/app/templates_src/edit.lisp
index 1840dcd..fc6ea85 100644
--- a/app/templates_src/edit.lisp
+++ b/app/templates_src/edit.lisp
@@ -19,7 +19,13 @@
("class" "button camo tab_button")
("id" "metadata_tab_button")
("onclick" "tab_metadata()")
- (text "Metadata")))
+ (text "Metadata")
+ (a
+ ("class" "button simple surface")
+ ("href" "/docs/metadata")
+ ("target" "_blank")
+ ("title" "Info")
+ (text "i"))))
(div
("class" "card tab tabs container")
("id" "tabs_group")
@@ -74,7 +80,6 @@
("class" "button red")
("ui_ident" "delete")
(text "Delete"))))
-(text "{{ components::footer() }}")
; editor
(script ("src" "https://unpkg.com/codemirror@5.39.2/lib/codemirror.js"))
diff --git a/app/templates_src/error.lisp b/app/templates_src/error.lisp
index 4cca2cb..23b614a 100644
--- a/app/templates_src/error.lisp
+++ b/app/templates_src/error.lisp
@@ -6,5 +6,4 @@
(div
("class" "card")
(p (text "{{ error }}")))
-(text "{{ components::footer() }}")
(text "{% endblock %}")
diff --git a/app/templates_src/index.lisp b/app/templates_src/index.lisp
index f928864..f5cf36c 100644
--- a/app/templates_src/index.lisp
+++ b/app/templates_src/index.lisp
@@ -1,6 +1,9 @@
(text "{% extends \"root.lisp\" %} {% block head %}")
(title
(text "{{ name }}"))
+
+(meta ("property" "og:title") ("content" "{{ name }}"))
+(meta ("property" "twitter:title") ("content" "{{ name }}"))
(link ("rel" "icon") ("href" "/public/favicon.svg"))
(text "{% endblock %} {% block body %}")
(div
@@ -19,7 +22,13 @@
("class" "button camo tab_button")
("id" "metadata_tab_button")
("onclick" "tab_metadata()")
- (text "Metadata")))
+ (text "Metadata")
+ (a
+ ("class" "button simple surface")
+ ("href" "/docs/metadata")
+ ("target" "_blank")
+ ("title" "Info")
+ (text "i"))))
(div
("class" "card tab tabs container")
("id" "tabs_group")
@@ -55,7 +64,6 @@
("name" "slug")
("oninput" "check_exists_input(event)")
("placeholder" "Custom url"))))
-(text "{{ components::footer() }}")
; editor
(script ("src" "https://unpkg.com/codemirror@5.39.2/lib/codemirror.js"))
diff --git a/app/templates_src/root.lisp b/app/templates_src/root.lisp
index fc016b7..0b993b0 100644
--- a/app/templates_src/root.lisp
+++ b/app/templates_src/root.lisp
@@ -1,4 +1,3 @@
-(text "{%- import \"components.lisp\" as components -%}")
(text "")
(html
("lang" "en")
@@ -19,8 +18,64 @@
(text "{% block head %}{% endblock %}"))
(body
+ ; nav
+ (nav
+ ("class" "flex w-full justify-between gap-2")
+ (div
+ ("class" "flex side")
+ (div
+ ("class" "dropdown")
+ (button
+ ("onclick" "open_dropdown(event)")
+ ("exclude" "dropdown")
+ ("class" "button camo fade")
+ (text "{{ icon \"menu\" }}"))
+ (div
+ ("class" "inner")
+ (a
+ ("class" "button")
+ ("href" "/")
+ (text "new"))
+ (a
+ ("class" "button")
+ ("href" "/{{ what_page_slug }}")
+ (text "what"))
+ (a
+ ("class" "button")
+ ("href" "https://trisua.com/t/attobin")
+ (text "source"))))
+
+ (a
+ ("class" "button camo fade")
+ ("href" "/")
+ ("title" "new")
+ (text "{{ icon \"plus\" }}")))
+
+ (div
+ ("class" "side flex")
+ (text "{% block nav_extras %}{% endblock %}")
+
+ ; theme switches
+ (button
+ ("class" "button camo fade")
+ ("id" "switch_light")
+ ("title" "Switch theme")
+ ("onclick" "set_theme('Dark')")
+ (text "{{ icon \"sun\" }}"))
+
+ (button
+ ("class" "button camo fade hidden")
+ ("id" "switch_dark")
+ ("title" "Switch theme")
+ ("onclick" "set_theme('Light')")
+ (text "{{ icon \"moon\" }}"))))
+
+ ; page
(article
("class" "content_container flex flex-col")
("id" "page")
(ul ("id" "messages"))
- (text "{% block body %}{% endblock %}"))))
+ (text "{% block body %}{% endblock %}")
+ (div ("style" "min-height: 32px")))
+
+ (script (text "setTimeout(() => init_dropdowns(document.body), 150);"))))
diff --git a/app/templates_src/view.lisp b/app/templates_src/view.lisp
index e3c252e..2a352b5 100644
--- a/app/templates_src/view.lisp
+++ b/app/templates_src/view.lisp
@@ -4,6 +4,11 @@
(text "{{ entry.slug }}"))
(text "{%- endif %} {{ metadata_head|safe }}")
+(text "{% if not metadata.share_title -%}")
+(meta ("property" "og:title") ("content" "{{ entry.slug }}"))
+(meta ("property" "twitter:title") ("content" "{{ entry.slug }}"))
+(text "{%- endif %}")
+
(text "{% if metadata.page_icon|length == 0 -%}")
(link ("rel" "icon") ("href" "/public/favicon.svg"))
(text "{%- endif %}")
@@ -30,7 +35,7 @@
; auto theme
(text "{% if metadata.access_recommended_theme != 'None' -%}")
- (span (text "Auto theme: {{ metadata.access_recommended_theme }}"))
+ (span ("id" "auto_theme") (text "Auto theme: {{ metadata.access_recommended_theme }}"))
(script ("defer" "true") (text "setTimeout(() => { temporary_set_theme('{{ metadata.access_recommended_theme }}') }, 150);"))
(text "{%- endif %}")
@@ -44,11 +49,17 @@
(a ("class" "button small") ("href" "/{{ metadata.access_easy_read }}") (b (text "E2R")))
(text "{%- endif %}"))))
-(text "{{ metadata_css|safe }}")
+(div ("style" "display: none") ("id" "metadata_css") (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"))
(script (text "hljs.highlightAll();"))
-(text "{{ components::footer() }}")
+(text "{% endblock %}")
+(text "{% block nav_extras %}")
+(button
+ ("class" "button camo fade no_fill")
+ ("title" "Toggle high-contrast")
+ ("onclick" "toggle_metadata_css(event)")
+ (text "{{ icon \"contrast\" }}"))
(text "{% endblock %}")
diff --git a/justfile b/justfile
new file mode 100644
index 0000000..7975439
--- /dev/null
+++ b/justfile
@@ -0,0 +1,6 @@
+doc:
+ cargo doc --no-deps --document-private-items
+
+build:
+ just doc
+ cargo build -r
diff --git a/src/main.rs b/src/main.rs
index 7aabae8..7d50353 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -7,7 +7,7 @@ use axum::{Extension, Router};
use nanoneo::core::element::Render;
use std::{collections::HashMap, env::var, net::SocketAddr, process::exit, sync::Arc};
use tera::{Tera, Value};
-use tetratto_core::sdk::DataClient;
+use tetratto_core::{html, sdk::DataClient};
use tetratto_shared::hash::salt;
use tokio::sync::RwLock;
use tower_http::{
@@ -64,20 +64,29 @@ async fn main() {
// build lisp
create_dir_if_not_exists!("./templates_build");
+ create_dir_if_not_exists!("./icons");
+
for x in glob::glob("./templates_src/**/*").expect("failed to read pattern") {
match x {
Ok(x) => std::fs::write(
x.to_str()
.unwrap()
.replace("templates_src/", "templates_build/"),
- nanoneo::parse(&std::fs::read_to_string(x).expect("failed to read template"))
- .render(&mut HashMap::new()),
+ html::pull_icons(
+ nanoneo::parse(&std::fs::read_to_string(x).expect("failed to read template"))
+ .render(&mut HashMap::new()),
+ "./icons",
+ )
+ .await,
)
.expect("failed to write template"),
Err(e) => panic!("{e}"),
}
}
+ // create docs dir
+ create_dir_if_not_exists!("./docs");
+
// ...
let mut tera = match Tera::new(&format!("./templates_build/**/*")) {
Ok(t) => t,
diff --git a/src/markdown.rs b/src/markdown.rs
index 07510b2..d72df10 100644
--- a/src/markdown.rs
+++ b/src/markdown.rs
@@ -76,7 +76,7 @@ fn parse_text_color_line(output: &mut String, buffer: &mut String, line: &str) {
// by this point, we have: !
// %color_buffer%main_buffer%%
output.push_str(&format!(
- "{buffer}"
+ "{buffer}"
));
color_buffer.clear();
@@ -468,7 +468,14 @@ fn parse_image_line(output: &mut String, buffer: &mut String, line: &str) {
if in_image {
// end
output.push_str(&format!(
- "
"
+ "
",
+ if buffer.ends_with("#left") {
+ "left"
+ } else if buffer.ends_with("#right") {
+ "right"
+ } else {
+ "unset"
+ }
));
alt = String::new();
diff --git a/src/model.rs b/src/model.rs
index 2d1e041..bc763d8 100644
--- a/src/model.rs
+++ b/src/model.rs
@@ -357,6 +357,17 @@ macro_rules! metadata_css {
}
};
+ ($selector:literal, $property:literal !important, $self:ident.$field:ident->$output:ident) => {
+ if !$self.$field.is_empty() {
+ $output.push_str(&format!(
+ "{} {{ {}: {} !important; }}\n",
+ $selector,
+ $property,
+ EntryMetadata::css_escape(&$self.$field)
+ ));
+ }
+ };
+
($selector:literal, $property:literal, $format:literal, $self:ident.$field:ident->$output:ident) => {
if !$self.$field.is_empty() {
$output.push_str(&format!(
@@ -462,7 +473,7 @@ impl EntryMetadata {
metadata_css!(".container", "border-radius", self.container_border_radius->output);
metadata_css!(".container", "box-shadow", self.container_shadow->output);
metadata_css!(".container", "text-shadow", self.content_text_shadow->output);
- metadata_css!(".container a", "color", self.content_text_link_color->output);
+ metadata_css!("*, html *", "--color-link" !important, self.content_text_link_color->output);
if self.content_text_align != TextAlignment::Left {
output.push_str(&format!(
diff --git a/src/routes.rs b/src/routes.rs
index e4db1e5..158f1b9 100644
--- a/src/routes.rs
+++ b/src/routes.rs
@@ -8,6 +8,8 @@ use axum::{
response::{Html, IntoResponse},
routing::{get, get_service, post},
};
+use axum_extra::extract::CookieJar;
+use pathbufd::PathBufD;
use serde::Deserialize;
use serde_valid::Validate;
use tera::Context;
@@ -32,6 +34,7 @@ pub fn routes() -> Router {
get_service(tower_http::services::ServeDir::new("./public")),
)
.fallback(not_found_request)
+ .route("/docs/{name}", get(view_doc_request))
// pages
.route("/", get(index_request))
.route("/{slug}", get(view_request))
@@ -78,6 +81,39 @@ async fn index_request(Extension(data): Extension) -> impl IntoResponse {
)
}
+async fn view_doc_request(
+ Extension(data): Extension,
+ Path(name): Path,
+) -> impl IntoResponse {
+ let (ref data, ref tera, ref build_code) = *data.read().await;
+ let path = PathBufD::current().extend(&["docs", &format!("{name}.md")]);
+
+ if !std::fs::exists(&path).unwrap_or(false) {
+ let mut ctx = default_context(&data, &build_code);
+ ctx.insert(
+ "error",
+ &Error::GeneralNotFound("entry".to_string()).to_string(),
+ );
+ return Html(tera.render("error.lisp", &ctx).unwrap());
+ }
+
+ let text = match std::fs::read_to_string(&path) {
+ Ok(t) => t,
+ Err(e) => {
+ let mut ctx = default_context(&data, &build_code);
+ ctx.insert("error", &Error::MiscError(e.to_string()).to_string());
+ return Html(tera.render("error.lisp", &ctx).unwrap());
+ }
+ };
+
+ let mut ctx = default_context(&data, &build_code);
+
+ ctx.insert("text", &text);
+ ctx.insert("file_name", &name);
+
+ return Html(tera.render("doc.lisp", &ctx).unwrap());
+}
+
async fn view_request(
Extension(data): Extension,
Path(slug): Path,
@@ -258,27 +294,46 @@ fn default_random() -> String {
salt()
}
+/// The time that must be waited between each entry creation.
+const CREATE_WAIT_TIME: usize = 5000;
+
async fn create_request(
+ jar: CookieJar,
Extension(data): Extension,
Json(req): Json,
-) -> impl IntoResponse {
+) -> std::result::Result>> {
let (ref data, _, _) = *data.read().await;
+ // check wait time
+ if let Some(cookie) = jar.get("__Secure-Claim-Next") {
+ if unix_epoch_timestamp()
+ != cookie
+ .to_string()
+ .replace("__Secure-Claim-Next=", "")
+ .parse::()
+ .unwrap_or(0)
+ {
+ return Err(Json(
+ Error::MiscError("You must wait a bit to create another entry".to_string()).into(),
+ ));
+ }
+ }
+
// check lengths
if req.slug.len() < 2 {
- return Json(Error::DataTooShort("slug".to_string()).into());
+ return Err(Json(Error::DataTooShort("slug".to_string()).into()));
}
if req.slug.len() > 32 {
- return Json(Error::DataTooLong("slug".to_string()).into());
+ return Err(Json(Error::DataTooLong("slug".to_string()).into()));
}
if req.content.len() < 2 {
- return Json(Error::DataTooShort("content".to_string()).into());
+ return Err(Json(Error::DataTooShort("content".to_string()).into()));
}
if req.content.len() > 150_000 {
- return Json(Error::DataTooLong("content".to_string()).into());
+ return Err(Json(Error::DataTooLong("content".to_string()).into()));
}
// check slug
@@ -288,17 +343,19 @@ async fn create_request(
.unwrap();
if regex.captures(&req.slug).is_some() {
- return Json(Error::MiscError("This slug contains invalid characters".to_string()).into());
+ return Err(Json(
+ Error::MiscError("This slug contains invalid characters".to_string()).into(),
+ ));
}
// check metadata
let metadata: EntryMetadata = match toml::from_str(&EntryMetadata::ini_to_toml(&req.metadata)) {
Ok(x) => x,
- Err(e) => return Json(Error::MiscError(e.to_string()).into()),
+ Err(e) => return Err(Json(Error::MiscError(e.to_string()).into())),
};
if let Err(e) = metadata.validate() {
- return Json(Error::MiscError(e.to_string()).into());
+ return Err(Json(Error::MiscError(e.to_string()).into()));
}
// check for existing
@@ -310,7 +367,9 @@ async fn create_request(
.await
.is_ok()
{
- return Json(Error::MiscError("Slug already in use".to_string()).into());
+ return Err(Json(
+ Error::MiscError("Slug already in use".to_string()).into(),
+ ));
}
// create
@@ -333,22 +392,31 @@ async fn create_request(
)
.await
{
- return Json(e.into());
+ return Err(Json(e.into()));
}
if let Err(e) = data
.insert(format!("entries.views('{}')", req.slug), 0.to_string())
.await
{
- return Json(e.into());
+ return Err(Json(e.into()));
}
// return
- Json(ApiReturn {
- ok: true,
- message: "Success".to_string(),
- payload: Some((req.slug, req.edit_code)),
- })
+ Ok((
+ [(
+ "Set-Cookie",
+ format!(
+ "__Secure-Claim-Next={}; SameSite=Lax; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age=5",
+ unix_epoch_timestamp() + CREATE_WAIT_TIME
+ ),
+ )],
+ Json(ApiReturn {
+ ok: true,
+ message: "Success".to_string(),
+ payload: Some((req.slug, req.edit_code)),
+ }),
+ ))
}
#[derive(Deserialize)]