add: global notes

This commit is contained in:
trisua 2025-06-26 02:56:22 -04:00
parent 59581f69c9
commit 2cd04b0db0
24 changed files with 371 additions and 608 deletions

View file

@ -256,3 +256,6 @@ version = "1.0.0"
"journals:action.create_subdir" = "Create subdirectory"
"journals:action.create_root_dir" = "Create root directory"
"journals:action.move" = "Move"
"journals:action.publish" = "Publish"
"journals:action.unpublish" = "Unpublish"
"journals:action.view" = "View"

View file

@ -2138,15 +2138,32 @@
(icon (text "pencil"))
(str (text "chats:action.rename")))
(a
("class" "button")
("href" "/journals/{{ journal.id }}/{{ note.id }}#/tags")
(icon (text "tag"))
(str (text "journals:action.edit_tags")))
(button
("class" "button")
("onclick" "window.NOTE_MOVER_NOTE_ID = '{{ note.id }}'; document.getElementById('note_mover_dialog').showModal()")
(icon (text "brush-cleaning"))
(str (text "journals:action.move")))
(text "{% if note.is_global -%}")
(a
("class" "button")
("href" "/x/{{ note.title }}")
(icon (text "eye"))
(str (text "journals:action.view")))
(button
("class" "purple")
("onclick" "unpublish_note('{{ note.id }}')")
(icon (text "globe-lock"))
(str (text "journals:action.unpublish")))
(text "{% elif note.title != 'journal.css' %}")
(button
("class" "green")
("onclick" "publish_note('{{ note.id }}')")
(icon (text "globe"))
(str (text "journals:action.publish")))
(text "{%- endif %}")
(button
("onclick" "delete_note('{{ note.id }}')")
("class" "red")

View file

@ -1,11 +1,19 @@
(text "{% extends \"root.html\" %} {% block head %}")
(text "{% if journal -%}") (title (text "{{ journal.title }}")) (text "{% else %}") (title (text "Journals - {{ config.name }}")) (text "{%- endif %}")
(text "{% if journal -%} {% if note -%}")
(title (text "{{ note.title }}"))
(text "{% else %}")
(title (text "{{ journal.title }}"))
(text "{%- endif %} {% else %}")
(title (text "Journals - {{ config.name }}"))
(text "{%- endif %}")
(text "{% if note and journal and owner -%}")
(meta
("name" "og:title")
("content" "{{ note.title }}"))
(text "{% if not global_mode -%}")
(meta
("name" "description")
("content" "View this note from the {{ journal.title }} journal on {{ config.name }}!"))
@ -14,6 +22,23 @@
("name" "og:description")
("content" "View this note from the {{ journal.title }} journal on {{ config.name }}!"))
(meta
("name" "twitter:description")
("content" "View this note from the {{ journal.title }} journal on {{ config.name }}!"))
(text "{% else %}")
(meta
("name" "description")
("content" "View this note on {{ config.name }}!"))
(meta
("name" "og:description")
("content" "View this note on {{ config.name }}!"))
(meta
("name" "twitter:description")
("content" "View this note on {{ config.name }}!"))
(text "{%- endif %}")
(meta
("property" "og:type")
("content" "website"))
@ -33,10 +58,6 @@
(meta
("name" "twitter:title")
("content" "{{ note.title }}"))
(meta
("name" "twitter:description")
("content" "View this note from the {{ journal.title }} journal on {{ config.name }}!"))
(text "{%- endif %}")
(link ("rel" "stylesheet") ("data-turbo-temporary" "true") ("href" "/css/chats.css?v=tetratto-{{ random_cache_breaker }}"))
@ -73,7 +94,7 @@
; 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 "{% endblock %} {% block body %} {% if not global_mode -%} {{ macros::nav(selected=\"journals\") }} {%- endif %}")
(text "{% if not view_mode -%}")
(nav
("class" "chats_nav")
@ -117,7 +138,7 @@
(main
("class" "flex flex-col gap-2")
; the journal/note header is always shown
(text "{% if journal -%}")
(text "{% if journal and not global_mode -%}")
(div
("class" "mobile_nav w-full flex items-center justify-between gap-2")
(div
@ -126,8 +147,8 @@
("class" "flex gap-2 items-center")
(a
("class" "flex items-center")
("href" "/api/v1/auth/user/find/{{ journal.owner }}")
(text "{{ components::avatar(username=journal.owner, selector_type=\"id\", size=\"18px\") }}"))
("href" "/@{{ owner.username }}")
(text "{{ components::avatar(username=owner.username, selector_type=\"username\", size=\"18px\") }}"))
(text "{% if (view_mode and owner) or not view_mode -%}")
(a
@ -462,19 +483,35 @@
(div
("class" "flex w-full justify-between gap-2")
(div
("class" "flex flex-col gap-2")
("class" "flex flex-col gap-2 fade")
(span (text "Last updated: ") (span ("class" "date") (text "{{ note.edited }}")))
(text "{% if global_mode -%}")
(span ("class" "flex gap-1") (text "Created by: ") (text "{{ components::full_username(user=owner) }}"))
(span (text "Views: {{ redis_views }}"))
(text "{% elif note.is_global -%}")
; globsl note, but we aren't viewing globally...
(a
("href" "/x/{{ note.title }}")
("class" "button lowered small green")
(icon (text "globe"))
(text "View as global"))
(text "{%- endif %}")
(text "{{ components::note_tags(note=note) }}"))
(text "{% if user and user.id == owner.id -%}")
(button
("class" "small")
("onclick" "{% if journal.privacy == \"Public\" -%}
("onclick" "{% if note.is_global -%}
trigger('atto::copy_text', ['{{ config.host }}/x/{{ note.title }}'])
{%- else -%}
{% 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 %}")
{%- endif -%} {%- endif %}")
(icon (text "share"))
(str (text "general:label.share")))
@ -809,6 +846,62 @@
});
}
globalThis.publish_note = async (id) => {
if (
!(await trigger(\"atto::confirm\", [
`Are you sure you would like to do this? The note will be public at '/x/name', even if the journal is private.
Publishing your note is specifically for making the note accessible through the global endpoint. The note will be public under your username as long as the journal is public.`,
]))
) {
return;
}
fetch(`/api/v1/notes/${id}/global`, {
method: \"POST\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
setTimeout(() => {
window.location.reload();
}, 100);
}
});
}
globalThis.unpublish_note = async (id) => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this? This global note name will be made available.\",
]))
) {
return;
}
fetch(`/api/v1/notes/${id}/global`, {
method: \"DELETE\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
setTimeout(() => {
window.location.reload();
}, 100);
}
});
}
// sidebars
window.SIDEBARS_OPEN = false;
if (new URLSearchParams(window.location.search).get(\"nav\") === \"true\") {

View file

@ -693,6 +693,8 @@
(text "Create infinite journals"))
(li
(text "Create infinite notes in each journal"))
(li
(text "Publish up to 50 notes"))
(text "{% if config.security.enable_invite_codes -%}")
(li

View file

@ -42,7 +42,7 @@
},
};
socket.addEventListener("message", (event) => {
socket.addEventListener("message", async (event) => {
if (event.data === "Ping") {
return socket.send("Pong");
}
@ -54,14 +54,14 @@
return console.info(`${stream} ${data.data}`);
}
return $.sock(stream).events.message(data);
return (await $.sock(stream)).events.message(data);
});
return $.STREAMS[stream];
});
self.define("close", ({ $ }, stream) => {
const socket = $.sock(stream);
self.define("close", async ({ $ }, stream) => {
const socket = await $.sock(stream);
if (!socket) {
console.warn("no such stream to close");

View file

@ -1,285 +0,0 @@
use axum::{
response::IntoResponse,
extract::{Json, Path},
Extension,
};
use axum_extra::extract::CookieJar;
use crate::{
get_user_from_token,
image::{save_webp_buffer, JsonMultipart},
routes::api::v1::{
CreateLink, UpdateLinkHref, UpdateLinkLabel, UpdateLinkPosition, UploadLinkIcon,
},
State,
};
use tetratto_core::model::{
links::Link,
oauth,
uploads::{MediaType, MediaUpload},
ApiReturn, Error,
};
pub async fn get_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
if get_user_from_token!(jar, data, oauth::AppScope::UserReadLinks).is_none() {
return Json(Error::NotAllowed.into());
};
let link = match data.get_link_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: Some(link),
})
}
pub async fn list_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadLinks) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.get_links_by_owner(user.id).await {
Ok(x) => Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: Some(x),
}),
Err(e) => Json(e.into()),
}
}
pub async fn create_request(
jar: CookieJar,
Extension(data): Extension<State>,
Json(props): Json<CreateLink>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateLinks) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data
.create_link(
Link::new(
user.id,
props.label,
props.href,
match data.get_links_by_owner_count(user.id).await {
Ok(c) => (c + 1) as usize,
Err(e) => return Json(e.into()),
},
),
&user,
)
.await
{
Ok(x) => Json(ApiReturn {
ok: true,
message: "Link created".to_string(),
payload: Some(x.id.to_string()),
}),
Err(e) => Json(e.into()),
}
}
pub async fn update_label_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
Json(props): Json<UpdateLinkLabel>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let link = match data.get_link_by_id(id).await {
Ok(n) => n,
Err(e) => return Json(e.into()),
};
if link.owner != user.id {
return Json(Error::NotAllowed.into());
}
// ...
match data.update_link_label(id, &props.label).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Link updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn update_href_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
Json(props): Json<UpdateLinkHref>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let link = match data.get_link_by_id(id).await {
Ok(n) => n,
Err(e) => return Json(e.into()),
};
if link.owner != user.id {
return Json(Error::NotAllowed.into());
}
// ...
match data.update_link_href(id, &props.href).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Link updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn update_position_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
Json(props): Json<UpdateLinkPosition>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let link = match data.get_link_by_id(id).await {
Ok(n) => n,
Err(e) => return Json(e.into()),
};
if link.owner != user.id {
return Json(Error::NotAllowed.into());
}
if props.position < 0 {
return Json(Error::MiscError("Position must be an unsigned integer".to_string()).into());
}
// ...
match data.update_link_position(id, props.position).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Link updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub const MAXIMUM_FILE_SIZE: usize = 131072; // 128 KiB
pub async fn upload_icon_request(
jar: CookieJar,
Extension(data): Extension<State>,
JsonMultipart(images, props): JsonMultipart<UploadLinkIcon>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let id = match props.id.parse::<usize>() {
Ok(i) => i,
Err(_) => return Json(Error::Unknown.into()),
};
let link = match data.get_link_by_id(id).await {
Ok(n) => n,
Err(e) => return Json(e.into()),
};
if link.owner != user.id {
return Json(Error::NotAllowed.into());
}
// create upload
let upload = match data
.create_upload(MediaUpload::new(MediaType::Webp, user.id))
.await
{
Ok(n) => n,
Err(e) => return Json(e.into()),
};
let image = match images.get(0) {
Some(i) => i,
None => return Json(Error::MiscError("Missing file".to_string()).into()),
};
if image.len() > MAXIMUM_FILE_SIZE {
return Json(Error::FileTooLarge.into());
}
// upload
if let Err(e) = save_webp_buffer(&upload.path(&data.0.0).to_string(), image.to_vec(), None) {
return Json(Error::MiscError(e.to_string()).into());
}
// ...
match data.update_link_upload_id(id, upload.id as i64).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Link updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn delete_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageStacks) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let link = match data.get_link_by_id(id).await {
Ok(n) => n,
Err(e) => return Json(e.into()),
};
if link.owner != user.id {
return Json(Error::NotAllowed.into());
}
match data.delete_link(id).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Link deleted".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}

View file

@ -1,7 +1,6 @@
pub mod connections;
pub mod images;
pub mod ipbans;
pub mod links;
pub mod profile;
pub mod social;
pub mod user_warnings;

View file

@ -588,6 +588,8 @@ pub fn routes() -> Router {
.route("/notes/{id}/content", post(notes::update_content_request))
.route("/notes/{id}/dir", post(notes::update_dir_request))
.route("/notes/{id}/tags", post(notes::update_tags_request))
.route("/notes/{id}/global", post(notes::publish_request))
.route("/notes/{id}/global", delete(notes::unpublish_request))
.route("/notes/from_journal/{id}", get(notes::list_request))
.route("/notes/preview", post(notes::render_markdown_request))
.route(
@ -597,18 +599,6 @@ pub fn routes() -> Router {
// uploads
.route("/uploads/{id}", get(uploads::get_request))
.route("/uploads/{id}", delete(uploads::delete_request))
// links
.route("/links", get(auth::links::list_request))
.route("/links", post(auth::links::create_request))
.route("/links/{id}", get(auth::links::get_request))
.route("/links/{id}", delete(auth::links::delete_request))
.route("/links/icon", post(auth::links::upload_icon_request))
.route("/links/{id}/label", post(auth::links::update_label_request))
.route("/links/{id}/href", post(auth::links::update_href_request))
.route(
"/links/{id}/position",
post(auth::links::update_position_request),
)
}
#[derive(Deserialize)]
@ -982,29 +972,3 @@ pub struct RemoveJournalDir {
pub struct UpdateNoteTags {
pub tags: Vec<String>,
}
#[derive(Deserialize)]
pub struct CreateLink {
pub label: String,
pub href: String,
}
#[derive(Deserialize)]
pub struct UpdateLinkLabel {
pub label: String,
}
#[derive(Deserialize)]
pub struct UpdateLinkHref {
pub href: String,
}
#[derive(Deserialize)]
pub struct UpdateLinkPosition {
pub position: i32,
}
#[derive(Deserialize)]
pub struct UploadLinkIcon {
pub id: String,
}

View file

@ -164,11 +164,21 @@ pub async fn update_title_request(
// ...
match data.update_note_title(id, &user, &props.title).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Note updated".to_string(),
payload: (),
}),
Ok(_) => {
// update note global status
if note.is_global {
if let Err(e) = data.update_note_is_global(id, 0).await {
return Json(e.into());
}
}
// ...
Json(ApiReturn {
ok: true,
message: "Note updated".to_string(),
payload: (),
})
}
Err(e) => Json(e.into()),
}
}
@ -318,3 +328,92 @@ pub async fn update_tags_request(
Err(e) => Json(e.into()),
}
}
pub async fn publish_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let note = match data.get_note_by_id(id).await {
Ok(n) => n,
Err(e) => return Json(e.into()),
};
if user.id != note.owner {
return Json(Error::NotAllowed.into());
}
// check count
if data.get_user_global_notes_count(user.id).await.unwrap_or(0)
>= if user.permissions.check(FinePermission::SUPPORTER) {
10
} else {
5
}
{
return Json(
Error::MiscError(
"You already have the maximum number of global notes you can have".to_string(),
)
.into(),
);
}
// make sure note doesn't already exist globally
if data.get_global_note_by_title(&note.title).await.is_ok() {
return Json(
Error::MiscError(
"Note name is already in use globally. Please change the name and try again"
.to_string(),
)
.into(),
);
}
// ...
match data.update_note_is_global(id, 1).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Note updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn unpublish_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let note = match data.get_note_by_id(id).await {
Ok(n) => n,
Err(e) => return Json(e.into()),
};
if user.id != note.owner {
return Json(Error::NotAllowed.into());
}
// ...
match data.update_note_is_global(id, 0).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Note updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}

View file

@ -81,7 +81,7 @@ pub async fn app_request(
};
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
let mut context = initial_context(&data.0.0.0, lang, &Some(user.clone())).await;
context.insert("selected_journal", &selected_journal);
context.insert("selected_note", &selected_note);
@ -89,6 +89,7 @@ pub async fn app_request(
context.insert("journal", &journal);
context.insert("note", &note);
context.insert("owner", &user);
context.insert("journals", &journals);
context.insert("notes", &notes);
@ -185,6 +186,10 @@ pub async fn view_request(
context.insert("selected_note", &0);
} else {
context.insert("selected_note", &selected_note);
context.insert(
"redis_views",
&data.0.get_note_views(note.as_ref().unwrap().id).await,
);
}
context.insert("journal", &journal);
@ -292,3 +297,70 @@ pub async fn index_view_request(
// return
Ok(Html(data.1.render("journals/app.html", &context).unwrap()))
}
/// `/x/{note}`
pub async fn global_view_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(mut selected_note): Path<String>,
) -> impl IntoResponse {
let data = data.read().await;
let user = match get_user_from_token!(jar, data.0) {
Some(ua) => Some(ua),
None => None,
};
if selected_note == "index" {
selected_note = String::new();
}
// if we don't have a selected journal, we shouldn't be here probably
if selected_note == "journal.css" {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &user).await,
));
}
// ...
let note = match data.0.get_global_note_by_title(&selected_note).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
};
let journal = match data.0.get_journal_by_id(note.journal).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
};
// get owner
let owner = match data.0.get_user_by_id(note.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);
// ...
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &user).await;
data.0.incr_note_views(note.id).await;
context.insert("selected_journal", &note.journal);
context.insert("selected_note", &selected_note);
context.insert("redis_views", &data.0.get_note_views(note.id).await);
context.insert("journal", &journal);
context.insert("note", &note);
context.insert("owner", &owner);
context.insert::<[i8; 0], &str>("notes", &[]);
context.insert("view_mode", &true);
context.insert("is_editor", &false);
context.insert("global_mode", &true);
// return
Ok(Html(data.1.render("journals/app.html", &context).unwrap()))
}

