diff --git a/app/public/style.css b/app/public/style.css
index 293ca64..9c219c6 100644
--- a/app/public/style.css
+++ b/app/public/style.css
@@ -264,12 +264,19 @@ video {
display: flex;
}
-.dropdown .inner .button {
+.dropdown .inner .button,
+.dropdown .inner .title {
padding: var(--pad-3) var(--pad-4);
justify-content: flex-start;
width: 100%;
}
+.dropdown .inner .title {
+ font-weight: 500;
+ border-top: solid 1px var(--color-super-lowered);
+ margin-top: var(--pad-2);
+}
+
.dropdown:has(.inner.open) .button:nth-child(1):not(.inner *) {
background: var(--color-raised);
}
@@ -517,6 +524,18 @@ img {
vertical-align: middle;
}
+.img_sizer img {
+ width: 100%;
+ height: 100%;
+}
+
+.avatar {
+ --size: 18px;
+ width: var(--size);
+ height: var(--size);
+ aspect-ratio: 1 / 1;
+}
+
blockquote {
padding-left: 1rem;
border-left: solid 5px var(--color-green);
diff --git a/app/templates_src/claim.lisp b/app/templates_src/claim.lisp
new file mode 100644
index 0000000..bff9a83
--- /dev/null
+++ b/app/templates_src/claim.lisp
@@ -0,0 +1,39 @@
+(text "{% extends \"root.lisp\" %} {% block head %}")
+(title
+ (text "Create reclaim for \"{{ entry.slug }}\" - {{ name }}"))
+(link ("rel" "icon") ("href" "/public/favicon.svg"))
+(text "{% endblock %} {% block body %}")
+(div
+ ("class" "card container")
+ (h1 (text "{{ entry.slug }}"))
+ (p (text "Custom slug reclaims are handled through ") (b (text "{{ tetratto }}")) (text ". You'll need to have an account there to submit a claim request."))
+ (p (text "Please note that you are unlikely to receive a response unless your claim is accepted. Please do not submit additional requests for the same slug."))
+
+ (text "{% if metadata.tetratto_owner_username -%}")
+ ; contact owner text
+ (p (text "Since this entry is connected to a user, it is encouraged that you directly contact the owner of the entry instead. If that doesn't work, you can create a regular request."))
+ (text "{%- endif %}")
+
+ (p (text "Once you're ready, you can submit a claim using the button below."))
+ (hr)
+ (ul
+ (li (b (text "Requsted slug: ")) (text "{{ entry.slug }}"))
+ (li (b (text "Last updated: ")) (text "{{ entry.edited / 1000|int|date(format=\"%Y-%m-%d %H:%M\", timezone=\"Etc/UTC\") }} UTC")))
+ (hr)
+ (text "{% if claimable -%}")
+ (text "{% if metadata.tetratto_owner_username -%}")
+ ; contact owner button
+ (a
+ ("href" "{{ tetratto }}/mail/compose?receivers={{ tetratto_owner_username }}&subject=Reclaim%20for%20%22{{ entry.slug }}%22")
+ ("class" "button surface no_fill")
+ (text "{{ icon \"external-link\" }} Contact owner"))
+ (text "{%- endif %}")
+
+ (a
+ ("href" "{{ tetratto }}/mail/compose?receivers={{ tetratto_handler_account_username }}&subject=Reclaim%20for%20%22{{ entry.slug }}%22")
+ ("class" "button surface no_fill")
+ (text "{{ icon \"external-link\" }} Submit request"))
+ (text "{% else %}")
+ (span (text "This slug is currently not claimable as it was edited too recently. ") (a ("href" "/{{ entry.slug }}") ("class" "red") (text "Go back")))
+ (text "{%- endif %}"))
+(text "{% endblock %}")
diff --git a/app/templates_src/root.lisp b/app/templates_src/root.lisp
index b806505..12eb5b9 100644
--- a/app/templates_src/root.lisp
+++ b/app/templates_src/root.lisp
@@ -45,7 +45,8 @@
(a
("class" "button")
("href" "https://trisua.com/t/fluffle")
- (text "source"))))
+ (text "source"))
+ (text "{% block dropdown %}{% endblock %}")))
(a
("class" "button camo fade")
diff --git a/app/templates_src/view.lisp b/app/templates_src/view.lisp
index 40fc46f..5dff22c 100644
--- a/app/templates_src/view.lisp
+++ b/app/templates_src/view.lisp
@@ -12,7 +12,6 @@
(text "{% if metadata.page_icon|length == 0 -%}")
(link ("rel" "icon") ("href" "/public/favicon.svg"))
(text "{%- endif %}")
-
(text "{% endblock %} {% block body %}")
(div
("class" "flex flex_col gap_2")
@@ -40,6 +39,20 @@
(script ("defer" "true") (text "setTimeout(() => { temporary_set_theme('{{ metadata.access_recommended_theme }}') }, 150);"))
(text "{%- endif %}")
+ ; owner
+ (text "{% if metadata.tetratto_owner_username|length > 0 -%}")
+ (span
+ ("class" "flex items_center gap_2")
+ (text "Owner:")
+ (a
+ ("class" "flex items_center gap_2")
+ ("href" "{{ tetratto }}/@{{ metadata.tetratto_owner_username }}")
+ (img
+ ("class" "avatar")
+ ("src" "{{ tetratto }}/api/v1/auth/user/{{ metadata.tetratto_owner_username }}/avatar?selector_type=username"))
+ (text "{{ metadata.tetratto_owner_username }}")))
+ (text "{%- endif %}")
+
; views
(text "{% if not metadata.option_disable_views -%}")
(span (text "Views: {{ views }}"))
@@ -55,7 +68,6 @@
(link ("rel" "stylesheet") ("href" "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/default.min.css"))
(script ("src" "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"))
(script (text "hljs.highlightAll();"))
-
(text "{% endblock %}")
(text "{% block nav_extras %}")
(button
@@ -72,3 +84,11 @@
}, 150);"))
(text "{%- endif %}")
(text "{% endblock %}")
+
+(text "{% block dropdown %}")
+(hr)
+(a
+ ("class" "button")
+ ("href" "/{{ entry.slug }}/claim")
+ (text "claim"))
+(text "{%- endblock %}")
diff --git a/src/markdown.rs b/src/markdown.rs
index 9c32c41..13b7b4e 100644
--- a/src/markdown.rs
+++ b/src/markdown.rs
@@ -3,8 +3,8 @@ use std::collections::HashSet;
pub fn render_markdown(input: &str) -> String {
let html = tetratto_shared::markdown::render_markdown_dirty(&parse_page(&parse_details(
&parse_text_color(&parse_highlight(&parse_link(&parse_image(
- &parse_image_size(&parse_toc(&parse_underline(&parse_comment(
- &input.replace("[/]", "
"),
+ &parse_image_size(&parse_toc(&parse_underline(&parse_markdown_element(
+ &parse_comment(&input.replace("[/]", "
")),
)))),
)))),
)))
@@ -983,3 +983,26 @@ pub fn parse_toc(input: &str) -> String {
output
}
+
+/// Handle the `` HTML element.
+fn parse_markdown_element_line(output: &mut String, buffer: &mut String, line: &str) {
+ let mut in_markdown = false;
+
+ for char in line.chars() {
+ if buffer.ends_with("") {
+ in_markdown = true;
+ output.push_str(&buffer.replace("", ""));
+ buffer.clear();
+ } else if in_markdown && buffer.ends_with("") {
+ in_markdown = false;
+ output.push_str(&render_markdown(&buffer.replace("", "")));
+ buffer.clear();
+ }
+
+ buffer.push(char);
+ }
+}
+
+pub fn parse_markdown_element(input: &str) -> String {
+ parser_ignores_pre!(parse_markdown_element_line, input)
+}
diff --git a/src/model.rs b/src/model.rs
index ad8a348..72db41f 100644
--- a/src/model.rs
+++ b/src/model.rs
@@ -371,6 +371,13 @@ pub struct EntryMetadata {
#[serde(default, alias = "SAFETY_CONTENT_WARNING")]
#[validate(max_length = 512)]
pub safety_content_warning: String,
+ /// The username of the owner of this entry on the Tetratto instance.
+ ///
+ /// For security reasons, this really does nothing but show the owner in the
+ /// entry's info section.
+ #[serde(default, alias = "TETRATTO_OWNER_USERNAME")]
+ #[validate(max_length = 32)]
+ pub tetratto_owner_username: String,
}
macro_rules! metadata_css {
diff --git a/src/routes.rs b/src/routes.rs
index 64029f6..553ee01 100644
--- a/src/routes.rs
+++ b/src/routes.rs
@@ -42,6 +42,7 @@ pub fn routes() -> Router {
.route("/", get(index_request))
.route("/{slug}", get(view_request))
.route("/{slug}/edit", get(editor_request))
+ .route("/{slug}/claim", get(reclaim_request))
// api
.route("/api/v1/util/ip", get(util_ip))
.route("/api/v1/render", post(render_request))
@@ -62,6 +63,10 @@ fn default_context(data: &DataClient, build_code: &str) -> Context {
"what_page_slug",
&var("WHAT_SLUG").unwrap_or("what".to_string()),
);
+ ctx.insert(
+ "tetratto_handler_account_username",
+ &var("TETRATTO_HANDLER_ACCOUNT_USERNAME").unwrap_or("fluffle".to_string()),
+ );
ctx.insert("build_code", &build_code);
ctx
}
@@ -300,6 +305,63 @@ async fn editor_request(
Html(tera.render("edit.lisp", &ctx).unwrap())
}
+const MINIMUM_CLAIMABLE_TIME: usize = 604_800_000; // 1 week
+
+async fn reclaim_request(
+ Extension(data): Extension,
+ Path(mut slug): Path,
+) -> impl IntoResponse {
+ let (ref data, ref tera, ref build_code) = *data.read().await;
+ slug = slug.to_lowercase();
+
+ let entry = match data
+ .query(&SimplifiedQuery {
+ query: AppDataSelectQuery::KeyIs(format!("entries('{}')", slug.to_lowercase())),
+ mode: AppDataSelectMode::One(0),
+ })
+ .await
+ {
+ Ok(r) => match r {
+ AppDataQueryResult::One(r) => serde_json::from_str::(&r.value).unwrap(),
+ AppDataQueryResult::Many(_) => unreachable!(),
+ },
+ Err(_) => {
+ let mut ctx = default_context(&data, &build_code);
+ ctx.insert(
+ "error",
+ &Error::GeneralNotFound("entry".to_string()).to_string(),
+ );
+
+ return Html(tera.render("error.lisp", &ctx).unwrap());
+ }
+ };
+
+ // check metadata
+ let metadata: EntryMetadata = match toml::from_str(&EntryMetadata::ini_to_toml(&entry.metadata))
+ {
+ Ok(x) => x,
+ Err(_) => EntryMetadata::default(),
+ };
+
+ if let Err(e) = metadata.validate() {
+ let mut ctx = default_context(&data, &build_code);
+ ctx.insert("error", &e.to_string());
+ return Html(tera.render("error.lisp", &ctx).unwrap());
+ }
+
+ // ...
+ let mut ctx = default_context(&data, &build_code);
+
+ ctx.insert("entry", &entry);
+ ctx.insert("metadata", &metadata);
+ ctx.insert(
+ "claimable",
+ &((unix_epoch_timestamp() - entry.edited) > MINIMUM_CLAIMABLE_TIME),
+ );
+
+ Html(tera.render("claim.lisp", &ctx).unwrap())
+}
+
// api
#[derive(Deserialize)]
struct RenderMarkdown {