diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css
index f592c77..0603ee1 100644
--- a/crates/app/src/public/css/style.css
+++ b/crates/app/src/public/css/style.css
@@ -1250,3 +1250,32 @@ details.accordion .inner {
.CodeMirror-focused .CodeMirror-placeholder {
opacity: 50%;
}
+
+.CodeMirror-gutters {
+ border-color: var(--color-super-lowered) !important;
+ background-color: var(--color-lowered) !important;
+}
+
+.CodeMirror-hints {
+ background: var(--color-raised) !important;
+ box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size)
+ var(--color-shadow);
+ border-radius: var(--radius) !important;
+ padding: var(--pad-1) !important;
+ border-color: var(--color-super-lowered) !important;
+}
+
+.CodeMirror-hints li {
+ color: var(--color-text-raised) !important;
+ border-radius: var(--radius) !important;
+ transition:
+ background 0.15s,
+ color 0.15s;
+ font-size: 10px;
+ padding: calc(var(--pad-1) / 2) var(--pad-2);
+}
+
+.CodeMirror-hints li.CodeMirror-hint-active {
+ background-color: var(--color-primary) !important;
+ color: var(--color-text-primary) !important;
+}
diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp
index 75a24ef..2726b26 100644
--- a/crates/app/src/public/html/components.lisp
+++ b/crates/app/src/public/html/components.lisp
@@ -1928,7 +1928,7 @@
(text "{%- endif %}")
; note listings
- (text "{% for note in notes %}")
+ (text "{% for note in notes %} {% if not view_mode or note.title != \"journal.css\" -%}")
(div
("class" "flex flex-row gap-1")
(a
@@ -1958,6 +1958,6 @@
(icon (text "trash"))
(str (text "general:action.delete")))))
(text "{%- endif %}"))
- (text "{% endfor %}"))
+ (text "{%- endif %} {% endfor %}"))
(text "{%- endif %}")
(text "{%- endmacro %}")
diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp
index 267541a..012909d 100644
--- a/crates/app/src/public/html/journals/app.lisp
+++ b/crates/app/src/public/html/journals/app.lisp
@@ -2,6 +2,27 @@
(text "{% if journal -%}") (title (text "{{ journal.title }}")) (text "{% else %}") (title (text "Journals - {{ config.name }}")) (text "{%- endif %}")
(link ("rel" "stylesheet") ("data-turbo-temporary" "true") ("href" "/css/chats.css?v=tetratto-{{ random_cache_breaker }}"))
+(style
+ (text "html, body {
+ overflow: hidden auto !important;
+ }
+
+ .sidebar {
+ position: sticky;
+ top: 42px;
+ }
+
+ @media screen and (max-width: 900px) {
+ .sidebar {
+ position: absolute;
+ top: unset;
+ }
+
+ body.sidebars_shown {
+ overflow: hidden !important;
+ }
+ }"))
+
(text "{% if view_mode and journal and is_editor -%} {% if note -%}")
; redirect to note
(meta ("http-equiv" "refresh") ("content" "0; url=/@{{ user.username }}/{{ journal.title }}/{{ note.title }}"))
@@ -9,6 +30,11 @@
; redirect to journal homepage
(meta ("http-equiv" "refresh") ("content" "0; url=/@{{ user.username }}/{{ journal.title }}"))
(text "{%- endif %} {%- endif %}")
+
+(text "{% if view_mode and journal -%}")
+; add journal css
+(link ("rel" "stylesheet") ("data-turbo-temporary" "true") ("href" "/api/v1/journals/{{ journal.id }}/journal.css?v=tetratto-{{ random_cache_breaker }}"))
+(text "{%- endif %}")
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"journals\") }}")
(text "{% if not view_mode -%}")
(nav
@@ -63,17 +89,19 @@
("href" "/api/v1/auth/user/find/{{ journal.owner }}")
(text "{{ components::avatar(username=journal.owner, selector_type=\"id\", size=\"18px\") }}"))
+ (text "{% if (view_mode and owner) or not view_mode -%}")
(a
("class" "flush")
- ("href" "{% if view_mode -%} /@{{ owner.username }}/{{ journal.title }}/index {%- else -%} /journals/{{ journal.id }}/0 {%- endif %}")
+ ("href" "{% if view_mode -%} /@{{ owner.username }}/{{ journal.title }} {%- else -%} /journals/{{ journal.id }}/0 {%- endif %}")
(b (text "{{ journal.title }}")))
+ (text "{%- endif %}")
(text "{% if note -%}")
(span (text "/"))
(b (text "{{ note.title }}"))
(text "{%- endif %}"))
- (text "{% if user and user.id == journal.owner -%}")
+ (text "{% if user and user.id == journal.owner and (not note or note.title != \"journal.css\") -%}")
(div
("class" "pillmenu")
(a
@@ -83,7 +111,7 @@
(icon (text "pencil")))
(a
("class" "{% if view_mode -%}active{%- endif %}")
- ("href" "/@{{ user.username }}/{{ journal.title }}/{% if note -%} {{ note.title }} {%- else -%} index {%- endif %}")
+ ("href" "/@{{ user.username }}/{{ journal.title }}{% if note -%} /{{ note.title }} {%- endif %}")
(icon (text "eye"))))
(text "{%- endif %}"))
(text "{%- endif %}")
@@ -96,6 +124,7 @@
("class" "card w-full flex flex-col gap-2")
(h3 (str (text "journals:label.welcome")))
(span (str (text "journals:label.select_a_journal")))
+ (span ("class" "mobile") (str (text "journals:label.mobile_click_my_journals")))
(button
("onclick" "create_journal()")
(icon (text "plus"))
@@ -180,10 +209,36 @@
; import codemirror
(script ("src" "https://unpkg.com/codemirror@5.39.2/lib/codemirror.js") ("data-turbo-temporary" "true"))
(script ("src" "https://unpkg.com/codemirror@5.39.2/addon/display/placeholder.js") ("data-turbo-temporary" "true"))
- (script ("src" "https://unpkg.com/codemirror@5.39.2/mode/markdown/markdown.js") ("data-turbo-temporary" "true"))
(link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/lib/codemirror.css") ("data-turbo-temporary" "true"))
+ (text "{% if note.title == \"journal.css\" -%}")
+ ; css editor
+ (script ("src" "https://unpkg.com/codemirror@5.39.2/addon/edit/closebrackets.js") ("data-turbo-temporary" "true"))
+ (script ("src" "https://unpkg.com/codemirror@5.39.2/addon/hint/css-hint.js") ("data-turbo-temporary" "true"))
+ (script ("src" "https://unpkg.com/codemirror@5.39.2/addon/hint/show-hint.js") ("data-turbo-temporary" "true"))
+ (script ("src" "https://unpkg.com/codemirror@5.39.2/addon/lint/css-lint.js") ("data-turbo-temporary" "true"))
+ (script ("src" "https://unpkg.com/codemirror@5.39.2/mode/css/css.js") ("data-turbo-temporary" "true"))
+ (link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/addon/hint/show-hint.css") ("data-turbo-temporary" "true"))
+ (link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/addon/lint/lint.css") ("data-turbo-temporary" "true"))
+
+ (style
+ (text ".CodeMirror {
+ font-family: monospace !important;
+ font-size: 16px;
+ border: solid 1px var(--color-super-lowered);
+ border-radius: var(--radius);
+ }
+
+ .CodeMirror-line {
+ padding-left: 5px !important;
+ }"))
+ (text "{% else %}")
+ ; markdown editor
+ (script ("src" "https://unpkg.com/codemirror@5.39.2/mode/markdown/markdown.js") ("data-turbo-temporary" "true"))
+ (text "{%- endif %}")
+
; tab bar
+ (text "{% if note.title != \"journal.css\" -%}")
(div
("class" "pillmenu")
(a
@@ -198,6 +253,7 @@
("data-tab-button" "preview")
("data-turbo" "false")
(str (text "journals:label.preview_pane"))))
+ (text "{%- endif %}")
; tabs
(div
@@ -221,10 +277,15 @@
(script ("id" "editor_content") ("type" "text/markdown") (text "{{ note.content|remove_script_tags|safe }}"))
(script
(text "setTimeout(() => {
+ if (!document.getElementById(\"preview_tab\").shadowRoot) {
+ document.getElementById(\"preview_tab\").attachShadow({ mode: \"open\" });
+ }
+
globalThis.editor = CodeMirror(document.getElementById(\"editor_tab\"), {
value: document.getElementById(\"editor_content\").innerHTML,
- mode: \"markdown\",
+ mode: \"{% if note.title == 'journal.css' -%} css {%- else -%} markdown {%- endif %}\",
lineWrapping: true,
+ lineNumbers: \"{{ note.title }}\" === \"journal.css\",
autoCloseBrackets: true,
autofocus: true,
viewportMargin: Number.POSITIVE_INFINITY,
@@ -232,7 +293,8 @@
highlightFormatting: false,
fencedCodeBlockHighlighting: false,
xml: false,
- smartIndent: false,
+ smartIndent: true,
+ indentUnit: 4,
placeholder: `# {{ note.title }}`,
extraKeys: {
Home: \"goLineLeft\",
@@ -243,6 +305,15 @@
},
});
+ editor.on(\"keydown\", (cm, e) => {
+ if (e.key.length > 1) {
+ // ignore all keys that aren't a letter
+ return;
+ }
+
+ CodeMirror.showHint(cm, CodeMirror.hint.css);
+ });
+
document.querySelector(\"[data-tab-button=editor]\").addEventListener(\"click\", async (e) => {
e.preventDefault();
trigger(\"atto::hooks::tabs:switch\", [\"editor\"]);
@@ -262,7 +333,10 @@
})
).text();
- document.getElementById(\"preview_tab\").innerHTML = res;
+ const preview_token = window.crypto.randomUUID();
+ document.getElementById(\"preview_tab\").shadowRoot.innerHTML = `${res}`;
trigger(\"atto::hooks::tabs:switch\", [\"preview\"]);
});
}, 150);"))
@@ -272,7 +346,34 @@
("class" "flex flex-col gap-2 card")
(text "{{ note.content|markdown|safe }}"))
- (span (text "Last updated: ") (span ("class" "date") (text "{{ note.edited }}")))
+ (div
+ ("class" "flex w-full justify-between gap-2")
+ (span (text "Last updated: ") (span ("class" "date") (text "{{ note.edited }}")))
+ (text "{% if user and user.id == owner.id -%}")
+ (button
+ ("class" "small")
+ ("onclick" "{% if journal.privacy == \"Public\" -%}
+ trigger('atto::copy_text', ['{{ config.host }}/@{{ owner.username }}/{{ journal.title }}/{{ note.title }}'])
+ {%- else -%}
+ prompt_make_public();
+ trigger('atto::copy_text', ['{{ config.host }}/@{{ owner.username }}/{{ journal.title }}/{{ note.title }}'])
+ {%- endif %}")
+ (icon (text "share"))
+ (str (text "general:label.share")))
+
+ (script
+ (text "globalThis.prompt_make_public = async () => {
+ if (
+ !(await trigger(\"atto::confirm\", [
+ \"Would you like to make this journal public? This is required for others to view this note.\",
+ ]))
+ ) {
+ return;
+ }
+
+ change_journal_privacy({ target: { selectedOptions: [{ value: \"Public\" }] }, preventDefault: () => {} });
+ }"))
+ (text "{%- endif %}"))
(text "{%- endif %}")
(text "{%- endif %}")))
(style
@@ -332,7 +433,7 @@
},
body: JSON.stringify({
title,
- content: `# ${title}`,
+ content: title === \"journal.css\" ? `/* ${title} */\\n` : `# ${title}`,
journal: \"{{ selected_journal }}\",
}),
})
@@ -431,8 +532,8 @@
globalThis.change_journal_privacy = async (e) => {
e.preventDefault();
- const selected = event.target.selectedOptions[0];
- fetch(\"/api/v1/journals/{{ selected_journal }}/privacy\", {
+ const selected = e.target.selectedOptions[0];
+ fetch(\"/api/v1/journals/{% if journal -%} {{ journal.id }} {%- else -%} {{ selected_journal }} {%- endif %}/privacy\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
diff --git a/crates/app/src/public/html/profile/posts.lisp b/crates/app/src/public/html/profile/posts.lisp
index 0c9d79a..06aca2f 100644
--- a/crates/app/src/public/html/profile/posts.lisp
+++ b/crates/app/src/public/html/profile/posts.lisp
@@ -44,9 +44,10 @@
("ui_ident" "io_data_load")
(div ("ui_ident" "io_data_marker"))))
+(text "{% set paged = user and user.settings.paged_timelines %}")
(script
(text "setTimeout(() => {
- trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?user_id={{ profile.id }}&tag={{ tag }}&page=\", Number.parseInt(\"{{ page }}\") - 1]);
+ trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?user_id={{ profile.id }}&tag={{ tag }}&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
});"))
(text "{% endblock %}")
diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp
index 8be4836..268dfef 100644
--- a/crates/app/src/public/html/profile/settings.lisp
+++ b/crates/app/src/public/html/profile/settings.lisp
@@ -564,14 +564,14 @@
(li
(text "Use custom CSS on your profile"))
(li
- (text "Ability to use community emojis outside of
+ (text "Use community emojis outside of
their community"))
(li
- (text "Ability to upload and use gif emojis"))
+ (text "Upload and use gif emojis"))
(li
(text "Create infinite stack timelines"))
(li
- (text "Ability to upload images to posts"))
+ (text "Upload images to posts"))
(li
(text "Save infinite post drafts"))
(li
@@ -579,7 +579,7 @@
(li
(text "Ability to create forges"))
(li
- (text "Ability to create more than 1 app"))
+ (text "Create more than 1 app"))
(li
(text "Create up to 10 stack blocks"))
(li
@@ -587,7 +587,9 @@
(li
(text "Increased proxied image size"))
(li
- (text "Create infinite journals")))
+ (text "Create infinite journals"))
+ (li
+ (text "Create infinite notes in each journal")))
(a
("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}")
("class" "button")
@@ -1401,6 +1403,11 @@
\"Hides dislikes on all posts. Users will also no longer be able to dislike your posts.\",
\"text\",
],
+ [
+ [\"paged_timelines\", \"Make timelines paged instead of infinitely scrolled\"],
+ \"{{ profile.settings.paged_timelines }}\",
+ \"checkbox\",
+ ],
[[], \"Fun\", \"title\"],
[
[\"disable_gpa_fun\", \"Disable GPA\"],
diff --git a/crates/app/src/public/html/stacks/feed.lisp b/crates/app/src/public/html/stacks/feed.lisp
index 5698065..5002856 100644
--- a/crates/app/src/public/html/stacks/feed.lisp
+++ b/crates/app/src/public/html/stacks/feed.lisp
@@ -83,9 +83,10 @@
("ui_ident" "io_data_load")
(div ("ui_ident" "io_data_marker")))
+ (text "{% set paged = user and user.settings.paged_timelines %}")
(script
(text "setTimeout(() => {
- trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?stack_id={{ stack.id }}&page=\", Number.parseInt(\"{{ page }}\") - 1]);
+ trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?stack_id={{ stack.id }}&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
});"))
(text "{%- endif %}"))))
diff --git a/crates/app/src/public/html/timelines/all.lisp b/crates/app/src/public/html/timelines/all.lisp
index c38dd88..7cced78 100644
--- a/crates/app/src/public/html/timelines/all.lisp
+++ b/crates/app/src/public/html/timelines/all.lisp
@@ -33,9 +33,10 @@
("ui_ident" "io_data_load")
(div ("ui_ident" "io_data_marker"))))
+(text "{% set paged = user and user.settings.paged_timelines %}")
(script
(text "setTimeout(() => {
- trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&page=\", Number.parseInt(\"{{ page }}\") - 1]);
+ trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
});"))
(text "{% endblock %}")
diff --git a/crates/app/src/public/html/timelines/following.lisp b/crates/app/src/public/html/timelines/following.lisp
index b1759e4..ef23a55 100644
--- a/crates/app/src/public/html/timelines/following.lisp
+++ b/crates/app/src/public/html/timelines/following.lisp
@@ -11,9 +11,10 @@
("ui_ident" "io_data_load")
(div ("ui_ident" "io_data_marker"))))
+(text "{% set paged = user and user.settings.paged_timelines %}")
(script
(text "setTimeout(() => {
- trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=FollowingPosts&page=\", Number.parseInt(\"{{ page }}\") - 1]);
+ trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=FollowingPosts&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
});"))
(text "{% endblock %}")
diff --git a/crates/app/src/public/html/timelines/home.lisp b/crates/app/src/public/html/timelines/home.lisp
index e398615..5a5658b 100644
--- a/crates/app/src/public/html/timelines/home.lisp
+++ b/crates/app/src/public/html/timelines/home.lisp
@@ -31,9 +31,10 @@
(div ("ui_ident" "io_data_marker")))
(text "{%- endif %}"))
+(text "{% set paged = user and user.settings.paged_timelines %}")
(script
(text "setTimeout(() => {
- trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=MyCommunities&page=\", Number.parseInt(\"{{ page }}\") - 1]);
+ trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=MyCommunities&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
});"))
(text "{% endblock %}")
diff --git a/crates/app/src/public/html/timelines/popular.lisp b/crates/app/src/public/html/timelines/popular.lisp
index 6d26f3d..d0223df 100644
--- a/crates/app/src/public/html/timelines/popular.lisp
+++ b/crates/app/src/public/html/timelines/popular.lisp
@@ -11,9 +11,10 @@
("ui_ident" "io_data_load")
(div ("ui_ident" "io_data_marker"))))
+(text "{% set paged = user and user.settings.paged_timelines %}")
(script
(text "setTimeout(() => {
- trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=PopularPosts&page=\", Number.parseInt(\"{{ page }}\") - 1]);
+ trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=PopularPosts&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
});"))
(text "{% endblock %}")
diff --git a/crates/app/src/public/html/timelines/swiss_army.lisp b/crates/app/src/public/html/timelines/swiss_army.lisp
index eb722c9..23243ce 100644
--- a/crates/app/src/public/html/timelines/swiss_army.lisp
+++ b/crates/app/src/public/html/timelines/swiss_army.lisp
@@ -30,3 +30,7 @@
(str (text "chats:label.go_back")))
(text "{%- endif %}"))
(text "{%- endif %}")
+
+(text "{% if paginated -%}")
+(text "{{ components::pagination(page=page, items=list|length) }}")
+(text "{%- endif %}")
diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js
index 6c30428..835f76e 100644
--- a/crates/app/src/public/js/atto.js
+++ b/crates/app/src/public/js/atto.js
@@ -1141,7 +1141,7 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""}
},
);
- self.define("io_data_load", (_, tmpl, page) => {
+ self.define("io_data_load", (_, tmpl, page, paginated_mode = false) => {
self.IO_DATA_MARKER = document.querySelector(
"[ui_ident=io_data_marker]",
);
@@ -1164,7 +1164,16 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""}
self.IO_DATA_PAGE = page;
self.IO_DATA_SEEN_IDS = [];
- self.IO_DATA_OBSERVER.observe(self.IO_DATA_MARKER);
+ if (!paginated_mode) {
+ self.IO_DATA_OBSERVER.observe(self.IO_DATA_MARKER);
+ } else {
+ // immediately load first page
+ self.IO_DATA_TMPL = self.IO_DATA_TMPL.replace("&page=", "");
+ self.IO_DATA_TMPL += `&paginated=true&page=`;
+ self.io_load_data();
+ }
+
+ self.IO_PAGINATED = paginated_mode;
});
self.define("io_load_data", async () => {
diff --git a/crates/app/src/routes/api/v1/auth/images.rs b/crates/app/src/routes/api/v1/auth/images.rs
index e062be1..e177db7 100644
--- a/crates/app/src/routes/api/v1/auth/images.rs
+++ b/crates/app/src/routes/api/v1/auth/images.rs
@@ -213,7 +213,7 @@ pub async fn upload_avatar_request(
if mime == "image/gif" {
// gif image, don't encode
if img.0.len() > MAXIMUM_GIF_FILE_SIZE {
- return Json(Error::DataTooLong("gif".to_string()).into());
+ return Json(Error::FileTooLarge.into());
}
std::fs::write(&path, img.0).unwrap();
@@ -226,7 +226,7 @@ pub async fn upload_avatar_request(
// check file size
if img.0.len() > MAXIMUM_FILE_SIZE {
- return Json(Error::DataTooLong("image".to_string()).into());
+ return Json(Error::FileTooLarge.into());
}
// upload image
@@ -314,7 +314,7 @@ pub async fn upload_banner_request(
if mime == "image/gif" {
// gif image, don't encode
if img.0.len() > MAXIMUM_GIF_FILE_SIZE {
- return Json(Error::DataTooLong("gif".to_string()).into());
+ return Json(Error::FileTooLarge.into());
}
std::fs::write(&path, img.0).unwrap();
@@ -327,7 +327,7 @@ pub async fn upload_banner_request(
// check file size
if img.0.len() > MAXIMUM_FILE_SIZE {
- return Json(Error::DataTooLong("image".to_string()).into());
+ return Json(Error::FileTooLarge.into());
}
// upload image
diff --git a/crates/app/src/routes/api/v1/communities/images.rs b/crates/app/src/routes/api/v1/communities/images.rs
index 464dede..3ddee00 100644
--- a/crates/app/src/routes/api/v1/communities/images.rs
+++ b/crates/app/src/routes/api/v1/communities/images.rs
@@ -136,7 +136,7 @@ pub async fn upload_avatar_request(
// check file size
if img.0.len() > MAXIMUM_FILE_SIZE {
- return Json(Error::DataTooLong("image".to_string()).into());
+ return Json(Error::FileTooLarge.into());
}
// upload image
@@ -191,7 +191,7 @@ pub async fn upload_banner_request(
// check file size
if img.0.len() > MAXIMUM_FILE_SIZE {
- return Json(Error::DataTooLong("image".to_string()).into());
+ return Json(Error::FileTooLarge.into());
}
// upload image
diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs
index 81a1fae..7bf4bf3 100644
--- a/crates/app/src/routes/api/v1/communities/posts.rs
+++ b/crates/app/src/routes/api/v1/communities/posts.rs
@@ -133,7 +133,7 @@ pub async fn create_request(
// check sizes
for img in &images {
if img.len() > MAXIMUM_FILE_SIZE {
- return Json(Error::DataTooLong("image".to_string()).into());
+ return Json(Error::FileTooLarge.into());
}
}
diff --git a/crates/app/src/routes/api/v1/journals.rs b/crates/app/src/routes/api/v1/journals.rs
index 97f8c9b..de6d501 100644
--- a/crates/app/src/routes/api/v1/journals.rs
+++ b/crates/app/src/routes/api/v1/journals.rs
@@ -9,11 +9,14 @@ use crate::{
routes::api::v1::{UpdateJournalPrivacy, CreateJournal, UpdateJournalTitle},
State,
};
-use tetratto_core::model::{
- journals::{Journal, JournalPrivacyPermission},
- oauth,
- permissions::FinePermission,
- ApiReturn, Error,
+use tetratto_core::{
+ database::NAME_REGEX,
+ model::{
+ journals::{Journal, JournalPrivacyPermission},
+ oauth,
+ permissions::FinePermission,
+ ApiReturn, Error,
+ },
};
pub async fn get_request(
@@ -46,6 +49,20 @@ pub async fn get_request(
})
}
+pub async fn get_css_request(
+ Path(id): Path,
+ Extension(data): Extension,
+) -> impl IntoResponse {
+ let data = &(data.read().await).0;
+
+ let note = match data.get_note_by_journal_title(id, "journal.css").await {
+ Ok(x) => x,
+ Err(e) => return ([("Content-Type", "text/plain")], format!("/* {e} */")),
+ };
+
+ ([("Content-Type", "text/css")], note.content)
+}
+
pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadJournals) {
@@ -99,7 +116,17 @@ pub async fn update_title_request(
None => return Json(Error::NotAllowed.into()),
};
- props.title = props.title.replace(" ", "_");
+ props.title = props.title.replace(" ", "_").to_lowercase();
+
+ // check name
+ let regex = regex::RegexBuilder::new(NAME_REGEX)
+ .multi_line(true)
+ .build()
+ .unwrap();
+
+ if regex.captures(&props.title).is_some() {
+ return Json(Error::MiscError("This title contains invalid characters".to_string()).into());
+ }
// make sure this title isn't already in use
if data
diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs
index f16b1ed..9217437 100644
--- a/crates/app/src/routes/api/v1/mod.rs
+++ b/crates/app/src/routes/api/v1/mod.rs
@@ -551,6 +551,7 @@ pub fn routes() -> Router {
.route("/journals", post(journals::create_request))
.route("/journals/{id}", get(journals::get_request))
.route("/journals/{id}", delete(journals::delete_request))
+ .route("/journals/{id}/journal.css", get(journals::get_css_request))
.route("/journals/{id}/title", post(journals::update_title_request))
.route(
"/journals/{id}/privacy",
diff --git a/crates/app/src/routes/api/v1/notes.rs b/crates/app/src/routes/api/v1/notes.rs
index 45c4a74..faf1bec 100644
--- a/crates/app/src/routes/api/v1/notes.rs
+++ b/crates/app/src/routes/api/v1/notes.rs
@@ -10,12 +10,15 @@ use crate::{
routes::api::v1::{CreateNote, RenderMarkdown, UpdateNoteContent, UpdateNoteTitle},
State,
};
-use tetratto_core::model::{
- journals::{JournalPrivacyPermission, Note},
- oauth,
- permissions::FinePermission,
- uploads::CustomEmoji,
- ApiReturn, Error,
+use tetratto_core::{
+ database::NAME_REGEX,
+ model::{
+ journals::{JournalPrivacyPermission, Note},
+ oauth,
+ permissions::FinePermission,
+ uploads::CustomEmoji,
+ ApiReturn, Error,
+ },
};
pub async fn get_request(
@@ -135,7 +138,17 @@ pub async fn update_title_request(
Err(e) => return Json(e.into()),
};
- props.title = props.title.replace(" ", "_");
+ props.title = props.title.replace(" ", "_").to_lowercase();
+
+ // check name
+ let regex = regex::RegexBuilder::new(NAME_REGEX)
+ .multi_line(true)
+ .build()
+ .unwrap();
+
+ if regex.captures(&props.title).is_some() {
+ return Json(Error::MiscError("This title contains invalid characters".to_string()).into());
+ }
// make sure this title isn't already in use
if data
diff --git a/crates/app/src/routes/pages/journals.rs b/crates/app/src/routes/pages/journals.rs
index f631826..1f03dd7 100644
--- a/crates/app/src/routes/pages/journals.rs
+++ b/crates/app/src/routes/pages/journals.rs
@@ -116,7 +116,7 @@ pub async fn view_request(
}
// if we don't have a selected journal, we shouldn't be here probably
- if selected_journal.is_empty() {
+ if selected_journal.is_empty() | (selected_note == "journal.css") {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &user).await,
));
@@ -207,3 +207,81 @@ pub async fn view_request(
// return
Ok(Html(data.1.render("journals/app.html", &context).unwrap()))
}
+
+/// `/@{owner}/{journal}`
+pub async fn index_view_request(
+ jar: CookieJar,
+ Extension(data): Extension,
+ Path((owner, selected_journal)): Path<(String, String)>,
+) -> impl IntoResponse {
+ let data = data.read().await;
+ let user = match get_user_from_token!(jar, data.0) {
+ Some(ua) => Some(ua),
+ None => None,
+ };
+
+ // get owner
+ let owner = match data.0.get_user_by_username(&owner).await {
+ Ok(ua) => ua,
+ Err(e) => {
+ return Err(Html(render_error(e, &jar, &data, &user).await));
+ }
+ };
+
+ check_user_blocked_or_private!(user, owner, data, jar);
+
+ // get journal and check privacy settings
+ let journal = match data
+ .0
+ .get_journal_by_owner_title(owner.id, &selected_journal)
+ .await
+ {
+ Ok(p) => p,
+ Err(e) => {
+ return Err(Html(render_error(e, &jar, &data, &user).await));
+ }
+ };
+
+ if journal.privacy == JournalPrivacyPermission::Private {
+ if let Some(ref user) = user {
+ if user.id != journal.owner {
+ return Err(Html(
+ render_error(Error::NotAllowed, &jar, &data, &Some(user.to_owned())).await,
+ ));
+ }
+ } else {
+ return Err(Html(
+ render_error(Error::NotAllowed, &jar, &data, &user).await,
+ ));
+ }
+ }
+
+ // ...
+ let notes = match data.0.get_notes_by_journal(journal.id).await {
+ Ok(p) => Some(p),
+ Err(e) => {
+ return Err(Html(render_error(e, &jar, &data, &user).await));
+ }
+ };
+
+ let lang = get_lang!(jar, data.0);
+ let mut context = initial_context(&data.0.0.0, lang, &user).await;
+
+ if selected_journal.is_empty() {
+ context.insert("selected_journal", &0);
+ } else {
+ context.insert("selected_journal", &selected_journal);
+ }
+
+ context.insert("selected_note", &0);
+ context.insert("journal", &journal);
+
+ context.insert("owner", &owner);
+ context.insert("notes", ¬es);
+
+ context.insert("view_mode", &true);
+ context.insert("is_editor", &false);
+
+ // return
+ Ok(Html(data.1.render("journals/app.html", &context).unwrap()))
+}
diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs
index 3ff3f0d..8b76292 100644
--- a/crates/app/src/routes/pages/misc.rs
+++ b/crates/app/src/routes/pages/misc.rs
@@ -576,6 +576,8 @@ pub struct TimelineQuery {
pub user_id: usize,
#[serde(default)]
pub tag: String,
+ #[serde(default)]
+ pub paginated: bool,
}
/// `/_swiss_army_timeline`
@@ -697,6 +699,7 @@ pub async fn swiss_army_timeline_request(
context.insert("list", &list);
context.insert("page", &req.page);
+ context.insert("paginated", &req.paginated);
Ok(Html(
data.1
.render("timelines/swiss_army.html", &context)
diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs
index 2177d94..2eaeca2 100644
--- a/crates/app/src/routes/pages/mod.rs
+++ b/crates/app/src/routes/pages/mod.rs
@@ -134,7 +134,7 @@ pub fn routes() -> Router {
// journals
.route("/journals", get(journals::redirect_request))
.route("/journals/{journal}/{note}", get(journals::app_request))
- .route("/@{owner}/{journal}", get(journals::view_request))
+ .route("/@{owner}/{journal}", get(journals::index_view_request))
.route("/@{owner}/{journal}/{note}", get(journals::view_request))
}
diff --git a/crates/core/src/database/journals.rs b/crates/core/src/database/journals.rs
index 4602b6b..a4a0d00 100644
--- a/crates/core/src/database/journals.rs
+++ b/crates/core/src/database/journals.rs
@@ -1,9 +1,10 @@
use oiseau::{cache::Cache, query_row};
use crate::{
+ database::common::NAME_REGEX,
model::{
auth::User,
- permissions::FinePermission,
journals::{Journal, JournalPrivacyPermission},
+ permissions::FinePermission,
Error, Result,
},
};
@@ -69,7 +70,7 @@ impl DataManager {
Ok(res.unwrap())
}
- const MAXIMUM_FREE_JOURNALS: usize = 15;
+ const MAXIMUM_FREE_JOURNALS: usize = 5;
/// Create a new journal in the database.
///
@@ -83,7 +84,19 @@ impl DataManager {
return Err(Error::DataTooLong("title".to_string()));
}
- data.title = data.title.replace(" ", "_");
+ data.title = data.title.replace(" ", "_").to_lowercase();
+
+ // check name
+ let regex = regex::RegexBuilder::new(NAME_REGEX)
+ .multi_line(true)
+ .build()
+ .unwrap();
+
+ if regex.captures(&data.title).is_some() {
+ return Err(Error::MiscError(
+ "This title contains invalid characters".to_string(),
+ ));
+ }
// make sure this title isn't already in use
if self
diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs
index e56bc93..a00fde9 100644
--- a/crates/core/src/database/mod.rs
+++ b/crates/core/src/database/mod.rs
@@ -30,3 +30,4 @@ mod userblocks;
mod userfollows;
pub use drivers::DataManager;
+pub use common::NAME_REGEX;
diff --git a/crates/core/src/database/notes.rs b/crates/core/src/database/notes.rs
index d46394b..ea7da45 100644
--- a/crates/core/src/database/notes.rs
+++ b/crates/core/src/database/notes.rs
@@ -1,4 +1,5 @@
use oiseau::cache::Cache;
+use crate::database::common::NAME_REGEX;
use crate::model::{auth::User, journals::Note, permissions::FinePermission, Error, Result};
use crate::{auto_method, DataManager};
use oiseau::{execute, get, params, query_row, query_rows, PostgresRow};
@@ -64,6 +65,8 @@ impl DataManager {
Ok(res.unwrap())
}
+ const MAXIMUM_FREE_NOTES_PER_JOURNAL: usize = 10;
+
/// Create a new note in the database.
///
/// # Arguments
@@ -82,7 +85,33 @@ impl DataManager {
return Err(Error::DataTooLong("content".to_string()));
}
- data.title = data.title.replace(" ", "_");
+ data.title = data.title.replace(" ", "_").to_lowercase();
+
+ // check number of notes
+ let owner = self.get_user_by_id(data.owner).await?;
+
+ if !owner.permissions.check(FinePermission::SUPPORTER) {
+ let journals = self.get_notes_by_journal(data.owner).await?;
+
+ if journals.len() >= Self::MAXIMUM_FREE_NOTES_PER_JOURNAL {
+ return Err(Error::MiscError(
+ "You already have the maximum number of notes you can have in this journal"
+ .to_string(),
+ ));
+ }
+ }
+
+ // check name
+ let regex = regex::RegexBuilder::new(NAME_REGEX)
+ .multi_line(true)
+ .build()
+ .unwrap();
+
+ if regex.captures(&data.title).is_some() {
+ return Err(Error::MiscError(
+ "This title contains invalid characters".to_string(),
+ ));
+ }
// make sure this title isn't already in use
if self
diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs
index 8c12f76..bc8b13f 100644
--- a/crates/core/src/model/auth.rs
+++ b/crates/core/src/model/auth.rs
@@ -231,6 +231,9 @@ pub struct UserSettings {
/// A list of strings the user has muted.
#[serde(default)]
pub muted: Vec,
+ /// If timelines are paged instead of infinitely scrolled.
+ #[serde(default)]
+ pub paged_timelines: bool,
}
fn mime_avif() -> String {
@@ -332,7 +335,7 @@ impl User {
// parse
for char in input.chars() {
- if (char == '\\') && !escape {
+ if ((char == '\\') | (char == '/')) && !escape {
escape = true;
continue;
}
diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs
index c50ea7c..62f26a3 100644
--- a/crates/core/src/model/mod.rs
+++ b/crates/core/src/model/mod.rs
@@ -41,6 +41,7 @@ pub enum Error {
AlreadyAuthenticated,
DataTooLong(String),
DataTooShort(String),
+ FileTooLarge,
UsernameInUse,
TitleInUse,
QuestionsDisabled,
@@ -62,6 +63,7 @@ impl Display for Error {
Self::AlreadyAuthenticated => "Already authenticated".to_string(),
Self::DataTooLong(name) => format!("Given {name} is too long!"),
Self::DataTooShort(name) => format!("Given {name} is too short!"),
+ Self::FileTooLarge => "Given file is too large".to_string(),
Self::UsernameInUse => "Username in use".to_string(),
Self::TitleInUse => "Title in use".to_string(),
Self::QuestionsDisabled => "You are not allowed to ask questions there".to_string(),