View file

@ -1,47 +0,0 @@
use axum::{
extract::Path,
response::{Html, IntoResponse},
Extension,
};
use axum_extra::extract::CookieJar;
use tetratto_core::model::{permissions::FinePermission, Error};
use crate::{get_user_from_token, State};
use super::render_error;
/// `/links/{id}`
pub async fn navigate_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = data.read().await;
let user = match get_user_from_token!(jar, data.0) {
Some(ua) => ua,
None => {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &None).await,
));
}
};
let link = match data.0.get_link_by_id(id).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
let owner = match data.0.get_user_by_id(link.owner).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
if owner.permissions.check(FinePermission::SUPPORTER) {
if let Err(e) = data.0.incr_link_clicks(link.id).await {
return Err(Html(render_error(e, &jar, &data, &Some(user)).await));
}
}
Ok(Html(format!(
"<!doctype html /><html><head><meta http-equiv=\"refresh\" content=\"0; url={}\" /></head><body>Navigating...</body></html>",
link.href
)))
}

View file

@ -4,7 +4,6 @@ pub mod communities;
pub mod developer;
pub mod forge;
pub mod journals;
pub mod links;
pub mod misc;
pub mod mod_panel;
pub mod profile;
@ -138,8 +137,7 @@ pub fn routes() -> Router {
.route("/journals/{journal}/{note}", get(journals::app_request))
.route("/@{owner}/{journal}", get(journals::index_view_request))
.route("/@{owner}/{journal}/{note}", get(journals::view_request))
// links
.route("/links/{id}", get(links::navigate_request))
.route("/x/{note}", get(journals::global_view_request))
}
pub async fn render_error(