add: better layout
This commit is contained in:
parent
2cc9ed7445
commit
dbd70d9592
19 changed files with 451 additions and 87 deletions
9
Cargo.lock
generated
9
Cargo.lock
generated
|
@ -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",
|
||||
|
|
|
@ -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"] }
|
||||
|
|
3
app/.gitignore
vendored
Normal file
3
app/.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
public/docs/**/*
|
||||
!public/docs/metadata.md
|
||||
icons/
|
11
app/docs/metadata.md
Normal file
11
app/docs/metadata.md
Normal file
|
@ -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).
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
|
1
app/public/reference
Symbolic link
1
app/public/reference
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../target/doc
|
|
@ -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 {
|
||||
|
|
|
@ -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 "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-sun-icon lucide-sun icon\"><circle cx=\"12\" cy=\"12\" r=\"4\"/><path d=\"M12 2v2\"/><path d=\"M12 20v2\"/><path d=\"m4.93 4.93 1.41 1.41\"/><path d=\"m17.66 17.66 1.41 1.41\"/><path d=\"M2 12h2\"/><path d=\"M20 12h2\"/><path d=\"m6.34 17.66-1.41 1.41\"/><path d=\"m19.07 4.93-1.41 1.41\"/></svg>"))
|
||||
|
||||
(button
|
||||
("class" "button camo fade hidden")
|
||||
("id" "switch_dark")
|
||||
("title" "Switch theme")
|
||||
("onclick" "set_theme('Light')")
|
||||
(text "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-moon-icon lucide-moon icon\"><path d=\"M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z\"/></svg>"))))
|
||||
(text "{%- endmacro %}")
|
9
app/templates_src/doc.lisp
Normal file
9
app/templates_src/doc.lisp
Normal file
|
@ -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 %}")
|
|
@ -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"))
|
||||
|
|
|
@ -6,5 +6,4 @@
|
|||
(div
|
||||
("class" "card")
|
||||
(p (text "{{ error }}")))
|
||||
(text "{{ components::footer() }}")
|
||||
(text "{% endblock %}")
|
||||
|
|
|
@ -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"))
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
(text "{%- import \"components.lisp\" as components -%}")
|
||||
(text "<!doctype html>")
|
||||
(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);"))))
|
||||
|
|
|
@ -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 %}")
|
||||
|
|
6
justfile
Normal file
6
justfile
Normal file
|
@ -0,0 +1,6 @@
|
|||
doc:
|
||||
cargo doc --no-deps --document-private-items
|
||||
|
||||
build:
|
||||
just doc
|
||||
cargo build -r
|
15
src/main.rs
15
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,
|
||||
|
|
|
@ -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!(
|
||||
"<span style=\"color: {color_buffer}\">{buffer}</span>"
|
||||
"<span style=\"color: {color_buffer}\" class=\"color_block\">{buffer}</span>"
|
||||
));
|
||||
|
||||
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!(
|
||||
"<img loading=\"lazy\" alt=\"{alt}\" src=\"{buffer}\" />"
|
||||
"<img loading=\"lazy\" alt=\"{alt}\" src=\"{buffer}\" style=\"float: {}\" />",
|
||||
if buffer.ends_with("#left") {
|
||||
"left"
|
||||
} else if buffer.ends_with("#right") {
|
||||
"right"
|
||||
} else {
|
||||
"unset"
|
||||
}
|
||||
));
|
||||
|
||||
alt = String::new();
|
||||
|
|
13
src/model.rs
13
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!(
|
||||
|
|
100
src/routes.rs
100
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<State>) -> impl IntoResponse {
|
|||
)
|
||||
}
|
||||
|
||||
async fn view_doc_request(
|
||||
Extension(data): Extension<State>,
|
||||
Path(name): Path<String>,
|
||||
) -> 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<State>,
|
||||
Path(slug): Path<String>,
|
||||
|
@ -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<State>,
|
||||
Json(req): Json<CreateEntry>,
|
||||
) -> impl IntoResponse {
|
||||
) -> std::result::Result<impl IntoResponse, Json<ApiReturn<()>>> {
|
||||
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::<usize>()
|
||||
.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)]
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue