Initial commit
This commit is contained in:
commit
a8c0bb1641
|
@ -0,0 +1,3 @@
|
||||||
|
/target
|
||||||
|
/index
|
||||||
|
.env
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,51 @@
|
||||||
|
[package]
|
||||||
|
name = "evie"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
blogdb = { git = "https://git.augustkline.com/august/blogdb" }
|
||||||
|
anyhow = "1.0.89"
|
||||||
|
argon2 = "0.5.3"
|
||||||
|
axum = { version = "0.7.7", features = ["macros", "multipart"] }
|
||||||
|
axum-extra = { version = "0.9.4", features = [
|
||||||
|
"cookie",
|
||||||
|
"cookie-private",
|
||||||
|
"form",
|
||||||
|
"multipart",
|
||||||
|
"query",
|
||||||
|
] }
|
||||||
|
serde = { version = "1.0.210", features = ["derive"] }
|
||||||
|
sqlx = { version = "0.8.2", features = ["sqlite", "runtime-tokio", "macros"] }
|
||||||
|
tokio = { version = "1.40.0", features = ["full"] }
|
||||||
|
tower-http = { version = "0.6.1", features = [
|
||||||
|
"cors",
|
||||||
|
"fs",
|
||||||
|
"limit",
|
||||||
|
"normalize-path",
|
||||||
|
"trace",
|
||||||
|
] }
|
||||||
|
tracing = "0.1.40"
|
||||||
|
futures = "0.3.31"
|
||||||
|
tower = "0.5.1"
|
||||||
|
http-body = "1.0.1"
|
||||||
|
lol_html = "2.0.0"
|
||||||
|
tokio-util = { version = "0.7.12", features = ["io"] }
|
||||||
|
glob = "0.3.1"
|
||||||
|
mime_guess = "2.0.5"
|
||||||
|
constcat = "0.5.1"
|
||||||
|
serde_json = "1.0.132"
|
||||||
|
tantivy = "0.22.0"
|
||||||
|
tracing-subscriber = { version = "0.3.18", features = [
|
||||||
|
"env-filter",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"std",
|
||||||
|
] }
|
||||||
|
phf = "0.11.2"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
dotenvy = "0.15.7"
|
||||||
|
glob = "0.3.1"
|
||||||
|
phf = "0.11.2"
|
||||||
|
phf_codegen = "0.11.2"
|
|
@ -0,0 +1,56 @@
|
||||||
|
use std::{
|
||||||
|
env,
|
||||||
|
fs::File,
|
||||||
|
io::Write,
|
||||||
|
io::{BufWriter, Read},
|
||||||
|
path::Path,
|
||||||
|
};
|
||||||
|
|
||||||
|
use dotenvy::{dotenv, dotenv_iter};
|
||||||
|
use glob::glob;
|
||||||
|
use phf_codegen::Map;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-changed={}", dotenv().unwrap().display());
|
||||||
|
for item in dotenv_iter().unwrap() {
|
||||||
|
let (key, value) = item.unwrap();
|
||||||
|
println!("cargo:rustc-env={key}={value}");
|
||||||
|
}
|
||||||
|
let path = Path::new(&env::var("OUT_DIR").unwrap()).join("codegen.rs");
|
||||||
|
let mut file = BufWriter::new(File::create(&path).unwrap());
|
||||||
|
let template_dir = [&env::var("CARGO_MANIFEST_DIR").unwrap(), "/src/templates"].concat();
|
||||||
|
println!("cargo:rerun-if-changed={}", template_dir);
|
||||||
|
let pattern = [&template_dir, "/**/*.html"].concat();
|
||||||
|
let mut map: Map<String> = phf_codegen::Map::new();
|
||||||
|
|
||||||
|
let paths = glob(&pattern).unwrap();
|
||||||
|
for path in paths {
|
||||||
|
match path {
|
||||||
|
Ok(path) => {
|
||||||
|
let slug_path = path.clone();
|
||||||
|
let slug: String = slug_path
|
||||||
|
.strip_prefix(&template_dir)
|
||||||
|
.unwrap()
|
||||||
|
.as_os_str()
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.strip_suffix(".html")
|
||||||
|
.unwrap()
|
||||||
|
.into(); // eek
|
||||||
|
let mut file = File::open(path.clone()).unwrap();
|
||||||
|
let mut content = "r####\"".to_string();
|
||||||
|
file.read_to_string(&mut content).unwrap();
|
||||||
|
content.push_str("\"####");
|
||||||
|
println!("cargo:warning={}\n{}", &slug, &content);
|
||||||
|
map.entry(slug, &content);
|
||||||
|
}
|
||||||
|
Err(_) => todo!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeln!(
|
||||||
|
&mut file,
|
||||||
|
"static TEMPLATES: phf::Map<&'static str, &'static str> = {};",
|
||||||
|
map.build()
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
|
@ -0,0 +1,256 @@
|
||||||
|
main {
|
||||||
|
--page-margin: calc(0.333 * 100vi / 2);
|
||||||
|
--page-content-size: calc(100% - (var(--page-margin) * 2));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--default-padding);
|
||||||
|
margin-block-end: 40svb;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 60rem) {
|
||||||
|
main {
|
||||||
|
block-size: calc(100svb - 8rem
|
||||||
|
/*the most magic number of all...*/
|
||||||
|
);
|
||||||
|
margin-block-end: unset;
|
||||||
|
max-inline-size: var(--page-content-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-admin {
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-widget-user {
|
||||||
|
flex-direction: row !important;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-widget ul {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: start;
|
||||||
|
flex: 1;
|
||||||
|
min-block-size: 0;
|
||||||
|
block-size: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
max-block-size: 100%;
|
||||||
|
margin-block: 0;
|
||||||
|
gap: var(--default-padding);
|
||||||
|
|
||||||
|
li {
|
||||||
|
@media screen and (min-width: 60rem) {
|
||||||
|
min-block-size: 6rem;
|
||||||
|
flex: 0 1 6rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-widget-user {
|
||||||
|
padding: var(--default-padding);
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--default-padding);
|
||||||
|
|
||||||
|
&>* {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-info {
|
||||||
|
display: flex;
|
||||||
|
position: absolute;
|
||||||
|
z-index: -1;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
transform: translate(calc(var(--default-padding) * -1), calc(var(--default-padding) * -1));
|
||||||
|
box-sizing: border-box;
|
||||||
|
opacity: 0;
|
||||||
|
text-wrap: nowrap;
|
||||||
|
inline-size: 100%;
|
||||||
|
block-size: 100%;
|
||||||
|
background: var(--color-bg);
|
||||||
|
|
||||||
|
.close {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:target {
|
||||||
|
z-index: 100;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info-form {
|
||||||
|
display: contents;
|
||||||
|
|
||||||
|
&>* {
|
||||||
|
padding-block: 0;
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
&>*:not(#user-info) {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(#user-info:target) {
|
||||||
|
&>*:not(#user-info) {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-widget:not(.admin-widget-user) {
|
||||||
|
min-block-size: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-admin {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--default-padding);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-block-size: 0;
|
||||||
|
flex: 1;
|
||||||
|
justify-content: stretch;
|
||||||
|
|
||||||
|
ul {
|
||||||
|
padding-inline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--default-padding);
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-block-size: 0;
|
||||||
|
flex: 0 1 100%;
|
||||||
|
|
||||||
|
|
||||||
|
.empty-message {
|
||||||
|
@media screen and (min-width: 60rem) {
|
||||||
|
margin-block-start: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-action {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
min-block-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
border-block-start: var(--border);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--default-padding);
|
||||||
|
box-sizing: content-box;
|
||||||
|
justify-content: end;
|
||||||
|
max-block-size: 0;
|
||||||
|
padding: 0 var(--default-padding);
|
||||||
|
min-block-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(:checked) {
|
||||||
|
.form-actions {
|
||||||
|
border-block: var(--border);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
max-block-size: calc(var(--default-padding) * 2);
|
||||||
|
padding-block: var(--default-padding);
|
||||||
|
|
||||||
|
@media screen and (max-width: 500px) {
|
||||||
|
& {
|
||||||
|
max-block-size: 4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-action {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
min-inline-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-widget {
|
||||||
|
position: relative;
|
||||||
|
min-block-size: 0;
|
||||||
|
border: var(--border);
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
&>*:not(form, a) {
|
||||||
|
padding-inline: var(--default-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-header {
|
||||||
|
display: flex;
|
||||||
|
min-block-size: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: calc(2 * var(--default-padding)) var(--default-padding);
|
||||||
|
border-inline-start: 0px solid black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-entry {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:has(.entry-content:hover),
|
||||||
|
&:has(.entry-content:focus) {
|
||||||
|
border-inline-start-width: 3px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.entry-content {
|
||||||
|
&>* {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.entry-content {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: inherit;
|
||||||
|
inline-size: 90%;
|
||||||
|
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
transform: translateX(1ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
main {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-block: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-roll-entry {
|
||||||
|
margin-block: 1rem;
|
||||||
|
|
||||||
|
a {
|
||||||
|
border: var(--border);
|
||||||
|
background: var(--color-bg-accent);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: start;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
--content-padding: 1rem;
|
||||||
|
|
||||||
|
.entry-content {
|
||||||
|
padding: var(--content-padding);
|
||||||
|
transition: padding-inline-start 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin-block: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
.entry-content {
|
||||||
|
padding-inline-start: calc(var(--content-padding) * 2);
|
||||||
|
box-shadow: inset 3px 0px 0px var(--color-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
border-inline: none;
|
||||||
|
border-block-start: none;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,200 @@
|
||||||
|
main {
|
||||||
|
@media screen and (min-width: 60rem) {
|
||||||
|
min-block-size: calc(100svb - 8rem
|
||||||
|
/*the most magic number of all...*/
|
||||||
|
) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
margin-block-end: 0;
|
||||||
|
|
||||||
|
border: solid 1px var(--color-text);
|
||||||
|
padding: 2rem;
|
||||||
|
min-block-size: 80%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: calc(var(--font-size) * 3.33);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: calc(var(--font-size) * 2.66);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: calc(var(--font-size) * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(h4, h5, h6) {
|
||||||
|
font-size: calc(var(--font-size) * 1.33);
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6) {
|
||||||
|
margin-block: 2rem 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ce-block:not(:first-child) {
|
||||||
|
|
||||||
|
*:is(h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6) {
|
||||||
|
margin-block: 0rem 0.5rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ce-header {
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
font-family: var(--font-family-display) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ce-delimiter {
|
||||||
|
width: 100%;
|
||||||
|
margin-block: 1rem;
|
||||||
|
text-align: left !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdx-input {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
border: var(--border) !important;
|
||||||
|
background-color: var(--color-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ce-delimiter::before {
|
||||||
|
display: block;
|
||||||
|
content: "" !important;
|
||||||
|
height: 1px !important;
|
||||||
|
inline-size: 100%;
|
||||||
|
position: absolute;
|
||||||
|
background: var(--color-text);
|
||||||
|
color: green
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-tool__image_preloader::after {
|
||||||
|
display: none !important;
|
||||||
|
visibility: hidden !important;
|
||||||
|
height: 0 !important;
|
||||||
|
width: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media all and (max-width: 650px) {
|
||||||
|
.ce-toolbar__actions {
|
||||||
|
|
||||||
|
&>*:nth-child(1),
|
||||||
|
&>*:nth-child(2) {
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: 1px solid var(--color-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdx-warning {
|
||||||
|
&::before {
|
||||||
|
display: none !important;
|
||||||
|
inline-size: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdx-warning__message {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdx-warning__title {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdx-input[data-placeholder]::before {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdx-input {
|
||||||
|
box-shadow: none;
|
||||||
|
-webkit-box-shadow: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
font-size: calc(var(--font-size) * 1.3);
|
||||||
|
border: 1px solid var(--color-text);
|
||||||
|
border-inline-start: 4px solid var(--color-text);
|
||||||
|
padding: 1rem 2rem !important;
|
||||||
|
max-inline-size: 100%;
|
||||||
|
margin-inline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codex-editor__redactor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--default-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ce-block__content {
|
||||||
|
word-wrap: break-word;
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ce-block {
|
||||||
|
margin-block: 0;
|
||||||
|
padding-block: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdx-block {
|
||||||
|
margin-block: 0;
|
||||||
|
padding-block: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codex-editor--narrow {
|
||||||
|
/* margin-inline-end: -50px !important; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.codex-editor {
|
||||||
|
&>*:first-child {
|
||||||
|
|
||||||
|
& h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4 {
|
||||||
|
padding-block-start: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ce-popover--opened>.ce-popover__container {
|
||||||
|
border: solid 1px var(--color-text) !important;
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ce-paragraph {
|
||||||
|
font-size: calc(var(--font-size) * 1.3);
|
||||||
|
/* margin-block: calc(var(--font-size) * 2.6); */
|
||||||
|
line-height: calc(var(--font-size) * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdx-block {
|
||||||
|
&>span {
|
||||||
|
background-color: var(--color-selection) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ce-block--selected .ce-block__content {
|
||||||
|
background-color: var(--color-selection) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-tool__caption {
|
||||||
|
display: none;
|
||||||
|
}
|
|
@ -0,0 +1,73 @@
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: stretch;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 50rem) {
|
||||||
|
body {
|
||||||
|
flex-direction: column;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body>div {
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
|
all: unset;
|
||||||
|
max-block-size: 100%;
|
||||||
|
min-block-size: 25%;
|
||||||
|
min-inline-size: 50%;
|
||||||
|
max-inline-size: 100%;
|
||||||
|
background: url("/assets/images/login.jpg");
|
||||||
|
filter: invert();
|
||||||
|
background-size: cover;
|
||||||
|
mask-size: 100%;
|
||||||
|
mask-image: linear-gradient(rgb(0 0 0 / 100%), transparent);
|
||||||
|
|
||||||
|
@media screen and (min-width: 50rem) {
|
||||||
|
mask-image: linear-gradient(90deg, rgb(0 0 0 / 100%), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
/* block-size: 100%; */
|
||||||
|
max-block-size: 100%;
|
||||||
|
|
||||||
|
&>:first-child {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: var(--border);
|
||||||
|
padding: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
label:has(input:not([type="checkbox"])) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 0.5ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-inline-size: 40ch;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
|
@ -0,0 +1,432 @@
|
||||||
|
@font-face {
|
||||||
|
src: url("/assets/fonts/Redaction-Regular.woff2");
|
||||||
|
font-family: Redaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
src: url("/assets/fonts/Atkinson-Hyperlegible-Regular.woff2");
|
||||||
|
font-family: Atkinson-Hyperlegible;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-bg: #faf9f6;
|
||||||
|
--color-bg-accent: #fffefb;
|
||||||
|
--color-text: #000000;
|
||||||
|
--color-accent: oklch(70.92% 0.1619 310);
|
||||||
|
--color-selection: color-mix(in srgb, var(--color-bg) 85%, var(--color-accent) 15%);
|
||||||
|
--font-family-display: Redaction;
|
||||||
|
--font-family-text: Atkinson-Hyperlegible;
|
||||||
|
--border: 1px solid var(--color-text);
|
||||||
|
|
||||||
|
--default-padding: 1rem;
|
||||||
|
|
||||||
|
--transition-timing: 0.3s ease;
|
||||||
|
|
||||||
|
--header-size: 6rem;
|
||||||
|
|
||||||
|
--font-size: 1rem;
|
||||||
|
font-size: var(--font-size);
|
||||||
|
line-height: 1.15;
|
||||||
|
/* 1. Correct the line height in all browsers. */
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
/* 2. Prevent adjustments of font size after orientation changes in iOS. */
|
||||||
|
tab-size: 4;
|
||||||
|
/* 3. Use a more readable tab size (opinionated). */
|
||||||
|
color: var(--color-text);
|
||||||
|
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
|
||||||
|
*::selection {
|
||||||
|
background-color: var(--color-selection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
[type='button'],
|
||||||
|
[type='reset'],
|
||||||
|
[type='submit'] {
|
||||||
|
-webkit-appearance: button;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
inline-size: 100%;
|
||||||
|
block-size: 100%;
|
||||||
|
margin: 0;
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
::before,
|
||||||
|
::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: var(--font-family-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
p,
|
||||||
|
li,
|
||||||
|
button,
|
||||||
|
.button,
|
||||||
|
input,
|
||||||
|
label,
|
||||||
|
a,
|
||||||
|
blockquote,
|
||||||
|
aside,
|
||||||
|
ol,
|
||||||
|
ul {
|
||||||
|
font-size: calc(var(--font-size) * 1.33);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
font-family: var(--font-family-display);
|
||||||
|
}
|
||||||
|
|
||||||
|
aside,
|
||||||
|
blockquote {
|
||||||
|
border: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote,
|
||||||
|
aside {
|
||||||
|
padding: var(--default-padding) calc(var(--default-padding) * 2);
|
||||||
|
max-inline-size: 100%;
|
||||||
|
margin-inline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
&::before {
|
||||||
|
content: '“';
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '”';
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
appearance: none;
|
||||||
|
list-style: none;
|
||||||
|
padding-inline: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: unset;
|
||||||
|
text-decoration: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background: var(--color-bg-accent);
|
||||||
|
outline: none;
|
||||||
|
border: var(--border);
|
||||||
|
transition: box-shadow var(--transition-timing);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:is([type="text"], [type="password"], [type="search"]) {
|
||||||
|
padding: 0.5ch 1ch;
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:hover {
|
||||||
|
box-shadow: inset 3px 0px 0px var(--color-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
min-inline-size: 1ch;
|
||||||
|
align-content: center;
|
||||||
|
justify-content: center;
|
||||||
|
display: inline-block;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border: 0px solid var(--color-bg);
|
||||||
|
outline: 0px solid var(--color-text);
|
||||||
|
|
||||||
|
&:not(:checked)::before {
|
||||||
|
outline: 3px solid var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:checked::before {
|
||||||
|
outline: 3px solid var(--color-text);
|
||||||
|
border: 3px solid var(--color-bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
background: var(--color-bg);
|
||||||
|
outline: 1px solid var(--color-text);
|
||||||
|
box-shadow: inset 0px 0px 0px var(--color-text), inset 0px 0px 0px var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&,
|
||||||
|
&::before {
|
||||||
|
transition: box-shadow var(--transition-timing), background var(--transition-timing), outline var(--transition-timing);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover::before {
|
||||||
|
box-shadow: inset 1px 1px 0px var(--color-text), inset -1px -1px 0px var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:checked::before {
|
||||||
|
background: var(--color-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
.button {
|
||||||
|
appearance: unset;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: var(--border);
|
||||||
|
transition: box-shadow var(--transition-timing);
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-block;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
box-shadow: 0px 2px 0px var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
box-shadow:
|
||||||
|
0px 1px 0px var(--color-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
padding: 1px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.animated-link-underline {
|
||||||
|
background-size: 100% 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animated-link,
|
||||||
|
.animated-link-underline {
|
||||||
|
text-decoration: unset;
|
||||||
|
background-size: 100% 0%;
|
||||||
|
background-position: left bottom;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
|
||||||
|
transition: background-size var(--transition-timing), background-image var(--transition-timing), color var(--transition-timing);
|
||||||
|
background-image: linear-gradient(var(--color-text), var(--color-text));
|
||||||
|
|
||||||
|
color: var(--color-text);
|
||||||
|
|
||||||
|
* {
|
||||||
|
transition: color var(--transition-timing);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus,
|
||||||
|
&:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
color: var(--color-bg);
|
||||||
|
|
||||||
|
* {
|
||||||
|
color: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
background-size: 100% 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
box-sizing: content-box;
|
||||||
|
flex-direction: column;
|
||||||
|
border-block-end: var(--border);
|
||||||
|
max-inline-size: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--default-padding);
|
||||||
|
position: sticky;
|
||||||
|
z-index: 100;
|
||||||
|
top: 0;
|
||||||
|
background: var(--color-bg);
|
||||||
|
|
||||||
|
&>:first-child {
|
||||||
|
margin: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-block: 0.25ch;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
max-inline-size: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
a {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-family-display);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-inline-start: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-inline-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
gap: var(--default-padding);
|
||||||
|
|
||||||
|
font-size: calc(var(--font-size) * 1.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
form {
|
||||||
|
min-inline-size: min(100%, 10ch);
|
||||||
|
flex: 0;
|
||||||
|
align-items: center;
|
||||||
|
border: var(--border);
|
||||||
|
background: var(--color-bg-accent);
|
||||||
|
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: flex;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
padding-inline-end: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
min-inline-size: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
transition: flex var(--transition-timing),
|
||||||
|
padding-inline var(--transition-timing);
|
||||||
|
|
||||||
|
input[type="search"]::-webkit-search-decoration,
|
||||||
|
input[type="search"]::-webkit-search-cancel-button,
|
||||||
|
input[type="search"]::-webkit-search-results-button,
|
||||||
|
input[type="search"]::-webkit-search-results-decoration {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&>* {
|
||||||
|
box-sizing: content-box;
|
||||||
|
background: transparent;
|
||||||
|
margin-inline: 0;
|
||||||
|
margin-block: 0;
|
||||||
|
padding-block: 0;
|
||||||
|
block-size: 100%;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
margin-block: var(--default-padding) 40svb;
|
||||||
|
margin-inline: auto;
|
||||||
|
max-inline-size: min(60ch, 80%);
|
||||||
|
padding-block: var(--default-padding);
|
||||||
|
gap: var(--default-padding);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
&>:first-child {
|
||||||
|
margin-block-start: 0;
|
||||||
|
|
||||||
|
&:is(a) {
|
||||||
|
&+* {
|
||||||
|
margin-block-start: 0rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&>* {
|
||||||
|
margin-block: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&>* {
|
||||||
|
|
||||||
|
&:is(h1) {
|
||||||
|
font-size: calc(var(--font-size) * 3.33);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:is(h2) {
|
||||||
|
font-size: calc(var(--font-size) * 2.66);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:is(h3) {
|
||||||
|
font-size: calc(var(--font-size) * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:is(h4, h5, h6) {
|
||||||
|
font-size: calc(var(--font-size) * 1.33);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:is(h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6) {
|
||||||
|
margin-block: 2rem 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
min-inline-size: 100%;
|
||||||
|
max-inline-size: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: top;
|
||||||
|
border: var(--border);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* anything wider than mobile */
|
||||||
|
@media all and (min-width: 60rem) {
|
||||||
|
header {
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
nav {
|
||||||
|
justify-content: flex-end;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
margin-block: var(--default-padding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* tablet */
|
||||||
|
@media all and (min-width: 60rem) and (max-width: 80rem) {}
|
||||||
|
|
||||||
|
/* desktop */
|
||||||
|
@media all and (min-width: 80rem) {}
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
After Width: | Height: | Size: 116 KiB |
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,745 @@
|
||||||
|
use std::{env, vec};
|
||||||
|
|
||||||
|
use blogdb::posts::Post;
|
||||||
|
use blogdb::BlogDb;
|
||||||
|
use lol_html::html_content::ContentType;
|
||||||
|
use lol_html::{comments, element, rewrite_str, RewriteStrSettings};
|
||||||
|
|
||||||
|
use crate::routes::api::editor::Blocks;
|
||||||
|
use crate::routes::SearchResponse;
|
||||||
|
|
||||||
|
include!(concat!(env!("OUT_DIR"), "/codegen.rs"));
|
||||||
|
|
||||||
|
/// Gets template html string from `src/templates`
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// // `src/templates/blog-roll.html`
|
||||||
|
/// //
|
||||||
|
/// // <h1>Blog</h1>
|
||||||
|
/// // <ul></ul>
|
||||||
|
///
|
||||||
|
/// let template = template!("blog-roll");
|
||||||
|
/// assert_eq!(template, "<h1>Blog</h1>\n<ul></ul>");
|
||||||
|
/// ```
|
||||||
|
macro_rules! template {
|
||||||
|
($t:tt) => {
|
||||||
|
TEMPLATES.get($t).unwrap_or(&"")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes a page with default settings and content inserted into <main></main>
|
||||||
|
/// (or replacing <main></main> if content has a <main> tag)
|
||||||
|
pub(crate) fn make_page<S>(content: S, settings: PageSettings) -> String
|
||||||
|
where
|
||||||
|
S: AsRef<str>,
|
||||||
|
{
|
||||||
|
rewrite_str(
|
||||||
|
template!("default"),
|
||||||
|
RewriteStrSettings {
|
||||||
|
element_content_handlers: vec![
|
||||||
|
element!("head", |head| {
|
||||||
|
head.prepend(
|
||||||
|
&["<title>", &settings.title, "</title>"].concat(),
|
||||||
|
ContentType::Html,
|
||||||
|
);
|
||||||
|
if let Some(stylesheets) = &settings.stylesheets {
|
||||||
|
for url in stylesheets {
|
||||||
|
head.append(
|
||||||
|
&[r#"<link rel="stylesheet" href=""#, url, r#"" />"#].concat(),
|
||||||
|
ContentType::Html,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}),
|
||||||
|
element!("body", |body| {
|
||||||
|
body.prepend("<script>0</script>", ContentType::Html);
|
||||||
|
Ok(())
|
||||||
|
}),
|
||||||
|
element!("main", |main| {
|
||||||
|
if content.as_ref().contains("<main>") {
|
||||||
|
main.replace(content.as_ref(), ContentType::Html);
|
||||||
|
} else {
|
||||||
|
main.set_inner_content(content.as_ref(), ContentType::Html);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}),
|
||||||
|
element!("site-header", |site_header| {
|
||||||
|
if settings.site_header {
|
||||||
|
site_header.replace(template!("site-header"), ContentType::Html);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}),
|
||||||
|
element!("br", |br| {
|
||||||
|
br.remove();
|
||||||
|
Ok(())
|
||||||
|
}),
|
||||||
|
comments!("*", |comments| {
|
||||||
|
comments.remove();
|
||||||
|
Ok(())
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
..RewriteStrSettings::new()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn rewrite_el<S, T, R>(input: S, name: T, with: R) -> String
|
||||||
|
where
|
||||||
|
S: AsRef<str>,
|
||||||
|
T: AsRef<str>,
|
||||||
|
R: AsRef<str>,
|
||||||
|
{
|
||||||
|
rewrite_str(
|
||||||
|
input.as_ref(),
|
||||||
|
RewriteStrSettings {
|
||||||
|
element_content_handlers: vec![element!(name.as_ref(), |el| {
|
||||||
|
el.replace(with.as_ref(), ContentType::Html);
|
||||||
|
Ok(())
|
||||||
|
})],
|
||||||
|
..RewriteStrSettings::new()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn remove_el<S, T>(input: S, name: T) -> String
|
||||||
|
where
|
||||||
|
S: AsRef<str>,
|
||||||
|
T: AsRef<str>,
|
||||||
|
{
|
||||||
|
rewrite_str(
|
||||||
|
input.as_ref(),
|
||||||
|
RewriteStrSettings {
|
||||||
|
element_content_handlers: vec![element!(name.as_ref(), |el| {
|
||||||
|
el.remove();
|
||||||
|
Ok(())
|
||||||
|
})],
|
||||||
|
..RewriteStrSettings::new()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_comments<S: AsRef<str>>(input: S) -> String {
|
||||||
|
rewrite_str(
|
||||||
|
input.as_ref(),
|
||||||
|
RewriteStrSettings {
|
||||||
|
element_content_handlers: vec![comments!("*", |co| {
|
||||||
|
co.remove();
|
||||||
|
Ok(())
|
||||||
|
})],
|
||||||
|
..RewriteStrSettings::new()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn homepage_editor<S: AsRef<str>>(homepage_content: S) -> String {
|
||||||
|
editor(homepage_content, "/api/editor/update/home")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn about_editor<S: AsRef<str>>(about_content: S) -> String {
|
||||||
|
editor(about_content, "/api/editor/update/about")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn draft_editor<S, T>(post_content: S, post_id: T) -> String
|
||||||
|
where
|
||||||
|
S: AsRef<str>,
|
||||||
|
T: AsRef<str>,
|
||||||
|
{
|
||||||
|
editor(
|
||||||
|
post_content,
|
||||||
|
["/api/editor/update/", post_id.as_ref()].concat(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn editor<S, T>(content: S, endpoint: T) -> String
|
||||||
|
where
|
||||||
|
S: AsRef<str>,
|
||||||
|
T: AsRef<str>,
|
||||||
|
{
|
||||||
|
let content = content.as_ref();
|
||||||
|
let data = if !content.is_empty() && content.starts_with("[") && content.ends_with("]") {
|
||||||
|
content
|
||||||
|
} else {
|
||||||
|
"[]"
|
||||||
|
};
|
||||||
|
let settings = [
|
||||||
|
"const data={blocks:",
|
||||||
|
data,
|
||||||
|
"};const onChange=()=>{editor.save().then(async(savedData)=>{const endpoint = \"",endpoint.as_ref(),"\"; const response = await fetch(endpoint, { method: \"POST\", mode: \"same-origin\", headers: {\"Content-Type\": \"application/json\"}, body: JSON.stringify(savedData.blocks) });})};"
|
||||||
|
].concat();
|
||||||
|
let html = rewrite_str(
|
||||||
|
template!("post-editor"),
|
||||||
|
RewriteStrSettings {
|
||||||
|
element_content_handlers: vec![element!("#editor", move |script| {
|
||||||
|
script.prepend(&settings, ContentType::Html);
|
||||||
|
Ok(())
|
||||||
|
})],
|
||||||
|
strict: false,
|
||||||
|
..RewriteStrSettings::new()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or_default();
|
||||||
|
make_page(
|
||||||
|
html,
|
||||||
|
PageSettings::new("Editor", Some(vec!["/assets/css/editor.css"]), true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrites `<login-status></login-status>` with html from template, and sets the `next` hidden
|
||||||
|
/// input element to the next location
|
||||||
|
pub(crate) fn login_status(next: &str, success: bool) -> String {
|
||||||
|
let status = if success {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
template!("login-status")
|
||||||
|
};
|
||||||
|
|
||||||
|
let next = next.strip_prefix("/").unwrap_or(next);
|
||||||
|
|
||||||
|
let html = rewrite_el(template!("login"), "login-status", status);
|
||||||
|
|
||||||
|
let html = rewrite_str(
|
||||||
|
&html,
|
||||||
|
RewriteStrSettings {
|
||||||
|
element_content_handlers: vec![element!(r#"input[name="next"]"#, |input| {
|
||||||
|
input.set_attribute("value", next).unwrap_or_default();
|
||||||
|
Ok(())
|
||||||
|
})],
|
||||||
|
..RewriteStrSettings::new()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let html = rewrite_str(
|
||||||
|
&html,
|
||||||
|
RewriteStrSettings {
|
||||||
|
element_content_handlers: vec![element!(r#"input[name="next"]"#, |input| {
|
||||||
|
input.set_attribute("value", next).unwrap_or_default();
|
||||||
|
Ok(())
|
||||||
|
})],
|
||||||
|
..RewriteStrSettings::new()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or_default();
|
||||||
|
make_page(
|
||||||
|
html,
|
||||||
|
PageSettings::new("Login", Some(vec!["/assets/css/login.css"]), false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn admin_page(session_id: i64, db: &BlogDb) -> String {
|
||||||
|
let content = admin_widgets(template!("admin"), session_id, db).await;
|
||||||
|
make_page(
|
||||||
|
&content,
|
||||||
|
PageSettings::new("Admin", Some(vec!["/assets/css/admin.css"]), true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn admin_widgets(input: &str, session_id: i64, db: &BlogDb) -> String {
|
||||||
|
let posts_html = admin_entries(EntryType::Post, session_id, db).await;
|
||||||
|
let drafts_html = admin_entries(EntryType::Draft, session_id, db).await;
|
||||||
|
let user_html = template!("admin-widgets/user");
|
||||||
|
|
||||||
|
rewrite_str(
|
||||||
|
input,
|
||||||
|
RewriteStrSettings {
|
||||||
|
element_content_handlers: vec![element!("admin-widget", move |a| {
|
||||||
|
match a.get_attribute("type") {
|
||||||
|
Some(attr) => {
|
||||||
|
match attr.as_str() {
|
||||||
|
"drafts" => a.replace(&drafts_html, ContentType::Html),
|
||||||
|
"posts" => a.replace(&posts_html, ContentType::Html),
|
||||||
|
"user" => a.replace(user_html, ContentType::Html),
|
||||||
|
_ => a.remove(),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => todo!(),
|
||||||
|
}
|
||||||
|
})],
|
||||||
|
..RewriteStrSettings::new()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EntryType {
|
||||||
|
Post,
|
||||||
|
Draft,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn admin_entries(entry_type: EntryType, session_id: i64, db: &BlogDb) -> String {
|
||||||
|
let (list_template, entry_template) = match entry_type {
|
||||||
|
EntryType::Post => (
|
||||||
|
template!("admin-widgets/posts"),
|
||||||
|
template!("admin-widgets/post"),
|
||||||
|
),
|
||||||
|
EntryType::Draft => (
|
||||||
|
template!("admin-widgets/drafts"),
|
||||||
|
template!("admin-widgets/draft"),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let is_empty: bool;
|
||||||
|
let entries_html = match entry_type {
|
||||||
|
EntryType::Post => {
|
||||||
|
let mut entries = db.get_posts().await.unwrap_or(vec![]);
|
||||||
|
entries.reverse();
|
||||||
|
let mut entry_list_html = String::new();
|
||||||
|
is_empty = entries.is_empty();
|
||||||
|
if !is_empty {
|
||||||
|
for entry in entries {
|
||||||
|
let href = ["/blog/", &entry.id].concat();
|
||||||
|
let mut entry_html = admin_entry(
|
||||||
|
entry_template,
|
||||||
|
&entry.id,
|
||||||
|
&entry.title,
|
||||||
|
&href,
|
||||||
|
&entry.content,
|
||||||
|
);
|
||||||
|
entry_html = rewrite_str(
|
||||||
|
&entry_html,
|
||||||
|
RewriteStrSettings {
|
||||||
|
element_content_handlers: vec![element!("time", |time| {
|
||||||
|
time.set_inner_content(&entry.date, ContentType::Text);
|
||||||
|
Ok(())
|
||||||
|
})],
|
||||||
|
..RewriteStrSettings::new()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or_default();
|
||||||
|
entry_list_html.push_str(&entry_html);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry_list_html
|
||||||
|
}
|
||||||
|
EntryType::Draft => {
|
||||||
|
let mut entries = db.get_drafts(session_id).await.unwrap_or(vec![]);
|
||||||
|
entries.reverse();
|
||||||
|
let mut entry_list_html = String::new();
|
||||||
|
is_empty = entries.is_empty();
|
||||||
|
if !is_empty {
|
||||||
|
for entry in entries {
|
||||||
|
let href = ["/editor/", &entry.id].concat();
|
||||||
|
let entry_html = admin_entry(
|
||||||
|
entry_template,
|
||||||
|
&entry.id,
|
||||||
|
&entry.title,
|
||||||
|
&href,
|
||||||
|
&entry.content,
|
||||||
|
);
|
||||||
|
entry_list_html.push_str(&entry_html);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry_list_html
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let final_html = rewrite_str(
|
||||||
|
list_template,
|
||||||
|
RewriteStrSettings {
|
||||||
|
element_content_handlers: vec![element!("ul", |ul| {
|
||||||
|
if is_empty {
|
||||||
|
match entry_type {
|
||||||
|
EntryType::Post => ul.replace("<h2 class=\"empty-message\">No Published Posts</h2>", ContentType::Html),
|
||||||
|
EntryType::Draft => ul.replace("<div class=\"entry-content empty-message\"><h2>No Drafts</h2><p>Start a <a href=\"/editor/new\" class=\"button\">New Draft</a></p></div>", ContentType::Html)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ul.set_inner_content(&entries_html, ContentType::Html);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})],
|
||||||
|
..RewriteStrSettings::new()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or_default();
|
||||||
|
final_html
|
||||||
|
}
|
||||||
|
|
||||||
|
fn admin_entry(entry_template: &str, id: &str, title: &str, href: &str, content: &str) -> String {
|
||||||
|
rewrite_str(
|
||||||
|
entry_template,
|
||||||
|
RewriteStrSettings {
|
||||||
|
element_content_handlers: vec![
|
||||||
|
element!("a.entry-content", |a| {
|
||||||
|
a.set_attribute("href", href).unwrap_or_default();
|
||||||
|
Ok(())
|
||||||
|
}),
|
||||||
|
element!("h2", |h2| {
|
||||||
|
h2.set_inner_content(title, ContentType::Html);
|
||||||
|
Ok(())
|
||||||
|
}),
|
||||||
|
element!("p", |p| {
|
||||||
|
let post_content: Blocks = serde_json::from_str(content).unwrap_or_default();
|
||||||
|
let content = rewrite_str(
|
||||||
|
&post_content.desc(),
|
||||||
|
RewriteStrSettings {
|
||||||
|
element_content_handlers: vec![element!("*", |all| {
|
||||||
|
all.remove_and_keep_content();
|
||||||
|
Ok(())
|
||||||
|
})],
|
||||||
|
..RewriteStrSettings::new()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or_default();
|
||||||
|
p.set_inner_content(&content, ContentType::Text);
|
||||||
|
Ok(())
|
||||||
|
}),
|
||||||
|
element!("input", |input| {
|
||||||
|
input.set_attribute("value", id).unwrap_or_default();
|
||||||
|
Ok(())
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
..RewriteStrSettings::new()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn about_page(db: &BlogDb) -> String {
|
||||||
|
let about = db.get_about().await;
|
||||||
|
let about_content: Blocks = serde_json::from_str(&about).unwrap_or_default();
|
||||||
|
let about_html = remove_comments(about_content.to_html());
|
||||||
|
make_page(&about_html, PageSettings::title("About"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn home_page(db: &BlogDb) -> String {
|
||||||
|
let home = db.get_homepage().await;
|
||||||
|
let home_content: Blocks = serde_json::from_str(&home).unwrap_or_default();
|
||||||
|
let home_html = home_content.to_html();
|
||||||
|
make_page(&home_html, PageSettings::title("Evie Ippolito"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn meta<S, T, R, I>(slug: S, title: T, description: R, image: I) -> String
|
||||||
|
where
|
||||||
|
S: AsRef<str>,
|
||||||
|
T: AsRef<str>,
|
||||||
|
R: AsRef<str>,
|
||||||
|
I: AsRef<str>,
|
||||||
|
{
|
||||||
|
rewrite_str(
|
||||||
|
template!("head/meta"),
|
||||||
|
RewriteStrSettings {
|
||||||
|
element_content_handlers: vec![element!("meta", |meta| {
|
||||||
|
if let Some(attr) = meta.get_attribute("property") {
|
||||||
|
let content = match attr.as_str() {
|
||||||
|
"og:url" => &[env!("DOMAIN"), slug.as_ref()].concat(),
|
||||||
|
"og:type" => "article",
|
||||||
|
"og:title" => title.as_ref(),
|
||||||
|
"og:description" => description.as_ref(),
|
||||||
|
"og:image" => {
|
||||||
|
let image = image.as_ref();
|
||||||
|
if !image.is_empty() {
|
||||||
|
image
|
||||||
|
} else {
|
||||||
|
meta.remove();
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
let _ = meta.set_attribute("content", content);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})],
|
||||||
|
|
||||||
|
..RewriteStrSettings::new()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a blog page from a post's content
|
||||||
|
pub(crate) async fn blog_page<S>(id: S, db: &BlogDb) -> String
|
||||||
|
where
|
||||||
|
S: AsRef<str>,
|
||||||
|
{
|
||||||
|
let template = template!("blog-page");
|
||||||
|
let html = make_page(template, PageSettings::title("Blog"));
|
||||||
|
|
||||||
|
let post = db.get_post(id).await.unwrap_or_default();
|
||||||
|
let head = head::<_, _, [String; 0]>(post.title, []);
|
||||||
|
let post_content: Blocks = serde_json::from_str(&post.content).unwrap_or_default();
|
||||||
|
let post_html: String = post_content.to_html();
|
||||||
|
let image = if let Some(url) = post_content.image() {
|
||||||
|
&[url].concat()
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
let meta = meta(
|
||||||
|
["/blog/", &post.id].concat(),
|
||||||
|
post_content.title(),
|
||||||
|
post_content.desc(),
|
||||||
|
image,
|
||||||
|
);
|
||||||
|
|
||||||
|
let html = rewrite_str(
|
||||||
|
&html,
|
||||||
|
RewriteStrSettings {
|
||||||
|
element_content_handlers: vec![element!("time", |time| {
|
||||||
|
time.after(&post_html, ContentType::Html);
|
||||||
|
time.replace(
|
||||||
|
&[
|
||||||
|
r#"<a class="animated-link" style="align-self: flex-start; margin-block-end: 0" href="https://"#,
|
||||||
|
env!("DOMAIN"),
|
||||||
|
"/blog/",
|
||||||
|
&post.id,
|
||||||
|
r#""><time>"#,
|
||||||
|
&post.date,
|
||||||
|
"</time></a>",
|
||||||
|
]
|
||||||
|
.concat(),
|
||||||
|
ContentType::Html,
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
})],
|
||||||
|
|
||||||
|
..RewriteStrSettings::new()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or_default();
|
||||||
|
html
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn blog_roll(db: &BlogDb) -> String {
|
||||||
|
let mut posts = match db.get_posts().await {
|
||||||
|
Ok(posts) => posts,
|
||||||
|
Err(_) => return "".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
posts.reverse();
|
||||||
|
|
||||||
|
let mut post_entries = String::new();
|
||||||
|
|
||||||
|
for post in posts.iter() {
|
||||||
|
let post_entry = post_entry(post);
|
||||||
|
post_entries.push_str(&post_entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
let blog_roll_html = rewrite_str(
|
||||||
|
template!("blog-roll"),
|
||||||
|
RewriteStrSettings {
|
||||||
|
element_content_handlers: vec![element!("ul", move |ul| {
|
||||||
|
ul.set_inner_content(&post_entries, ContentType::Html);
|
||||||
|
Ok(())
|
||||||
|
})],
|
||||||
|
..RewriteStrSettings::new()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
make_page(
|
||||||
|
blog_roll_html,
|
||||||
|
PageSettings::new("Blog", Some(vec!["/assets/css/blog.css"]), true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn post_entry(post: &Post) -> String {
|
||||||
|
rewrite_str(
|
||||||
|
template!("blog-roll-entry"),
|
||||||
|
RewriteStrSettings {
|
||||||
|
element_content_handlers: vec![
|
||||||
|
element!("a", |a| {
|
||||||
|
a.set_attribute("href", &["/blog/", &post.id].concat())
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok(())
|
||||||
|
}),
|
||||||
|
element!("img", |img| {
|
||||||
|
let post_content: Blocks =
|
||||||
|
serde_json::from_str(&post.content).unwrap_or_default();
|
||||||
|
match post_content.image() {
|
||||||
|
Some(src) => {
|
||||||
|
let _ = img.set_attribute("src", src);
|
||||||
|
}
|
||||||
|
None => img.remove(),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}),
|
||||||
|
element!("h2", |h2| {
|
||||||
|
h2.set_inner_content(&post.title, ContentType::Text);
|
||||||
|
Ok(())
|
||||||
|
}),
|
||||||
|
element!("time", |time| {
|
||||||
|
time.set_inner_content(&post.date, ContentType::Text);
|
||||||
|
Ok(())
|
||||||
|
}),
|
||||||
|
element!("p", |p| {
|
||||||
|
let post_content: Blocks =
|
||||||
|
serde_json::from_str(&post.content).unwrap_or_default();
|
||||||
|
let content = remove_el(post_content.desc(), "br");
|
||||||
|
p.set_inner_content(&content, ContentType::Html);
|
||||||
|
Ok(())
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
..RewriteStrSettings::new()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn search_page_no_query() -> String {
|
||||||
|
let search_page_html = make_page(template!("search-page"), PageSettings::title("Search"));
|
||||||
|
rewrite_str(
|
||||||
|
&search_page_html,
|
||||||
|
RewriteStrSettings {
|
||||||
|
element_content_handlers: vec![element!("ul", move |ul| {
|
||||||
|
ul.remove();
|
||||||
|
Ok(())
|
||||||
|
})],
|
||||||
|
..RewriteStrSettings::new()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn search_page(
|
||||||
|
query: String,
|
||||||
|
search_response: SearchResponse,
|
||||||
|
db: &BlogDb,
|
||||||
|
) -> String {
|
||||||
|
let mut entries = String::new();
|
||||||
|
let search_page_html = if !search_response.is_empty() {
|
||||||
|
for entry in search_response {
|
||||||
|
let post = match db.get_post(&entry.id[0]).await {
|
||||||
|
Ok(post) => post,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let entry_html = post_entry(&post);
|
||||||
|
entries.push_str(&entry_html);
|
||||||
|
}
|
||||||
|
[
|
||||||
|
"<h1>Results for “",
|
||||||
|
&query,
|
||||||
|
"”</h1>",
|
||||||
|
"<ul>",
|
||||||
|
&entries,
|
||||||
|
"</ul>",
|
||||||
|
]
|
||||||
|
.concat()
|
||||||
|
} else {
|
||||||
|
["<h1>No results for “", &query, "”</h1>"].concat()
|
||||||
|
};
|
||||||
|
make_page(
|
||||||
|
&search_page_html,
|
||||||
|
PageSettings::new(
|
||||||
|
["Results for “", &query, "”"].concat(),
|
||||||
|
Some(vec!["/assets/css/blog.css"]),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a <link rel="stylesheet" href="style_url"/> for each given style_url
|
||||||
|
fn stylesheets<S, I>(style_urls: I) -> String
|
||||||
|
where
|
||||||
|
S: AsRef<str>,
|
||||||
|
I: IntoIterator<Item = S>,
|
||||||
|
{
|
||||||
|
style_urls
|
||||||
|
.into_iter()
|
||||||
|
.map(|url| [r#"<link href="#, url.as_ref(), r#" rel="stylesheet"/>"#].concat())
|
||||||
|
.collect::<String>()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn head<S, T, I>(title: S, style_urls: I) -> String
|
||||||
|
where
|
||||||
|
S: AsRef<str>,
|
||||||
|
T: AsRef<str>,
|
||||||
|
I: IntoIterator<Item = T>,
|
||||||
|
{
|
||||||
|
let content = rewrite_str(
|
||||||
|
template!("head/content"),
|
||||||
|
RewriteStrSettings {
|
||||||
|
element_content_handlers: vec![element!("title", move |t| {
|
||||||
|
t.set_inner_content(title.as_ref(), ContentType::Html);
|
||||||
|
Ok(())
|
||||||
|
})],
|
||||||
|
..RewriteStrSettings::new()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let links_html = stylesheets(style_urls);
|
||||||
|
[content, links_html].concat()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct PageSettings {
|
||||||
|
title: String,
|
||||||
|
stylesheets: Option<Vec<String>>,
|
||||||
|
site_header: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PageSettings {
|
||||||
|
pub(crate) fn title<S>(title: S) -> Self
|
||||||
|
where
|
||||||
|
S: ToString,
|
||||||
|
{
|
||||||
|
Self {
|
||||||
|
title: title.to_string(),
|
||||||
|
stylesheets: None,
|
||||||
|
site_header: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) fn new<S, T>(title: S, stylesheets: Option<Vec<T>>, site_header: bool) -> Self
|
||||||
|
where
|
||||||
|
S: ToString,
|
||||||
|
T: ToString,
|
||||||
|
{
|
||||||
|
let stylesheets =
|
||||||
|
stylesheets.map(|s| s.iter().map(|t| t.to_string()).collect::<Vec<String>>());
|
||||||
|
Self {
|
||||||
|
title: title.to_string(),
|
||||||
|
stylesheets,
|
||||||
|
site_header,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn make_404<S>(message: S) -> String
|
||||||
|
where
|
||||||
|
S: AsRef<str>,
|
||||||
|
{
|
||||||
|
make_page(message, PageSettings::title("Not found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn animate_anchors<S>(input: S) -> String
|
||||||
|
where
|
||||||
|
S: AsRef<str>,
|
||||||
|
{
|
||||||
|
rewrite_str(
|
||||||
|
input.as_ref(),
|
||||||
|
RewriteStrSettings {
|
||||||
|
element_content_handlers: vec![element!("a", move |t| {
|
||||||
|
match t.get_attribute("class") {
|
||||||
|
Some(class) => t
|
||||||
|
.set_attribute("class", &[&class, "animated-link-underline"].concat())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
None => {
|
||||||
|
t.set_attribute("class", "animated-link-underline")
|
||||||
|
.unwrap_or_default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})],
|
||||||
|
..RewriteStrSettings::new()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn sanitize<S: AsRef<str>>(input: S) -> String {
|
||||||
|
rewrite_str(
|
||||||
|
input.as_ref(),
|
||||||
|
RewriteStrSettings {
|
||||||
|
element_content_handlers: vec![element!("*", move |all| {
|
||||||
|
all.remove_and_keep_content();
|
||||||
|
Ok(())
|
||||||
|
})],
|
||||||
|
..RewriteStrSettings::new()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
mod html;
|
||||||
|
mod server;
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
use server::*;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
let port: Option<u16> = match env::args().nth(1) {
|
||||||
|
Some(value) => Some(value.parse()?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
Blog::new().await?.serve(port).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
use axum::{
|
||||||
|
extract::{Request, State},
|
||||||
|
http::{header::LOCATION, HeaderMap, HeaderValue, StatusCode},
|
||||||
|
middleware::Next,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use axum_extra::extract::PrivateCookieJar;
|
||||||
|
|
||||||
|
use crate::BlogState;
|
||||||
|
|
||||||
|
/// Middleware layer requiring user to log in to access route
|
||||||
|
pub(crate) async fn logged_in(
|
||||||
|
jar: PrivateCookieJar,
|
||||||
|
State(state): State<BlogState>,
|
||||||
|
request: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
let next_uri = request.uri();
|
||||||
|
let redirect_to_login = || -> Response {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.append(
|
||||||
|
LOCATION,
|
||||||
|
HeaderValue::from_str(&["/login", &next_uri.to_string()].concat()).unwrap(),
|
||||||
|
);
|
||||||
|
(StatusCode::FOUND, headers).into_response()
|
||||||
|
};
|
||||||
|
let db = state.db();
|
||||||
|
let session_id = match jar.get("id") {
|
||||||
|
Some(id) => match id.value_trimmed().parse::<i64>() {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => {
|
||||||
|
return redirect_to_login();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
return redirect_to_login();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(_error) = db.check_session(session_id).await {
|
||||||
|
return redirect_to_login();
|
||||||
|
}
|
||||||
|
next.run(request).await
|
||||||
|
}
|
|
@ -0,0 +1,347 @@
|
||||||
|
mod middleware;
|
||||||
|
pub(crate) mod routes;
|
||||||
|
|
||||||
|
use routes::{api::editor::Blocks, make_router};
|
||||||
|
use tantivy::{
|
||||||
|
directory::MmapDirectory,
|
||||||
|
doc,
|
||||||
|
schema::{Schema, STORED, STRING, TEXT},
|
||||||
|
Index, IndexWriter, TantivyDocument, Term,
|
||||||
|
};
|
||||||
|
use tower::Layer;
|
||||||
|
use tower_http::normalize_path::NormalizePathLayer;
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
env,
|
||||||
|
fs::{self, File},
|
||||||
|
io::{Read, Write},
|
||||||
|
path::PathBuf,
|
||||||
|
str::FromStr,
|
||||||
|
sync::Arc,
|
||||||
|
sync::Mutex,
|
||||||
|
};
|
||||||
|
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use axum::{
|
||||||
|
extract::{FromRef, Request},
|
||||||
|
Router, ServiceExt,
|
||||||
|
};
|
||||||
|
use axum_extra::extract::cookie::Key;
|
||||||
|
use blogdb::BlogDb;
|
||||||
|
use glob::glob;
|
||||||
|
|
||||||
|
use crate::html;
|
||||||
|
|
||||||
|
const DB_URL: &str = "sqlite://evie.db";
|
||||||
|
|
||||||
|
pub struct Blog {
|
||||||
|
app: Router,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Blog {
|
||||||
|
pub async fn new() -> anyhow::Result<Self> {
|
||||||
|
let state = BlogState::new().await?;
|
||||||
|
let app = make_router(state).await;
|
||||||
|
Ok(Self { app })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn serve(self, port: Option<u16>) {
|
||||||
|
let port = port.unwrap_or(3000);
|
||||||
|
let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{port}"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
axum::serve(
|
||||||
|
listener,
|
||||||
|
ServiceExt::<Request>::into_make_service(
|
||||||
|
NormalizePathLayer::trim_trailing_slash().layer(self.app),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct BlogState(Arc<BlogStateInner>);
|
||||||
|
|
||||||
|
impl BlogState {
|
||||||
|
async fn new() -> anyhow::Result<Self> {
|
||||||
|
Ok(BlogState(Arc::new(BlogStateInner::new().await?)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn db(&self) -> &BlogDb {
|
||||||
|
&self.0.db
|
||||||
|
}
|
||||||
|
|
||||||
|
fn domain(&self) -> String {
|
||||||
|
self.0.domain.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index(&self) -> &Index {
|
||||||
|
&self.0.index
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index_add_document(&self, document: TantivyDocument) {
|
||||||
|
let _ = self.0.index_writer.lock().unwrap().add_document(document);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index_delete(&self, term: Term) {
|
||||||
|
let _ = self.0.index_writer.lock().unwrap().delete_term(term);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index_commit(&self) {
|
||||||
|
let _ = self.0.index_writer.lock().unwrap().commit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// this impl tells `SignedCookieJar` how to access the key from our state
|
||||||
|
impl FromRef<BlogState> for Key {
|
||||||
|
fn from_ref(state: &BlogState) -> Self {
|
||||||
|
state.0.key.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BlogStateInner {
|
||||||
|
db: BlogDb,
|
||||||
|
key: Key,
|
||||||
|
domain: String,
|
||||||
|
index: Index,
|
||||||
|
index_writer: Mutex<IndexWriter>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BlogStateInner {
|
||||||
|
async fn new() -> anyhow::Result<Self> {
|
||||||
|
let username = env!("INIT_USER_NAME").to_string();
|
||||||
|
let password = env!("INIT_USER_PASSWORD").to_string();
|
||||||
|
let domain = env!("DOMAIN").to_string();
|
||||||
|
let url_as_path = DB_URL.strip_prefix("sqlite://").unwrap_or_default();
|
||||||
|
if File::open(url_as_path).is_err() {
|
||||||
|
let _ = File::create_new(url_as_path);
|
||||||
|
}
|
||||||
|
let db = BlogDb::new(&username, password.clone(), DB_URL).await?;
|
||||||
|
Self::add_initial_assets(&db).await?;
|
||||||
|
Self::add_initial_pages(&db).await?;
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
{
|
||||||
|
Self::add_posts(&db).await?;
|
||||||
|
Self::add_drafts(&username, password, &db).await?;
|
||||||
|
}
|
||||||
|
let key = Self::key()?;
|
||||||
|
let (index, index_writer) = Self::generate_index(&db).await?;
|
||||||
|
Ok(Self {
|
||||||
|
db,
|
||||||
|
key,
|
||||||
|
domain,
|
||||||
|
index,
|
||||||
|
index_writer: Mutex::new(index_writer),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_initial_pages(db: &BlogDb) -> anyhow::Result<()> {
|
||||||
|
let pages = vec![
|
||||||
|
("index".to_string(), html::home_page(db).await),
|
||||||
|
("about".to_string(), html::about_page(db).await),
|
||||||
|
];
|
||||||
|
db.add_pages(pages).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
const ASSETS_DIR: &'static str = concat!(env!("CARGO_MANIFEST_DIR"), "/src/client/assets");
|
||||||
|
|
||||||
|
async fn add_initial_assets(db: &BlogDb) -> anyhow::Result<()> {
|
||||||
|
let pattern = [Self::ASSETS_DIR, "/*/*"].concat();
|
||||||
|
let paths = glob(&pattern).unwrap();
|
||||||
|
let mut assets = vec![];
|
||||||
|
for path in paths.into_iter() {
|
||||||
|
match path {
|
||||||
|
Ok(path) => {
|
||||||
|
let slug_path = path.clone();
|
||||||
|
let slug: String = slug_path
|
||||||
|
.strip_prefix(Self::ASSETS_DIR)?
|
||||||
|
.as_os_str()
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.into(); // eek
|
||||||
|
let mut file = File::open(path.clone())?;
|
||||||
|
let mut data = vec![];
|
||||||
|
file.read_to_end(&mut data).unwrap();
|
||||||
|
assets.push((slug, data));
|
||||||
|
}
|
||||||
|
Err(_) => todo!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.add_assets(assets).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
const INDEX_PATH: &'static str = concat!(env!("CARGO_MANIFEST_DIR"), "/index");
|
||||||
|
const MEMORY_BUDGET: usize = 50_000_000;
|
||||||
|
|
||||||
|
async fn generate_index(db: &BlogDb) -> anyhow::Result<(Index, IndexWriter)> {
|
||||||
|
let mut schema_builder = Schema::builder();
|
||||||
|
let id = schema_builder.add_text_field("id", STRING | STORED);
|
||||||
|
let title = schema_builder.add_text_field("title", TEXT | STORED);
|
||||||
|
let body = schema_builder.add_text_field("body", TEXT | STORED);
|
||||||
|
let schema = schema_builder.build();
|
||||||
|
let _ = fs::remove_dir_all(Self::INDEX_PATH);
|
||||||
|
let _ = fs::create_dir_all(Self::INDEX_PATH);
|
||||||
|
let dir = MmapDirectory::open(Self::INDEX_PATH).unwrap();
|
||||||
|
let index = Index::open_or_create(dir, schema.clone())?;
|
||||||
|
let mut index_writer: IndexWriter = index.writer(Self::MEMORY_BUDGET)?;
|
||||||
|
let posts = db.get_posts().await?;
|
||||||
|
for post in posts.into_iter() {
|
||||||
|
let content: Blocks = serde_json::from_str(&post.content).unwrap_or_default();
|
||||||
|
index_writer.add_document(
|
||||||
|
doc!(id => post.id, title => post.title, body => content.to_plaintext()),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
index_writer.commit()?;
|
||||||
|
Ok((index, index_writer))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
fn key() -> anyhow::Result<Key> {
|
||||||
|
let key = Key::try_generate().ok_or(anyhow!("Couldn't generate key for cookie signing"))?;
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
fn key() -> anyhow::Result<Key> {
|
||||||
|
let key_path = PathBuf::from_str("./key")?;
|
||||||
|
let key_file = File::options().read(true).write(true).open(&key_path);
|
||||||
|
let key = match key_file {
|
||||||
|
Ok(mut file) => {
|
||||||
|
let mut bytes = vec![];
|
||||||
|
file.read_to_end(&mut bytes)?;
|
||||||
|
match Key::try_from(bytes.as_slice()) {
|
||||||
|
Ok(key) => key,
|
||||||
|
Err(error) => {
|
||||||
|
println!("need to make a new key, couldn't get from file cause {error:?}");
|
||||||
|
let key = Key::try_generate()
|
||||||
|
.ok_or(anyhow!("Couldn't generate key for cookie signing"))?;
|
||||||
|
file.write_all(key.master())?;
|
||||||
|
println!("made one");
|
||||||
|
key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
println!("need to make a new key, couldn't open the key file cause {error:?}");
|
||||||
|
let key = Key::try_generate()
|
||||||
|
.ok_or(anyhow!("Couldn't generate key for cookie signing"))?;
|
||||||
|
|
||||||
|
let mut key_file = File::create(&key_path)?;
|
||||||
|
key_file.write_all(key.master())?;
|
||||||
|
println!("made one");
|
||||||
|
key
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
async fn add_posts(db: &BlogDb) -> anyhow::Result<()> {
|
||||||
|
let content = r#"
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "26lQiP26Dd",
|
||||||
|
"type": "header",
|
||||||
|
"data": {
|
||||||
|
"text": "Example Post",
|
||||||
|
"level": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "PnWeuvNW6O",
|
||||||
|
"type": "header",
|
||||||
|
"data": {
|
||||||
|
"text": "This is a subheading",
|
||||||
|
"level": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "xSV-1St5Nh",
|
||||||
|
"type": "warning",
|
||||||
|
"data": {
|
||||||
|
"title": "This is a warning...",
|
||||||
|
"message": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rfnEtkKIjP",
|
||||||
|
"type": "quote",
|
||||||
|
"data": {
|
||||||
|
"text": "this is a quote<br>",
|
||||||
|
"caption": "",
|
||||||
|
"alignment": "left"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "6lQRMa2guf",
|
||||||
|
"type": "paragraph",
|
||||||
|
"data": {
|
||||||
|
"text": "This is some content<br>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
"#;
|
||||||
|
if db.get_posts().await?.is_empty() {
|
||||||
|
db.add_post("Example Post", "", content).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
async fn add_drafts(username: &str, password: String, db: &BlogDb) -> anyhow::Result<()> {
|
||||||
|
let content = r#"
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "26lQiP26Dd",
|
||||||
|
"type": "header",
|
||||||
|
"data": {
|
||||||
|
"text": "Example Draft",
|
||||||
|
"level": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "PnWeuvNW6O",
|
||||||
|
"type": "header",
|
||||||
|
"data": {
|
||||||
|
"text": "This is a subheading",
|
||||||
|
"level": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "xSV-1St5Nh",
|
||||||
|
"type": "warning",
|
||||||
|
"data": {
|
||||||
|
"title": "This is a warning...",
|
||||||
|
"message": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rfnEtkKIjP",
|
||||||
|
"type": "quote",
|
||||||
|
"data": {
|
||||||
|
"text": "this is a quote<br>",
|
||||||
|
"caption": "",
|
||||||
|
"alignment": "left"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "6lQRMa2guf",
|
||||||
|
"type": "paragraph",
|
||||||
|
"data": {
|
||||||
|
"text": "This is some content<br>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
"#;
|
||||||
|
let session_id = db.new_session(username, password, "+1 year").await?;
|
||||||
|
if db.get_drafts(session_id).await?.is_empty() {
|
||||||
|
db.add_draft("Example Draft", "", content).await?;
|
||||||
|
}
|
||||||
|
db.end_session(session_id).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,97 @@
|
||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
http::{header::LOCATION, HeaderMap, HeaderValue, StatusCode},
|
||||||
|
response::{Html, IntoResponse, Response},
|
||||||
|
routing::post,
|
||||||
|
Form, Router,
|
||||||
|
};
|
||||||
|
use axum_extra::extract::{cookie::Cookie, PrivateCookieJar};
|
||||||
|
use blogdb::BlogDb;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::to_string;
|
||||||
|
|
||||||
|
use crate::{html, BlogState};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct LoginCredentials {
|
||||||
|
username: Option<String>,
|
||||||
|
password: Option<String>,
|
||||||
|
remember: Option<String>,
|
||||||
|
next: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn auth(State(state): State<BlogState>) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/login", post(login))
|
||||||
|
.route("/logout", post(logout))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login(
|
||||||
|
mut jar: PrivateCookieJar,
|
||||||
|
State(state): State<BlogState>,
|
||||||
|
Form(body): Form<LoginCredentials>,
|
||||||
|
) -> Response {
|
||||||
|
let db = &state.db();
|
||||||
|
let next = match body.next.unwrap_or("/".to_string()).as_str() {
|
||||||
|
"/" => "/".to_string(),
|
||||||
|
next => ["/", next].concat(),
|
||||||
|
};
|
||||||
|
let username = match body.username {
|
||||||
|
Some(username) => username,
|
||||||
|
None => return bad_login(&next).await,
|
||||||
|
};
|
||||||
|
let password = match body.password {
|
||||||
|
Some(password) => password,
|
||||||
|
None => return bad_login(&next).await,
|
||||||
|
};
|
||||||
|
let expiration = if body.remember.is_some() {
|
||||||
|
"+1 year"
|
||||||
|
} else {
|
||||||
|
"+0 day"
|
||||||
|
};
|
||||||
|
match db.new_session(username, password, expiration).await {
|
||||||
|
Ok(id) => {
|
||||||
|
jar = jar.add(
|
||||||
|
Cookie::build(("id", id.to_string()))
|
||||||
|
.domain(state.domain())
|
||||||
|
.secure(true)
|
||||||
|
.http_only(true)
|
||||||
|
.path("/"),
|
||||||
|
);
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(LOCATION, HeaderValue::from_str(&next).unwrap());
|
||||||
|
(StatusCode::FOUND, headers, jar).into_response()
|
||||||
|
}
|
||||||
|
Err(_) => bad_login(&next).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn bad_login(next: &str) -> Response {
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Html(html::login_status(next, false)),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn logout(jar: PrivateCookieJar, State(state): State<BlogState>) -> Response {
|
||||||
|
let db = &state.db();
|
||||||
|
let session_id: i64 = if let Some(cookie) = jar.get("id") {
|
||||||
|
match cookie.value().parse() {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => return StatusCode::BAD_REQUEST.into_response(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return StatusCode::BAD_REQUEST.into_response();
|
||||||
|
};
|
||||||
|
match db.end_session(session_id).await {
|
||||||
|
Ok(_) => {
|
||||||
|
let jar = jar.remove("id");
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(LOCATION, HeaderValue::from_str("/").unwrap());
|
||||||
|
(StatusCode::FOUND, headers, jar).into_response()
|
||||||
|
}
|
||||||
|
Err(_) => StatusCode::BAD_REQUEST.into_response(),
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,267 @@
|
||||||
|
use axum::{
|
||||||
|
extract::{Json, Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::post,
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use axum_extra::extract::PrivateCookieJar;
|
||||||
|
use blogdb::BlogDb;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
html::{self, about_page, home_page, remove_el},
|
||||||
|
BlogState,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(tag = "type", content = "data")]
|
||||||
|
#[allow(non_camel_case_types)]
|
||||||
|
pub(crate) enum Block {
|
||||||
|
paragraph {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
header {
|
||||||
|
text: String,
|
||||||
|
level: usize,
|
||||||
|
},
|
||||||
|
list {
|
||||||
|
style: ListStyle,
|
||||||
|
items: Vec<String>,
|
||||||
|
},
|
||||||
|
warning {
|
||||||
|
title: String,
|
||||||
|
},
|
||||||
|
quote {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
embed {
|
||||||
|
service: String,
|
||||||
|
source: String,
|
||||||
|
embed: String,
|
||||||
|
width: usize,
|
||||||
|
height: usize,
|
||||||
|
},
|
||||||
|
image {
|
||||||
|
file: ImageSource,
|
||||||
|
#[serde(default)]
|
||||||
|
caption: String,
|
||||||
|
},
|
||||||
|
delimiter {},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
#[allow(non_camel_case_types)]
|
||||||
|
pub struct ImageSource {
|
||||||
|
url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
#[allow(non_camel_case_types)]
|
||||||
|
pub enum ListStyle {
|
||||||
|
ordered,
|
||||||
|
unordered,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Block {
|
||||||
|
fn to_html(&self) -> String {
|
||||||
|
let text = match self {
|
||||||
|
Block::paragraph { text } => ["<p>", text, "</p>"].concat(),
|
||||||
|
Block::header { text, level } => {
|
||||||
|
let level = level.to_string();
|
||||||
|
["<h", &level, ">", text, "</h", &level, ">"].concat()
|
||||||
|
}
|
||||||
|
Block::list { style, items } => match style {
|
||||||
|
ListStyle::ordered => ["<ol><li>", &items.join("</li><li>"), "</li></ol>"].concat(),
|
||||||
|
ListStyle::unordered => {
|
||||||
|
["<ul><li>", &items.join("</li><li>"), "</li></ul>"].concat()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Block::warning { title } => ["<aside>", title, "</aside>"].concat(),
|
||||||
|
Block::quote { text } => ["<blockquote>", text, "</blockquote>"].concat(),
|
||||||
|
Block::embed {
|
||||||
|
embed,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
..
|
||||||
|
} => [
|
||||||
|
"<iframe width=\"",
|
||||||
|
&width.to_string(),
|
||||||
|
"\" height=\"",
|
||||||
|
&height.to_string(),
|
||||||
|
"\" src=\"",
|
||||||
|
embed,
|
||||||
|
"\"></iframe>",
|
||||||
|
]
|
||||||
|
.concat(),
|
||||||
|
Block::image { file, caption } => {
|
||||||
|
["<img src=\"", &file.url, "\" alt=\"", caption, "\"/>"].concat()
|
||||||
|
}
|
||||||
|
Block::delimiter {} => "<div style=\"inline-size: 100%; block-size: 1px; background: var(--color-text)\"></div>".to_string(),
|
||||||
|
|
||||||
|
};
|
||||||
|
html::animate_anchors(html::remove_el(&text, "br"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_plaintext(&self) -> String {
|
||||||
|
let text = match self {
|
||||||
|
Block::paragraph { text } => text,
|
||||||
|
Block::header { text, level: _ } => text,
|
||||||
|
Block::list { style: _, items } => &items.join("\n"),
|
||||||
|
Block::warning { title } => title,
|
||||||
|
Block::quote { text } => text,
|
||||||
|
_ => &"".to_string(),
|
||||||
|
};
|
||||||
|
let text = [text, "\n"].concat();
|
||||||
|
remove_el(&text, "br")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn editor(State(state): State<BlogState>) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/update/:id", post(update))
|
||||||
|
.route("/update/about", post(update_about))
|
||||||
|
.route("/update/home", post(update_homepage))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn check_id(
|
||||||
|
jar: PrivateCookieJar,
|
||||||
|
db: &BlogDb,
|
||||||
|
) -> Result<(i64, String), Response> {
|
||||||
|
match jar.get("id") {
|
||||||
|
Some(id) => match id.value_trimmed().parse::<i64>() {
|
||||||
|
Ok(id) => match db.check_session(id).await {
|
||||||
|
Ok(user) => Ok((id, user)),
|
||||||
|
Err(_) => Err(StatusCode::UNAUTHORIZED.into_response()),
|
||||||
|
},
|
||||||
|
Err(_) => Err(StatusCode::BAD_REQUEST.into_response()),
|
||||||
|
},
|
||||||
|
None => Err(StatusCode::UNAUTHORIZED.into_response()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update(
|
||||||
|
jar: PrivateCookieJar,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
State(state): State<BlogState>,
|
||||||
|
Json(content): Json<Blocks>,
|
||||||
|
) -> Response {
|
||||||
|
let db = state.db();
|
||||||
|
|
||||||
|
if let Err(response) = check_id(jar, db).await {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = content.title();
|
||||||
|
let _ = db.update_draft_title(&id, title).await;
|
||||||
|
|
||||||
|
let _ = db
|
||||||
|
.update_draft_content(&id, serde_json::to_string(&content).unwrap())
|
||||||
|
.await;
|
||||||
|
|
||||||
|
StatusCode::OK.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_about(
|
||||||
|
jar: PrivateCookieJar,
|
||||||
|
State(state): State<BlogState>,
|
||||||
|
Json(content): Json<Blocks>,
|
||||||
|
) -> Response {
|
||||||
|
let db = state.db();
|
||||||
|
|
||||||
|
let (id, user) = match check_id(jar, db).await {
|
||||||
|
Ok(info) => info,
|
||||||
|
Err(response) => return response,
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = db
|
||||||
|
.update_user_about(&user, serde_json::to_string(&content).unwrap(), id)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let _ = db.update_page("about", about_page(db).await).await;
|
||||||
|
StatusCode::OK.into_response()
|
||||||
|
}
|
||||||
|
async fn update_homepage(
|
||||||
|
jar: PrivateCookieJar,
|
||||||
|
State(state): State<BlogState>,
|
||||||
|
Json(content): Json<Blocks>,
|
||||||
|
) -> Response {
|
||||||
|
let db = state.db();
|
||||||
|
|
||||||
|
let (id, user) = match check_id(jar, db).await {
|
||||||
|
Ok(info) => info,
|
||||||
|
Err(response) => return response,
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = db
|
||||||
|
.update_user_homepage(&user, serde_json::to_string(&content).unwrap(), id)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let _ = db.update_page("index", home_page(db).await).await;
|
||||||
|
StatusCode::OK.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Default)]
|
||||||
|
pub(crate) struct Blocks(Vec<Block>);
|
||||||
|
|
||||||
|
impl Blocks {
|
||||||
|
pub(crate) fn to_html(&self) -> String {
|
||||||
|
let mut html = String::new();
|
||||||
|
for block in self.0.iter() {
|
||||||
|
html.push_str(&block.to_html());
|
||||||
|
}
|
||||||
|
html
|
||||||
|
}
|
||||||
|
pub(crate) fn to_plaintext(&self) -> String {
|
||||||
|
let mut text = String::new();
|
||||||
|
for block in self.0.iter() {
|
||||||
|
text.push_str(&block.to_plaintext());
|
||||||
|
}
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn first(&self) -> Option<&Block> {
|
||||||
|
self.0.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn title(&self) -> &str {
|
||||||
|
if let Some(Block::header { text, level: 1 }) = self.first() {
|
||||||
|
text
|
||||||
|
} else {
|
||||||
|
"Untitled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn desc(&self) -> String {
|
||||||
|
html::sanitize(
|
||||||
|
self.0
|
||||||
|
.iter()
|
||||||
|
.find(|block| match block {
|
||||||
|
Block::paragraph { text: _ } => true,
|
||||||
|
Block::header { text: _, level } => *level > 1,
|
||||||
|
Block::warning { title: _ } => true,
|
||||||
|
Block::quote { text: _ } => true,
|
||||||
|
_ => false,
|
||||||
|
})
|
||||||
|
.map(|block| match block {
|
||||||
|
Block::paragraph { text } => text,
|
||||||
|
Block::header { text, level: _ } => text,
|
||||||
|
Block::warning { title } => title,
|
||||||
|
Block::quote { text } => text,
|
||||||
|
_ => "...",
|
||||||
|
})
|
||||||
|
.unwrap_or("No description"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
pub(crate) fn image(&self) -> Option<&str> {
|
||||||
|
self.0
|
||||||
|
.iter()
|
||||||
|
.find(|block| matches!(block, Block::image { .. }))
|
||||||
|
.map(|block| match block {
|
||||||
|
Block::image { file, .. } => Some(file.url.as_str()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,167 @@
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
handler::HandlerWithoutStateExt,
|
||||||
|
http::{header::LOCATION, HeaderMap, HeaderValue, StatusCode},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
|
||||||
|
use axum_extra::extract::{Form, PrivateCookieJar, Query};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tantivy::{
|
||||||
|
doc,
|
||||||
|
schema::{Schema, STORED, TEXT},
|
||||||
|
Term,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::BlogState;
|
||||||
|
|
||||||
|
mod auth;
|
||||||
|
pub(crate) mod editor;
|
||||||
|
use auth::auth;
|
||||||
|
use editor::{check_id, editor, Blocks};
|
||||||
|
|
||||||
|
pub(super) fn api(state: BlogState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/posts/:path", post(posts))
|
||||||
|
.route("/user/update", post(user_update))
|
||||||
|
.route("/drafts/:path", post(drafts))
|
||||||
|
.route("/search", get(search))
|
||||||
|
.with_state(state.clone())
|
||||||
|
.nest("/auth", auth(State(state.clone())))
|
||||||
|
.nest("/editor", editor(State(state.clone())))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Entries {
|
||||||
|
#[serde(default)]
|
||||||
|
item: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[allow(non_camel_case_types)]
|
||||||
|
enum PostsEndpoints {
|
||||||
|
delete,
|
||||||
|
unpublish,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn posts(
|
||||||
|
Path(path): Path<PostsEndpoints>,
|
||||||
|
State(state): State<BlogState>,
|
||||||
|
Form(data): Form<Entries>,
|
||||||
|
) -> Response {
|
||||||
|
let db = state.db();
|
||||||
|
let mut schema_builder = Schema::builder();
|
||||||
|
let id = schema_builder.add_text_field("id", TEXT | STORED);
|
||||||
|
match path {
|
||||||
|
PostsEndpoints::delete => {
|
||||||
|
for post_id in data.item.iter() {
|
||||||
|
let _ = db.delete_post(post_id).await;
|
||||||
|
let term = Term::from_field_text(id, post_id);
|
||||||
|
state.index_delete(term);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PostsEndpoints::unpublish => {
|
||||||
|
for post_id in data.item.iter() {
|
||||||
|
let _ = db.edit_post(post_id).await;
|
||||||
|
let _ = db.delete_post(post_id).await;
|
||||||
|
let term = Term::from_field_text(id, post_id);
|
||||||
|
state.index_delete(term);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.index_commit();
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(LOCATION, HeaderValue::from_str("/admin").unwrap());
|
||||||
|
(StatusCode::FOUND, headers).into_response()
|
||||||
|
}
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[allow(non_camel_case_types)]
|
||||||
|
enum DraftsEndpoints {
|
||||||
|
delete,
|
||||||
|
publish,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn drafts(
|
||||||
|
Path(path): Path<DraftsEndpoints>,
|
||||||
|
State(state): State<BlogState>,
|
||||||
|
Form(data): Form<Entries>,
|
||||||
|
) -> Response {
|
||||||
|
let db = state.db();
|
||||||
|
match path {
|
||||||
|
DraftsEndpoints::delete => {
|
||||||
|
for id in data.item.iter() {
|
||||||
|
let _result = db.delete_draft(id).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DraftsEndpoints::publish => {
|
||||||
|
for id in data.item.iter() {
|
||||||
|
match db.publish_draft(id).await {
|
||||||
|
Ok(post) => {
|
||||||
|
let mut schema_builder = Schema::builder();
|
||||||
|
let id = schema_builder.add_text_field("id", TEXT | STORED);
|
||||||
|
let body = schema_builder.add_text_field("body", TEXT | STORED);
|
||||||
|
let title = schema_builder.add_text_field("title", TEXT | STORED);
|
||||||
|
let content: Blocks =
|
||||||
|
serde_json::from_str(&post.content).unwrap_or_default();
|
||||||
|
state.index_add_document(doc!(id => post.id, body => content.to_plaintext(), title => post.title));
|
||||||
|
}
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
state.index_commit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(LOCATION, HeaderValue::from_str("/admin").unwrap());
|
||||||
|
(StatusCode::FOUND, headers).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct SearchQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
query: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search(Query(query): Query<SearchQuery>) -> Response {
|
||||||
|
let query = query
|
||||||
|
.query
|
||||||
|
.join(" ")
|
||||||
|
.split(" ")
|
||||||
|
.collect::<Vec<&str>>()
|
||||||
|
.join("-");
|
||||||
|
let url = ["/search/", &query].concat();
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(LOCATION, HeaderValue::from_str(&url).unwrap());
|
||||||
|
(StatusCode::FOUND, headers).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct UpdateUserInfo {
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn user_update(
|
||||||
|
jar: PrivateCookieJar,
|
||||||
|
State(state): State<BlogState>,
|
||||||
|
Form(info): Form<UpdateUserInfo>,
|
||||||
|
) -> Response {
|
||||||
|
let db = state.db();
|
||||||
|
let (id, user) = match check_id(jar, db).await {
|
||||||
|
Ok(creds) => creds,
|
||||||
|
Err(resp) => return resp,
|
||||||
|
};
|
||||||
|
if info.username != user {
|
||||||
|
let _ = db.update_user_name(&user, &info.username, id).await;
|
||||||
|
let _ = db.update_password(user, info.password, id).await;
|
||||||
|
} else {
|
||||||
|
let _ = db.update_password(user, info.password, id).await;
|
||||||
|
}
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(LOCATION, HeaderValue::from_str("/admin").unwrap());
|
||||||
|
(StatusCode::FOUND, headers).into_response()
|
||||||
|
}
|
|
@ -0,0 +1,76 @@
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::{header::LOCATION, HeaderMap, HeaderValue, StatusCode},
|
||||||
|
response::{Html, IntoResponse, Response},
|
||||||
|
routing::get,
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use axum_extra::extract::PrivateCookieJar;
|
||||||
|
|
||||||
|
use crate::{html, BlogState};
|
||||||
|
|
||||||
|
pub(super) fn login(State(state): State<BlogState>) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(login_page))
|
||||||
|
.route("/*next", get(login_next))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login_next(
|
||||||
|
jar: PrivateCookieJar,
|
||||||
|
Path(path): Path<String>,
|
||||||
|
State(state): State<BlogState>,
|
||||||
|
) -> Response {
|
||||||
|
let db = state.db();
|
||||||
|
match jar.get("id") {
|
||||||
|
Some(id) => match id.value_trimmed().parse::<i64>() {
|
||||||
|
Ok(id) => {
|
||||||
|
if db.check_session(id).await.is_ok() {
|
||||||
|
return go_next(path).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
{};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
{};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Html(html::login_status(&path, true)).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login_page(jar: PrivateCookieJar, State(state): State<BlogState>) -> Response {
|
||||||
|
let db = state.db();
|
||||||
|
match jar.get("id") {
|
||||||
|
Some(id) => match id.value_trimmed().parse::<i64>() {
|
||||||
|
Ok(id) => {
|
||||||
|
if db.check_session(id).await.is_ok() {
|
||||||
|
return go_next("/".to_string()).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
{};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
{};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Html(html::login_status("/", true)).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn go_next(path: String) -> Response {
|
||||||
|
let next = path.as_str();
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
let value = match HeaderValue::from_str(next) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_error) => {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.append(LOCATION, "/".parse().unwrap());
|
||||||
|
return (StatusCode::FOUND, headers).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
headers.append(LOCATION, value);
|
||||||
|
(StatusCode::FOUND, headers).into_response()
|
||||||
|
}
|
|
@ -0,0 +1,384 @@
|
||||||
|
pub(crate) mod api;
|
||||||
|
mod login;
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
use api::{api, editor::check_id};
|
||||||
|
use axum_extra::{extract::PrivateCookieJar, response::Html};
|
||||||
|
use login::login;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
extract::{rejection::PathRejection, DefaultBodyLimit, Multipart, Path, State},
|
||||||
|
http::{
|
||||||
|
header::{CACHE_CONTROL, CONTENT_TYPE, EXPIRES, LOCATION, PRAGMA},
|
||||||
|
HeaderMap, HeaderValue, StatusCode,
|
||||||
|
},
|
||||||
|
middleware::from_fn_with_state,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tantivy::{
|
||||||
|
collector::TopDocs,
|
||||||
|
query::QueryParser,
|
||||||
|
schema::{Schema, STRING, TEXT},
|
||||||
|
DocAddress, Document, Score, TantivyDocument,
|
||||||
|
};
|
||||||
|
use tokio_util::io::ReaderStream;
|
||||||
|
use tower_http::{limit::RequestBodyLimitLayer, trace::TraceLayer};
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
html::{
|
||||||
|
self, about_editor, admin_page, draft_editor, homepage_editor, search_page,
|
||||||
|
search_page_no_query,
|
||||||
|
},
|
||||||
|
BlogState,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::middleware::logged_in;
|
||||||
|
|
||||||
|
pub(super) async fn make_router(state: BlogState) -> Router {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||||
|
// axum logs rejections from built-in extractors with the `axum::rejection`
|
||||||
|
// target, at `TRACE` level. `axum::rejection=trace` enables showing those events
|
||||||
|
format!(
|
||||||
|
"{}=debug,tower_http=debug,axum::rejection=trace",
|
||||||
|
env!("CARGO_CRATE_NAME")
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.with(tracing_subscriber::fmt::layer())
|
||||||
|
.init();
|
||||||
|
let router = Router::new()
|
||||||
|
.merge(protected_routes(state.clone()).await)
|
||||||
|
.merge(blog(State(state.clone())))
|
||||||
|
.route("/assets/*asset", get(asset).with_state(state.clone()))
|
||||||
|
.nest("/api", api(state.clone()))
|
||||||
|
.nest("/login", login(State(state.clone())))
|
||||||
|
.route("/", get(pages).with_state(state.clone()))
|
||||||
|
.route("/*path", get(pages).with_state(state.clone()))
|
||||||
|
.route("/search", get(search_empty))
|
||||||
|
.route("/search/*query", get(search).with_state(state.clone()))
|
||||||
|
.fallback(get(handle_404))
|
||||||
|
.layer(DefaultBodyLimit::disable())
|
||||||
|
.layer(RequestBodyLimitLayer::new(250 * 1024 * 1024));
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
let router = router.layer(TraceLayer::new_for_http());
|
||||||
|
router
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn protected_routes(state: BlogState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/admin", get(admin).with_state(state.clone()))
|
||||||
|
.route("/editor/:id", get(editor).with_state(state.clone()))
|
||||||
|
.route(
|
||||||
|
"/editor/uploadFile",
|
||||||
|
post(editor_upload_file).with_state(state.clone()),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/editor/fetchUrl",
|
||||||
|
post(editor_fetch_url).with_state(state.clone()),
|
||||||
|
)
|
||||||
|
.route_layer(from_fn_with_state(state.clone(), logged_in))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pages(
|
||||||
|
path: Result<Path<String>, PathRejection>,
|
||||||
|
State(state): State<BlogState>,
|
||||||
|
) -> Response {
|
||||||
|
match path {
|
||||||
|
Ok(path) => match state.db().get_page(path.0).await {
|
||||||
|
Ok(page) => Html(page).into_response(),
|
||||||
|
Err(_) => four_oh_four(),
|
||||||
|
},
|
||||||
|
Err(error) => match error {
|
||||||
|
PathRejection::FailedToDeserializePathParams(_) => {
|
||||||
|
match state.db().get_page("/").await {
|
||||||
|
Ok(page) => Html(page).into_response(),
|
||||||
|
Err(_) => four_oh_four(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => four_oh_four(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn blog(State(state): State<BlogState>) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/blog", get(blog_root))
|
||||||
|
.route("/blog/*path", get(blog_post))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn blog_root(State(state): State<BlogState>) -> Response {
|
||||||
|
let db = state.db();
|
||||||
|
let page = html::blog_roll(db).await;
|
||||||
|
Html(page).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn blog_post(Path(id): Path<String>, State(state): State<BlogState>) -> Response {
|
||||||
|
let db = state.db();
|
||||||
|
match db.get_post(id).await {
|
||||||
|
Ok(post) => {
|
||||||
|
let html = html::blog_page(post.id, db).await;
|
||||||
|
Html(html).into_response()
|
||||||
|
}
|
||||||
|
Err(_) => four_oh_four(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn four_oh_four() -> Response {
|
||||||
|
(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Html(html::make_404("Page not found!")),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn editor(
|
||||||
|
jar: PrivateCookieJar,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
State(state): State<BlogState>,
|
||||||
|
) -> Response {
|
||||||
|
let db = state.db();
|
||||||
|
match id.as_str() {
|
||||||
|
"new" => match db.add_draft("Untitled", "", "").await {
|
||||||
|
Ok(draft) => {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
LOCATION,
|
||||||
|
HeaderValue::from_str(&format!("/editor/{}", draft.id)).unwrap(),
|
||||||
|
);
|
||||||
|
(StatusCode::FOUND, headers).into_response()
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(LOCATION, HeaderValue::from_str("/admin").unwrap());
|
||||||
|
(StatusCode::FOUND, headers).into_response()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"about" => {
|
||||||
|
let (_id, user) = match check_id(jar, db).await {
|
||||||
|
Ok(info) => info,
|
||||||
|
Err(response) => return response,
|
||||||
|
};
|
||||||
|
let about_content = db.get_user_info(user).await.unwrap().about;
|
||||||
|
let page = about_editor(&about_content);
|
||||||
|
Html(page).into_response()
|
||||||
|
}
|
||||||
|
"home" => {
|
||||||
|
let (_id, user) = match check_id(jar, db).await {
|
||||||
|
Ok(info) => info,
|
||||||
|
Err(response) => return response,
|
||||||
|
};
|
||||||
|
let home_content = db.get_user_info(user).await.unwrap().home;
|
||||||
|
let page = homepage_editor(&home_content);
|
||||||
|
Html(page).into_response()
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let draft = match db.edit_post(id).await {
|
||||||
|
Ok(draft) => draft,
|
||||||
|
Err(_) => {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(LOCATION, HeaderValue::from_str("/editor/new").unwrap());
|
||||||
|
return (StatusCode::FOUND, headers).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let page = draft_editor(&draft.content, &draft.id.to_string());
|
||||||
|
Html(page).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct UploadResult {
|
||||||
|
success: usize,
|
||||||
|
file: Option<UrlUpload>,
|
||||||
|
}
|
||||||
|
impl UploadResult {
|
||||||
|
fn success(url: String) -> Self {
|
||||||
|
Self {
|
||||||
|
success: 1,
|
||||||
|
file: Some(UrlUpload { url }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn failure() -> Self {
|
||||||
|
Self {
|
||||||
|
success: 0,
|
||||||
|
file: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn editor_upload_file(
|
||||||
|
State(state): State<BlogState>,
|
||||||
|
mut fields: Multipart,
|
||||||
|
) -> Response {
|
||||||
|
let db = state.db();
|
||||||
|
let field = match fields.next_field().await {
|
||||||
|
Ok(option) => match option {
|
||||||
|
Some(field) => field,
|
||||||
|
None => {
|
||||||
|
return (StatusCode::BAD_REQUEST, Json(UploadResult::failure())).into_response()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(_) => return (StatusCode::BAD_REQUEST, Json(UploadResult::failure())).into_response(),
|
||||||
|
};
|
||||||
|
let name = match field.file_name() {
|
||||||
|
Some(name) => name.to_string(),
|
||||||
|
None => "".to_string(),
|
||||||
|
};
|
||||||
|
let data = match field.bytes().await {
|
||||||
|
Ok(data) => data,
|
||||||
|
Err(_) => return (StatusCode::BAD_REQUEST, Json(UploadResult::failure())).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
match db.add_asset(name, data).await {
|
||||||
|
Ok(asset) => {
|
||||||
|
let slug = ["/assets/", &asset.slug].concat();
|
||||||
|
Json(UploadResult::success(slug)).into_response()
|
||||||
|
}
|
||||||
|
Err(_error) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(UploadResult::failure()),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
pub struct UrlUpload {
|
||||||
|
url: String,
|
||||||
|
}
|
||||||
|
pub(crate) async fn editor_fetch_url(
|
||||||
|
// jar: PrivateCookieJar,
|
||||||
|
State(state): State<BlogState>,
|
||||||
|
Json(UrlUpload { url: _ }): Json<UrlUpload>,
|
||||||
|
) -> Response {
|
||||||
|
let _db = state.db();
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, Json(r#"{"success": 0}"#)).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn asset(Path(slug): Path<String>, State(state): State<BlogState>) -> Response {
|
||||||
|
let db = state.db();
|
||||||
|
match db.get_asset(slug).await {
|
||||||
|
Ok(asset) => {
|
||||||
|
async {
|
||||||
|
// i still don't quite know how to do async rust
|
||||||
|
let stream = ReaderStream::new(Cursor::new(asset.data));
|
||||||
|
let body = Body::from_stream(stream);
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.append(CONTENT_TYPE, HeaderValue::from_str(&asset.mime).unwrap());
|
||||||
|
(headers, body).into_response()
|
||||||
|
}
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_404() -> Response {
|
||||||
|
four_oh_four()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn admin(jar: PrivateCookieJar, State(state): State<BlogState>) -> Response {
|
||||||
|
let db = state.db();
|
||||||
|
// this can be unwrapped because our login middleware
|
||||||
|
// ensures the user has a valid id
|
||||||
|
let session_id = jar
|
||||||
|
.get("id")
|
||||||
|
.unwrap()
|
||||||
|
.value_trimmed()
|
||||||
|
.parse()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let page = admin_page(session_id, db).await;
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.append(
|
||||||
|
CACHE_CONTROL,
|
||||||
|
HeaderValue::from_str("no-cache, no-store, must-revalidate").unwrap(),
|
||||||
|
);
|
||||||
|
headers.append(PRAGMA, HeaderValue::from_str("no-cache").unwrap());
|
||||||
|
headers.append(EXPIRES, HeaderValue::from_str("0").unwrap());
|
||||||
|
(headers, Html(page)).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Default, Debug)]
|
||||||
|
pub(crate) struct SearchResponse(Vec<SearchResponseEntry>);
|
||||||
|
impl SearchResponse {
|
||||||
|
pub(crate) fn is_empty(&self) -> bool {
|
||||||
|
self.0.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoIterator for SearchResponse {
|
||||||
|
type IntoIter = <Vec<SearchResponseEntry> as IntoIterator>::IntoIter;
|
||||||
|
type Item = SearchResponseEntry;
|
||||||
|
|
||||||
|
fn into_iter(self) -> Self::IntoIter {
|
||||||
|
self.0.into_iter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub(crate) struct SearchResponseEntry {
|
||||||
|
// idk why tantivy makes these fields sequences
|
||||||
|
body: Vec<String>,
|
||||||
|
pub(crate) id: Vec<String>,
|
||||||
|
title: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search_empty() -> Response {
|
||||||
|
Html(search_page_no_query()).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search(Path(query): Path<String>, State(state): State<BlogState>) -> Response {
|
||||||
|
let query = query.split("-").collect::<Vec<&str>>().join(" ");
|
||||||
|
|
||||||
|
let closure_query = query.clone();
|
||||||
|
|
||||||
|
let closure_state = state.clone();
|
||||||
|
let response = tokio::task::spawn_blocking(move || {
|
||||||
|
let index = closure_state.index();
|
||||||
|
let reader = index.reader().unwrap();
|
||||||
|
let searcher = reader.searcher();
|
||||||
|
let mut schema_builder = Schema::builder();
|
||||||
|
let (id, title, body) = (
|
||||||
|
schema_builder.add_text_field("id", STRING),
|
||||||
|
schema_builder.add_text_field("title", TEXT),
|
||||||
|
schema_builder.add_text_field("body", TEXT),
|
||||||
|
);
|
||||||
|
let schema = schema_builder.build();
|
||||||
|
let query_parser = QueryParser::for_index(index, vec![id, title, body]);
|
||||||
|
let query = query_parser.parse_query(&closure_query).unwrap();
|
||||||
|
let top_docs: Vec<(Score, DocAddress)> =
|
||||||
|
searcher.search(&query, &TopDocs::with_limit(100)).unwrap();
|
||||||
|
let mut json = String::new();
|
||||||
|
json.push('[');
|
||||||
|
let mut docs_iter = top_docs.iter().peekable();
|
||||||
|
while let Some((_score, doc_address)) = docs_iter.next() {
|
||||||
|
let retrieved = searcher
|
||||||
|
.doc::<TantivyDocument>(*doc_address)
|
||||||
|
.unwrap_or_default();
|
||||||
|
json.push_str(&retrieved.to_json(&schema));
|
||||||
|
if docs_iter.peek().is_some() {
|
||||||
|
json.push(',');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
json.push(']');
|
||||||
|
// TODO: Deserialize directly to SearchResponseEntry
|
||||||
|
let result = serde_json::from_str::<SearchResponse>(&json);
|
||||||
|
result.unwrap_or_default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let db = state.db();
|
||||||
|
let html = search_page(query, response, db).await;
|
||||||
|
Html(html).into_response()
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
<li class="widget-entry">
|
||||||
|
<a class="entry-content" href="">
|
||||||
|
<h2></h2>
|
||||||
|
<p></p>
|
||||||
|
</a>
|
||||||
|
<input type="checkbox" name="item" value="">
|
||||||
|
</li>
|
|
@ -0,0 +1,14 @@
|
||||||
|
<div class="admin-widget admin-widget-drafts">
|
||||||
|
<div class="widget-header">
|
||||||
|
<h1>Drafts</h1>
|
||||||
|
<a class="button" href="/editor/new">New Draft</a>
|
||||||
|
</div>
|
||||||
|
<form method="post">
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="form-action" formaction="/api/drafts/delete">Delete drafts</button>
|
||||||
|
<button type="submit" class="form-action" formaction="/api/drafts/publish">Publish drafts</button>
|
||||||
|
</div>
|
||||||
|
<ul tabindex="-1">
|
||||||
|
</ul>
|
||||||
|
</form>
|
||||||
|
</div>
|
|
@ -0,0 +1,8 @@
|
||||||
|
<li class="widget-entry">
|
||||||
|
<a class="entry-content" href="">
|
||||||
|
<time></time>
|
||||||
|
<h2></h2>
|
||||||
|
<p></p>
|
||||||
|
</a>
|
||||||
|
<input type="checkbox" name="item" value="">
|
||||||
|
</li>
|
|
@ -0,0 +1,15 @@
|
||||||
|
<div class="admin-widget admin-widget-posts">
|
||||||
|
<div class="widget-header">
|
||||||
|
<h1>Published Posts</h1>
|
||||||
|
</div>
|
||||||
|
<form method="post">
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="form-action" type="submit" formaction="/api/posts/delete">Delete posts
|
||||||
|
</button>
|
||||||
|
<button class=" form-action" type="submit" formaction="/api/posts/unpublish">Unpublish Posts
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul tabindex="-1">
|
||||||
|
</ul>
|
||||||
|
</form>
|
||||||
|
</div>
|
|
@ -0,0 +1,16 @@
|
||||||
|
<div class="admin-widget admin-widget-user">
|
||||||
|
<form action="#user-info" method="get"><button>Update User Info</button></form>
|
||||||
|
<a class="button" href="/editor/home">Update Home Page</a>
|
||||||
|
<a class="button" href="/editor/about">Update About Page</a>
|
||||||
|
<form action="/api/auth/logout" method="post"><button type="submit">Logout</button></form>
|
||||||
|
<div id="user-info" class="admin-widget-user">
|
||||||
|
<form action="#" method="get"><button aria-label="close">✕</button></form>
|
||||||
|
<form action="/api/user/update" method="post" class="user-info-form">
|
||||||
|
<input type="text" name="username" placeholder="New Username" autocomplete="off" aria-label="New Username"
|
||||||
|
required />
|
||||||
|
<input type="password" name="password" placeholder="New Password" autocomplete="new-password"
|
||||||
|
aria-label="New Password" required />
|
||||||
|
<button type="submit">Update</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -0,0 +1,5 @@
|
||||||
|
<admin-widget type="user"></admin-widget>
|
||||||
|
<div class="blog-admin">
|
||||||
|
<admin-widget type="drafts"></admin-widget>
|
||||||
|
<admin-widget type="posts"></admin-widget>
|
||||||
|
</div>
|
|
@ -0,0 +1 @@
|
||||||
|
<time></time>
|
|
@ -0,0 +1,10 @@
|
||||||
|
<li class="blog-roll-entry">
|
||||||
|
<a href="">
|
||||||
|
<img src="" />
|
||||||
|
<div class="entry-content">
|
||||||
|
<time></time>
|
||||||
|
<h2></h2>
|
||||||
|
<p></p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
|
@ -0,0 +1,2 @@
|
||||||
|
<h1>Blog</h1>
|
||||||
|
<ul class="blog-roll"></ul>
|
|
@ -0,0 +1,16 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="/assets/css/style.css" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<site-header></site-header>
|
||||||
|
<main>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
|
@ -0,0 +1,4 @@
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title></title>
|
||||||
|
<link href="/assets/css/style.css" rel="stylesheet">
|
|
@ -0,0 +1,5 @@
|
||||||
|
<meta property="og:url" content="" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:title" content="" />
|
||||||
|
<meta property="og:description" content="" />
|
||||||
|
<meta property="og:image" content="" />
|
|
@ -0,0 +1,3 @@
|
||||||
|
<div class="login-status">
|
||||||
|
<span>Incorrect username or password. Please try again.</span>
|
||||||
|
</div>
|
|
@ -0,0 +1,23 @@
|
||||||
|
<div></div>
|
||||||
|
<main>
|
||||||
|
<div class="login-area">
|
||||||
|
<h1>Log In</h1>
|
||||||
|
<login-status></login-status>
|
||||||
|
<form method="POST" action="/api/auth/login">
|
||||||
|
<label>
|
||||||
|
Username
|
||||||
|
<input type="text" name="username" autocomplete="off" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input type="password" name="password" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="remember">
|
||||||
|
Remember me
|
||||||
|
</label>
|
||||||
|
<input type="hidden" name="next" value="">
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
|
@ -0,0 +1,7 @@
|
||||||
|
<main>
|
||||||
|
<div id="editorjs"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script id="editor"></script>
|
||||||
|
|
||||||
|
<script src="/assets/js/editor.js"></script>
|
|
@ -0,0 +1,15 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<site-header></site-header>
|
||||||
|
<main>
|
||||||
|
<h1></h1>
|
||||||
|
<ul></ul>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
|
@ -0,0 +1,19 @@
|
||||||
|
<header>
|
||||||
|
<a class="animated-link" href="/">
|
||||||
|
<h1>Evie Ippolito</h1>
|
||||||
|
</a>
|
||||||
|
<nav>
|
||||||
|
<a href="/blog" class="animated-link">blog</a>
|
||||||
|
<a href="/about" class="animated-link">about</a>
|
||||||
|
<form action="/api/search" method="get" role="search">
|
||||||
|
<input type="search" name="query" tabindex="0" autocomplete="off" autocapitalize="none" spellcheck="false"
|
||||||
|
required dir="auto" />
|
||||||
|
<button type="submit" aria-label="search" tabindex="-1">
|
||||||
|
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" height="1.5rem" width="1.5rem">
|
||||||
|
<path
|
||||||
|
d="M8.5 14a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11Zm4.936-1.27 4.418 4.416-.708.708-4.417-4.418a6.5 6.5 0 1 1 .707-.707Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</nav>
|
||||||
|
</header>
|
Loading…
Reference in New Issue