From c1568ad866de6c88b45733f339c17e2e1d6c495e Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 19 Jun 2025 15:48:04 -0400 Subject: [PATCH] add: journals + notes --- Cargo.lock | 8 +- crates/app/Cargo.toml | 2 +- crates/app/src/assets.rs | 5 + crates/app/src/langs/en-US.toml | 11 + crates/app/src/macros.rs | 13 +- crates/app/src/public/css/chats.css | 232 ++++++++ crates/app/src/public/css/root.css | 2 +- crates/app/src/public/css/style.css | 139 ++++- crates/app/src/public/html/chats/app.lisp | 225 +------- crates/app/src/public/html/components.lisp | 110 ++++ crates/app/src/public/html/journals/app.lisp | 543 ++++++++++++++++++ crates/app/src/public/html/root.lisp | 2 +- crates/app/src/public/html/stacks/manage.lisp | 2 +- crates/app/src/public/js/atto.js | 2 + crates/app/src/routes/api/v1/journals.rs | 22 +- crates/app/src/routes/api/v1/mod.rs | 10 +- crates/app/src/routes/api/v1/notes.rs | 50 +- crates/app/src/routes/assets.rs | 1 + crates/app/src/routes/mod.rs | 1 + crates/app/src/routes/pages/journals.rs | 209 +++++++ crates/app/src/routes/pages/mod.rs | 12 + crates/core/Cargo.toml | 2 +- crates/core/src/database/journals.rs | 36 +- crates/core/src/database/notes.rs | 53 +- crates/l10n/Cargo.toml | 2 +- crates/shared/Cargo.toml | 2 +- 26 files changed, 1431 insertions(+), 265 deletions(-) create mode 100644 crates/app/src/public/css/chats.css create mode 100644 crates/app/src/public/html/journals/app.lisp create mode 100644 crates/app/src/routes/pages/journals.rs diff --git a/Cargo.lock b/Cargo.lock index 48e412e..a11c634 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3231,7 +3231,7 @@ dependencies = [ [[package]] name = "tetratto" -version = "8.0.0" +version = "9.0.0" dependencies = [ "ammonia", "async-stripe", @@ -3262,7 +3262,7 @@ dependencies = [ [[package]] name = "tetratto-core" -version = "8.0.0" +version = "9.0.0" dependencies = [ "async-recursion", "base16ct", @@ -3284,7 +3284,7 @@ dependencies = [ [[package]] name = "tetratto-l10n" -version = "8.0.0" +version = "9.0.0" dependencies = [ "pathbufd", "serde", @@ -3293,7 +3293,7 @@ dependencies = [ [[package]] name = "tetratto-shared" -version = "8.0.0" +version = "9.0.0" dependencies = [ "ammonia", "chrono", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 41eec67..e29dcb9 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto" -version = "8.0.0" +version = "9.0.0" edition = "2024" [dependencies] diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index bf2a64c..a3bb588 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -32,6 +32,7 @@ pub const FAVICON: &str = include_str!("./public/images/favicon.svg"); pub const STYLE_CSS: &str = include_str!("./public/css/style.css"); pub const ROOT_CSS: &str = include_str!("./public/css/root.css"); pub const UTILITY_CSS: &str = include_str!("./public/css/utility.css"); +pub const CHATS_CSS: &str = include_str!("./public/css/chats.css"); // js pub const LOADER_JS: &str = include_str!("./public/js/loader.js"); @@ -125,6 +126,8 @@ pub const DEVELOPER_HOME: &str = include_str!("./public/html/developer/home.lisp pub const DEVELOPER_APP: &str = include_str!("./public/html/developer/app.lisp"); pub const DEVELOPER_LINK: &str = include_str!("./public/html/developer/link.lisp"); +pub const JOURNALS_APP: &str = include_str!("./public/html/journals/app.lisp"); + // langs pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); @@ -414,6 +417,8 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"developer/app.html"(crate::assets::DEVELOPER_APP) --config=config --lisp plugins); write_template!(html_path->"developer/link.html"(crate::assets::DEVELOPER_LINK) --config=config --lisp plugins); + write_template!(html_path->"journals/app.html"(crate::assets::JOURNALS_APP) -d "journals" --config=config --lisp plugins); + html_path } diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 76e0490..b725251 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -16,6 +16,7 @@ version = "1.0.0" "general:link.ip_bans" = "IP bans" "general:link.stats" = "Stats" "general:link.search" = "Search" +"general:link.journals" = "Journals" "general:action.save" = "Save" "general:action.delete" = "Delete" "general:action.purge" = "Purge" @@ -231,3 +232,13 @@ version = "1.0.0" "developer:label.guides_and_help" = "Guides & help" "developer:action.delete" = "Delete app" "developer:action.authorize" = "Authorize" + +"journals:label.my_journals" = "My journals" +"journals:action.create_journal" = "Create journal" +"journals:action.create_note" = "Create note" +"journals:label.welcome" = "Welcome to Journals!" +"journals:label.select_a_journal" = "Select or create a journal to get started." +"journals:label.select_a_note" = "Select or create a note in this journal to get started." +"journals:label.mobile_click_my_journals" = "Click \"My journals\" at the top to open the sidebar." +"journals:label.editor" = "Editor" +"journals:label.preview_pane" = "Preview" diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index 6377581..01406bb 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -193,7 +193,10 @@ macro_rules! check_user_blocked_or_private { // check if other user is banned if $other_user.permissions.check_banned() { if let Some(ref ua) = $user { - if !ua.permissions.check(FinePermission::MANAGE_USERS) { + if !ua + .permissions + .check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS) + { $crate::user_banned!($user, $other_user, $data, $jar); } } else { @@ -213,7 +216,9 @@ macro_rules! check_user_blocked_or_private { .get_user_stack_blocked_users($other_user.id) .await .contains(&ua.id)) - && !ua.permissions.check(FinePermission::MANAGE_USERS) + && !ua + .permissions + .check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS) { let lang = get_lang!($jar, $data.0); let mut context = initial_context(&$data.0.0.0, lang, &$user).await; @@ -238,7 +243,9 @@ macro_rules! check_user_blocked_or_private { if $other_user.settings.private_profile { if let Some(ref ua) = $user { if (ua.id != $other_user.id) - && !ua.permissions.check(FinePermission::MANAGE_USERS) + && !ua + .permissions + .check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS) && $data .0 .get_userfollow_by_initiator_receiver($other_user.id, ua.id) diff --git a/crates/app/src/public/css/chats.css b/crates/app/src/public/css/chats.css new file mode 100644 index 0000000..b98db51 --- /dev/null +++ b/crates/app/src/public/css/chats.css @@ -0,0 +1,232 @@ +:root { + --list-bar-width: 64px; + --channels-bar-width: 256px; + --sidebar-height: calc(100dvh - 42px); + --channel-header-height: 48px; +} + +html, +body { + overflow: hidden; +} + +.name.shortest { + max-width: 165px; + overflow-wrap: normal; +} + +.send_button { + width: 48px; + height: 48px; +} + +.send_button .icon { + width: 2em; + height: 2em; +} + +a.channel_icon { + width: 48px; + height: 48px; + min-height: 48px; +} + +a.channel_icon .icon { + min-width: 24px; + height: 24px; +} + +a.channel_icon.small { + width: 24px; + height: 24px; + min-height: 24px; +} + +a.channel_icon.small .icon { + min-width: 12px; + height: 12px; +} + +a.channel_icon:has(img) { + padding: 0; +} + +a.channel_icon img { + min-width: 48px; + min-height: 48px; +} + +a.channel_icon img, +a.channel_icon:has(.icon) { + transition: + outline 0.25s, + background 0.15s !important; +} + +a.channel_icon:not(.selected):hover img, +a.channel_icon:not(.selected):hover:has(.icon) { + outline: solid 1px var(--color-text); +} +a.channel_icon.selected img, +a.channel_icon.selected:has(.icon) { + outline: solid 2px var(--color-text); +} + +nav { + background: var(--color-raised); + color: var(--color-text-raised) !important; + height: 42px; + position: sticky !important; +} + +nav::after { + display: block; + position: absolute; + background: var(--color-super-lowered); + height: 1px; + width: calc(100% - var(--list-bar-width)); + bottom: 0; + left: var(--list-bar-width); + content: ""; +} + +nav .content_container { + max-width: 100% !important; + width: 100%; +} + +.chats_nav { + display: none; + padding: 0; +} + +.chats_nav button { + justify-content: flex-start; + width: 100% !important; + flex-direction: row !important; + font-size: 16px !important; + margin-top: -4px; +} + +.chats_nav button svg { + margin-right: var(--pad-4); +} + +.sidebar { + background: var(--color-raised); + color: var(--color-text-raised); + border-right: solid 1px var(--color-super-lowered); + padding: 0.4rem; + width: max-content; + height: var(--sidebar-height); + overflow: auto; + transition: left 0.15s; + z-index: 2; +} + +.sidebar .title:not(.dropdown *) { + padding: var(--pad-4); + border-bottom: solid 1px var(--color-super-lowered); +} + +.sidebar#channels_list { + width: var(--channels-bar-width); + background: var(--color-surface); + color: var(--color-text); +} + +.sidebar#notes_list { + width: calc(var(--channels-bar-width) + var(--list-bar-width)); + flex: 1 0 auto; +} + +#stream { + width: calc( + 100dvw - var(--list-bar-width) - var(--channels-bar-width) + ) !important; + height: var(--sidebar-height); +} + +.message { + transition: background 0.15s; + box-shadow: none; + position: relative; +} + +.message:hover { + background: var(--color-raised); +} + +.message:hover .hidden, +.message:focus .hidden, +.message:active .hidden { + display: flex !important; +} + +.message.grouped { + padding: var(--pad-1) var(--pad-4) var(--pad-1) + calc(var(--pad-4) + var(--pad-2) + 42px); +} + +turbo-frame { + display: contents; +} + +.channel_header { + height: var(--channel-header-height); +} + +.members_list_half { + padding-top: var(--pad-4); + border-top: solid 1px var(--color-super-lowered); +} + +.channels_list_half:not(.no_members), +.members_list_half { + overflow: auto; + height: calc( + (var(--sidebar-height) - var(--channel-header-height) - 8rem) / 2 + ); +} + +@media screen and (max-width: 900px) { + :root { + --sidebar-height: calc(100dvh - 42px * 2); + } + + .message.grouped { + padding: var(--pad-1) var(--pad-4) var(--pad-1) + calc(var(--pad-4) + var(--pad-2) + 31px); + } + + body:not(.sidebars_shown) .sidebar { + position: absolute; + left: -200%; + } + + body.sidebars_shown .sidebar { + position: absolute; + } + + #stream { + width: 100dvw !important; + height: var(--sidebar-height); + } + + nav::after { + width: 100dvw; + left: 0; + } + + .chats_nav { + display: flex; + } + + nav:has(+ .chats_nav) .dropdown .inner { + top: calc(100% + 44px); + } + + .padded_section { + padding: 0 !important; + } +} diff --git a/crates/app/src/public/css/root.css b/crates/app/src/public/css/root.css index 41db0d5..fbb1d4d 100644 --- a/crates/app/src/public/css/root.css +++ b/crates/app/src/public/css/root.css @@ -116,7 +116,7 @@ article { padding: 0; } - body .card:not(.card *):not(#stream *):not(.user_plate), + body .card:not(.card *):not(.user_plate), body .pillmenu:not(.card *) > a, body .card-nest:not(.card *) > .card, body .banner { diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index 8e9bbce..f592c77 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -273,6 +273,12 @@ button, font-weight: 600; } +button:disabled, +.button:disabled { + cursor: not-allowed; + opacity: 50%; +} + button.small, .button.small { /* min-height: max-content; */ @@ -714,6 +720,13 @@ nav .button:not(.title):not(.active):hover { border-bottom-right-radius: var(--radius) !important; } +@media screen and (min-width: 900px) { + .mobile_nav:not(.mobile) { + border-radius: var(--radius); + border: solid 1px var(--color-super-lowered); + } +} + /* dialog */ dialog { padding: 0; @@ -1072,7 +1085,7 @@ details[open] summary::after { animation: fadein ease-in-out 1 0.1s forwards running; } -details .card { +details > .card { background: var(--color-super-raised); } @@ -1113,3 +1126,127 @@ details.accordion .inner { border: solid 1px var(--color-super-lowered); border-top: none; } + +/* codemirror */ +.CodeMirror { + color: var(--color-text) !important; +} + +.CodeMirror { + background: transparent !important; + font-family: inherit !important; + height: 10rem !important; + min-height: 100%; + max-height: 100%; + cursor: text; +} + +.CodeMirror-cursor { + border-color: rgb(0, 0, 0) !important; +} + +.CodeMirror-cursor:is(.dark *) { + border-color: rgb(255, 255, 255) !important; +} + +.CodeMirror-cursor { + height: 22px !important; +} + +[role="presentation"]::-moz-selection, +[role="presentation"] *::-moz-selection { + background-color: rgb(191, 219, 254) !important; +} + +[role="presentation"]::selection, +[role="presentation"] *::selection, +.CodeMirror-selected { + background-color: rgb(191, 219, 254) !important; +} + +[role="presentation"]:is(.dark *)::-moz-selection, +[role="presentation"] *:is(.dark *)::-moz-selection { + background-color: rgb(64, 64, 64) !important; +} + +[role="presentation"]:is(.dark *)::selection, +[role="presentation"] *:is(.dark *)::selection, +.CodeMirror-selected:is(.dark *) { + background-color: rgb(64, 64, 64) !important; +} + +.cm-header { + color: inherit !important; +} + +.cm-variable-2, +.cm-quote, +.cm-keyword, +.cm-string, +.cm-atom { + color: rgb(63, 98, 18) !important; +} + +.cm-variable-2:is(.dark *), +.cm-quote:is(.dark *), +.cm-keyword:is(.dark *), +.cm-string:is(.dark *), +.cm-atom:is(.dark *) { + color: rgb(217, 249, 157) !important; +} + +.cm-comment { + color: rgb(153 27 27) !important; +} + +.cm-comment:is(.dark *) { + color: rgb(254, 202, 202) !important; +} + +.cm-comment { + font-family: ui-monospace, monospace; +} + +.cm-link { + color: var(--color-link) !important; +} + +.cm-url, +.cm-property, +.cm-qualifier { + color: rgb(29, 78, 216) !important; +} + +.cm-url:is(.dark *), +.cm-property:is(.dark *), +.cm-qualifier:is(.dark *) { + color: rgb(191, 219, 254) !important; +} + +.cm-variable-3, +.cm-tag, +.cm-def, +.cm-attribute, +.cm-number { + color: rgb(91, 33, 182) !important; +} + +.cm-variable-3:is(.dark *), +.cm-tag:is(.dark *), +.cm-def:is(.dark *), +.cm-attribute:is(.dark *), +.cm-number:is(.dark *) { + color: rgb(221, 214, 254) !important; +} + +.CodeMirror { + height: auto !important; +} + +.CodeMirror-line { + padding-left: 0 !important; +} + +.CodeMirror-focused .CodeMirror-placeholder { + opacity: 50%; +} diff --git a/crates/app/src/public/html/chats/app.lisp b/crates/app/src/public/html/chats/app.lisp index e7cc4ec..a24ca27 100644 --- a/crates/app/src/public/html/chats/app.lisp +++ b/crates/app/src/public/html/chats/app.lisp @@ -1,7 +1,7 @@ (text "{% extends \"root.html\" %} {% block head %}") (title (text "Chats - {{ config.name }}")) - +(link ("rel" "stylesheet") ("data-turbo-temporary" "true") ("href" "/css/chats.css?v=tetratto-{{ random_cache_breaker }}")) (text "{% endblock %} {% block body %} {{ macros::nav(selected=\"chats\", hide_user_menu=true) }}") (nav ("class" "chats_nav") @@ -16,7 +16,6 @@ (b (text "{{ text \"chats:label.my_chats\" }}")) (text "{%- endif %}"))) - (div ("class" "flex") (div @@ -87,7 +86,7 @@ (text "{{ components::user_plate(user=user, show_menu=true) }}")) (text "{% if channel -%}") (div - ("class" "w-full flex flex-col gap-2") + ("class" "w-full flex flex-col gap-2 padded_section") ("id" "stream") ("style" "padding: var(--pad-4)") (turbo-frame @@ -110,225 +109,6 @@ ("title" "Send") (text "{{ icon \"send-horizontal\" }}")))) (text "{%- endif %}") - (style - (text ":root { - --list-bar-width: 64px; - --channels-bar-width: 256px; - --sidebar-height: calc(100dvh - 42px); - --channel-header-height: 48px; - } - - html, - body { - overflow: hidden; - } - - .name.shortest { - max-width: 165px; - overflow-wrap: normal; - } - - .send_button { - width: 48px; - height: 48px; - } - - .send_button .icon { - width: 2em; - height: 2em; - } - - a.channel_icon { - width: 48px; - height: 48px; - min-height: 48px; - } - - a.channel_icon .icon { - min-width: 24px; - height: 24px; - } - - a.channel_icon.small { - width: 24px; - height: 24px; - min-height: 24px; - } - - a.channel_icon.small .icon { - min-width: 12px; - height: 12px; - } - - a.channel_icon:has(img) { - padding: 0; - } - - a.channel_icon img { - min-width: 48px; - min-height: 48px; - } - - a.channel_icon img, - a.channel_icon:has(.icon) { - transition: - outline 0.25s, - background 0.15s !important; - } - - a.channel_icon:not(.selected):hover img, - a.channel_icon:not(.selected):hover:has(.icon) { - outline: solid 1px var(--color-text); - } - a.channel_icon.selected img, - a.channel_icon.selected:has(.icon) { - outline: solid 2px var(--color-text); - } - - nav { - background: var(--color-raised); - color: var(--color-text-raised) !important; - height: 42px; - position: sticky !important; - } - - nav::after { - display: block; - position: absolute; - background: var(--color-super-lowered); - height: 1px; - width: calc(100% - var(--list-bar-width)); - bottom: 0; - left: var(--list-bar-width); - content: \"\"; - } - - nav .content_container { - max-width: 100% !important; - width: 100%; - } - - .chats_nav { - display: none; - padding: 0; - } - - .chats_nav button { - justify-content: flex-start; - width: 100% !important; - flex-direction: row !important; - font-size: 16px !important; - margin-top: -4px; - } - - .chats_nav button svg { - margin-right: var(--pad-4); - } - - .sidebar { - background: var(--color-raised); - color: var(--color-text-raised); - border-right: solid 1px var(--color-super-lowered); - padding: 0.4rem; - width: max-content; - height: var(--sidebar-height); - overflow: auto; - transition: left 0.15s; - z-index: 1; - } - - .sidebar .title:not(.dropdown *) { - padding: var(--pad-4); - border-bottom: solid 1px var(--color-super-lowered); - } - - .sidebar#channels_list { - width: var(--channels-bar-width); - background: var(--color-surface); - color: var(--color-text); - } - - #stream { - width: calc( - 100dvw - var(--list-bar-width) - var(--channels-bar-width) - ) !important; - height: var(--sidebar-height); - } - - .message { - transition: background 0.15s; - box-shadow: none; - position: relative; - } - - .message:hover { - background: var(--color-raised); - } - - .message:hover .hidden, - .message:focus .hidden, - .message:active .hidden { - display: flex !important; - } - - .message.grouped { - padding: var(--pad-1) var(--pad-4) var(--pad-1) calc(var(--pad-4) + var(--pad-2) + 42px); - } - - turbo-frame { - display: contents; - } - - .channel_header { - height: var(--channel-header-height); - } - - .members_list_half { - padding-top: var(--pad-4); - border-top: solid 1px var(--color-super-lowered); - } - - .channels_list_half:not(.no_members), - .members_list_half { - overflow: auto; - height: calc( - (var(--sidebar-height) - var(--channel-header-height) - 8rem) / - 2 - ); - } - - @media screen and (max-width: 900px) { - :root { - --sidebar-height: calc(100dvh - 42px * 2); - } - - .message.grouped { - padding: var(--pad-1) var(--pad-4) var(--pad-1) calc(var(--pad-4) + var(--pad-2) + 31px); - } - - body:not(.sidebars_shown) .sidebar { - position: absolute; - left: -200%; - } - - body.sidebars_shown .sidebar { - position: absolute; - } - - #stream { - width: 100dvw !important; - height: var(--sidebar-height); - } - - nav::after { - width: 100dvw; - left: 0; - } - - .chats_nav { - display: flex; - } - }")) (script (text "window.CURRENT_PAGE = Number.parseInt(\"{{ page }}\"); window.VIEWING_SINGLE = \"{{ message }}\".length > 0; @@ -684,5 +464,4 @@ } }, 100);")) (text "{%- endif %}")) - (text "{% endblock %}") diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index d5b6805..75a24ef 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -976,6 +976,10 @@ (text "{{ icon \"circle-user-round\" }}") (span (text "{{ text \"auth:link.my_profile\" }}"))) + (a + ("href" "/journals/0/0") + (icon (text "notebook")) + (str (text "general:link.journals"))) (a ("href" "/settings") (text "{{ icon \"settings\" }}") @@ -1851,3 +1855,109 @@ (text "{{ stack.created }}")) (text "; {{ stack.privacy }}; {{ stack.users|length }} users"))) (text "{%- endmacro %}") + +(text "{% macro journal_listing(journal, notes, selected_note, selected_journal, view_mode=false, owner=false) -%}") +(text "{% if selected_journal != journal.id -%}") +; not selected +(div + ("class" "flex flex-row gap-1") + (a + ("href" "/journals/{{ journal.id }}/0?view={{ view_mode }}") + ("class" "button justify-start lowered w-full") + (icon (text "notebook")) + (text "{{ journal.title }}")) + + (div + ("class" "dropdown") + (button + ("class" "big_icon lowered") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + ("style" "width: 32px") + (text "{{ icon \"ellipsis\" }}")) + (div + ("class" "inner") + (button + ("onclick" "delete_journal('{{ journal.id }}')") + ("class" "red") + (text "{{ icon \"trash\" }}") + (span + (text "{{ text \"general:action.delete\" }}")))))) +(text "{% else %}") +; selected +(div + ("class" "flex flex-row gap-1") + (button + ("class" "justify-start lowered w-full") + (icon (text "arrow-down")) + (text "{{ journal.title }}")) + + (text "{% if user and user.id == journal.owner -%}") + (div + ("class" "dropdown") + (button + ("class" "big_icon lowered") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + ("style" "width: 32px") + (text "{{ icon \"ellipsis\" }}")) + (div + ("class" "inner") + (a + ("class" "button") + ("href" "/journals/{{ journal.id }}/0?view={{ view_mode }}") + (icon (text "house")) + (str (text "general:link.home"))) + (button + ("onclick" "delete_journal('{{ journal.id }}')") + ("class" "red") + (icon (text "trash")) + (str (text "general:action.delete"))))) + (text "{%- endif %}")) + +(div + ("class" "flex flex-col gap-2") + ("style" "margin-left: 10px; padding-left: 5px; border-left: solid 2px var(--color-super-lowered); width: calc(100% - 10px)") + ; create note + (text "{% if user and user.id == journal.owner -%}") + (button + ("class" "lowered justify-start w-full") + ("onclick" "create_note()") + (icon (text "plus")) + (str (text "journals:action.create_note"))) + (text "{%- endif %}") + + ; note listings + (text "{% for note in notes %}") + (div + ("class" "flex flex-row gap-1") + (a + ("href" "{% if owner -%} /@{{ owner.username }}/{{ journal.title }}/{{ note.title }} {%- else -%} /journals/{{ journal.id }}/{{ note.id }} {%- endif %}") + ("class" "button justify-start w-full {% if selected_note == note.id -%} lowered {%- else -%} raised {%- endif %}") + (icon (text "file-text")) + (text "{{ note.title }}")) + + (text "{% if user and user.id == journal.owner -%}") + (div + ("class" "dropdown") + (button + ("class" "big_icon {% if selected_note == note.id -%} lowered {%- else -%} raised {%- endif %}") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + ("style" "width: 32px") + (text "{{ icon \"ellipsis\" }}")) + (div + ("class" "inner") + (button + ("onclick" "change_note_title('{{ note.id }}')") + (icon (text "pencil")) + (str (text "chats:action.rename"))) + (button + ("onclick" "delete_note('{{ note.id }}')") + ("class" "red") + (icon (text "trash")) + (str (text "general:action.delete"))))) + (text "{%- endif %}")) + (text "{% 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 new file mode 100644 index 0000000..267541a --- /dev/null +++ b/crates/app/src/public/html/journals/app.lisp @@ -0,0 +1,543 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(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 }}")) + +(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 }}")) +(text "{% else %}") +; redirect to journal homepage +(meta ("http-equiv" "refresh") ("content" "0; url=/@{{ user.username }}/{{ journal.title }}")) +(text "{%- endif %} {%- endif %}") +(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"journals\") }}") +(text "{% if not view_mode -%}") +(nav + ("class" "chats_nav") + (button + ("class" "flex gap-2 items-center active") + ("onclick" "toggle_sidebars(event)") + (text "{{ icon \"panel-left\" }} {% if community -%}") + (b + ("class" "name shorter") + (text "{% if community.context.display_name -%} {{ community.context.display_name }} {% else %} {{ community.title }} {%- endif %}")) + (text "{% else %}") + (b + (text "{{ text \"journals:label.my_journals\" }}")) + (text "{%- endif %}"))) +(text "{%- endif %}") +(div + ("class" "flex") + ; journals/notes listing + (text "{% if not view_mode -%}") + ; this isn't shown if we're in view mode + (div + ("class" "sidebar flex flex-col gap-2 justify-between") + ("id" "notes_list") + (div + ("class" "flex flex-col gap-2 w-full") + (button + ("class" "lowered justify-start w-full") + ("onclick" "create_journal()") + (icon (text "plus")) + (str (text "journals:action.create_journal"))) + + (text "{% for journal in journals %}") + (text "{{ components::journal_listing(journal=journal, notes=notes, selected_note=selected_note, selected_journal=selected_journal, view_mode=view_mode) }}") + (text "{% endfor %}"))) + (text "{%- endif %}") + ; editor + (div + ("class" "w-full padded_section") + ("id" "editor") + ("style" "padding: var(--pad-4)") + (main + ("class" "flex flex-col gap-2") + ; the journal/note header is always shown + (text "{% if journal -%}") + (div + ("class" "mobile_nav w-full flex items-center justify-between gap-2") + (div + ("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\") }}")) + + (a + ("class" "flush") + ("href" "{% if view_mode -%} /@{{ owner.username }}/{{ journal.title }}/index {%- else -%} /journals/{{ journal.id }}/0 {%- endif %}") + (b (text "{{ journal.title }}"))) + + (text "{% if note -%}") + (span (text "/")) + (b (text "{{ note.title }}")) + (text "{%- endif %}")) + + (text "{% if user and user.id == journal.owner -%}") + (div + ("class" "pillmenu") + (a + ("class" "{% if not view_mode -%}active{%- endif %}") + ("href" "/journals/{{ journal.id }}/{% if note -%} {{ note.id }} {%- else -%} 0 {%- endif %}") + ("data-turbo" "false") + (icon (text "pencil"))) + (a + ("class" "{% if view_mode -%}active{%- endif %}") + ("href" "/@{{ user.username }}/{{ journal.title }}/{% if note -%} {{ note.title }} {%- else -%} index {%- endif %}") + (icon (text "eye")))) + (text "{%- endif %}")) + (text "{%- endif %}") + + ; we're going to put some help panes in here if something is 0 + ; ...people got confused on the chats page because they somehow couldn't figure out how to open the sidebar + (text "{% if selected_journal == 0 -%}") + ; no journal selected + (div + ("class" "card w-full flex flex-col gap-2") + (h3 (str (text "journals:label.welcome"))) + (span (str (text "journals:label.select_a_journal"))) + (button + ("onclick" "create_journal()") + (icon (text "plus")) + (str (text "journals:action.create_journal")))) + (text "{% elif selected_note == 0 -%}") + ; journal selected, but no note is selected + (text "{% if not view_mode -%}") + ; we're the journal owner and we're not in view mode + (div + ("class" "card w-full flex flex-col gap-2") + (h3 (text "{{ journal.title }}")) + (span (str (text "journals:label.select_a_note"))) + (button + ("onclick" "create_note()") + (icon (text "plus")) + (str (text "journals:action.create_note")))) + + ; we'll also let users edit the journal's settings here i guess + (details + ("class" "w-full") + (summary + ("class" "button lowered w-full justify-start") + (icon (text "settings")) + (str (text "general:action.manage"))) + + (div + ("class" "card flex flex-col gap-2 lowered") + (div + ("class" "card-nest") + (div + ("class" "card small") + (b + (text "Privacy"))) + (div + ("class" "card") + (select + ("onchange" "change_journal_privacy(event)") + (option + ("value" "Private") + ("selected" "{% if journal.privacy == 'Private' -%}true{% else %}false{%- endif %}") + (text "Private")) + (option + ("value" "Public") + ("selected" "{% if journal.privacy == 'Public' -%}true{% else %}false{%- endif %}") + (text "Public"))))) + + (div + ("class" "card-nest") + (div + ("class" "card small") + (label + ("for" "title") + (b (str (text "communities:label.title"))))) + + (form + ("class" "card flex flex-col gap-2") + ("onsubmit" "change_journal_title(event)") + (div + ("class" "flex flex-col gap-1") + (input + ("type" "text") + ("name" "title") + ("id" "title") + ("placeholder" "title") + ("required" "") + ("minlength" "2"))) + (button + ("class" "primary") + (text "{{ icon \"check\" }}") + (span + (text "{{ text \"general:action.save\" }}"))))))) + (text "{% else %}") + ; we're in view mode; just show journal listing and notes as journal homepage + (div + ("class" "card flex flex-col gap-2") + (text "{{ components::journal_listing(journal=journal, notes=notes, selected_note=selected_note, selected_journal=selected_journal, view_mode=view_mode, owner=owner) }}")) + (text "{%- endif %}") + (text "{% else %}") + ; journal AND note selected + (text "{% if not view_mode -%}") + ; not view mode; show editor + ; 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")) + + ; tab bar + (div + ("class" "pillmenu") + (a + ("href" "#/editor") + ("data-tab-button" "editor") + ("data-turbo" "false") + ("class" "active") + (str (text "journals:label.editor"))) + + (a + ("href" "#/preview") + ("data-tab-button" "preview") + ("data-turbo" "false") + (str (text "journals:label.preview_pane")))) + + ; tabs + (div + ("data-tab" "editor") + ("class" "flex flex-col gap-2 card") + ("style" "animation: fadein ease-in-out 1 0.5s forwards running") + ("id" "editor_tab")) + + (div + ("data-tab" "preview") + ("class" "flex flex-col gap-2 card hidden") + ("style" "animation: fadein ease-in-out 1 0.5s forwards running") + ("id" "preview_tab")) + + (button + ("onclick" "change_note_content('{{ note.id }}')") + (icon (text "check")) + (str (text "general:action.save"))) + + ; init codemirror + (script ("id" "editor_content") ("type" "text/markdown") (text "{{ note.content|remove_script_tags|safe }}")) + (script + (text "setTimeout(() => { + globalThis.editor = CodeMirror(document.getElementById(\"editor_tab\"), { + value: document.getElementById(\"editor_content\").innerHTML, + mode: \"markdown\", + lineWrapping: true, + autoCloseBrackets: true, + autofocus: true, + viewportMargin: Number.POSITIVE_INFINITY, + inputStyle: \"contenteditable\", + highlightFormatting: false, + fencedCodeBlockHighlighting: false, + xml: false, + smartIndent: false, + placeholder: `# {{ note.title }}`, + extraKeys: { + Home: \"goLineLeft\", + End: \"goLineRight\", + Enter: (cm) => { + cm.replaceSelection(\"\\n\"); + }, + }, + }); + + document.querySelector(\"[data-tab-button=editor]\").addEventListener(\"click\", async (e) => { + e.preventDefault(); + trigger(\"atto::hooks::tabs:switch\", [\"editor\"]); + }); + + document.querySelector(\"[data-tab-button=preview]\").addEventListener(\"click\", async (e) => { + e.preventDefault(); + const res = await ( + await fetch(\"/api/v1/notes/preview\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + content: globalThis.editor.getValue(), + }), + }) + ).text(); + + document.getElementById(\"preview_tab\").innerHTML = res; + trigger(\"atto::hooks::tabs:switch\", [\"preview\"]); + }); + }, 150);")) + (text "{% else %}") + ; we're just viewing this note + (div + ("class" "flex flex-col gap-2 card") + (text "{{ note.content|markdown|safe }}")) + + (span (text "Last updated: ") (span ("class" "date") (text "{{ note.edited }}"))) + (text "{%- endif %}") + (text "{%- endif %}"))) + (style + (text "nav::after { + width: 100%; + left: 0; + }")) + (script + (text "window.JOURNAL_PROPS = { + selected_journal: \"{{ selected_journal }}\", + selected_note: \"{{ selected_note }}\", + }; + + // journals/notes + globalThis.create_journal = async () => { + const title = await trigger(\"atto::prompt\", [\"Journal title:\"]); + + if (!title) { + return; + } + + fetch(\"/api/v1/journals\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + title, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + setTimeout(() => { + window.location.href = `/journals/${res.payload}/0`; + }, 100); + } + }); + } + + globalThis.create_note = async () => { + const title = await trigger(\"atto::prompt\", [\"Note title:\"]); + + if (!title) { + return; + } + + fetch(\"/api/v1/notes\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + title, + content: `# ${title}`, + journal: \"{{ selected_journal }}\", + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + setTimeout(() => { + window.location.href = `/journals/{{ selected_journal }}/${res.payload}`; + }, 100); + } + }); + } + + globalThis.delete_journal = async (id) => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(`/api/v1/journals/${id}`, { + method: \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + setTimeout(() => { + window.location.href = \"/journals\"; + }, 100); + } + }); + } + + globalThis.delete_note = async (id) => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(`/api/v1/notes/${id}`, { + method: \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + setTimeout(() => { + window.location.href = \"/journals/{{ selected_journal }}/0\"; + }, 100); + } + }); + } + + globalThis.change_journal_title = async (e) => { + e.preventDefault(); + fetch(\"/api/v1/journals/{{ selected_journal }}/title\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + title: e.target.title.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + e.reset(); + } + }); + } + + globalThis.change_journal_privacy = async (e) => { + e.preventDefault(); + const selected = event.target.selectedOptions[0]; + fetch(\"/api/v1/journals/{{ selected_journal }}/privacy\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + privacy: selected.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + globalThis.change_note_title = async (id) => { + const title = await trigger(\"atto::prompt\", [\"New note title:\"]); + + if (!title) { + return; + } + + fetch(`/api/v1/notes/${id}/title`, { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + title, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + e.reset(); + } + }); + } + + globalThis.change_note_content = async (id) => { + fetch(`/api/v1/notes/${id}/content`, { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + content: globalThis.editor.getValue(), + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + // sidebars + window.SIDEBARS_OPEN = false; + if (new URLSearchParams(window.location.search).get(\"nav\") === \"true\") { + window.SIDEBARS_OPEN = true; + } + + if ( + window.SIDEBARS_OPEN && + !document.body.classList.contains(\"sidebars_shown\") + ) { + toggle_sidebars(); + window.SIDEBARS_OPEN = true; + } + + for (const anchor of document.querySelectorAll(\"[data-turbo=false]\")) { + anchor.href += `?nav=${window.SIDEBARS_OPEN}`; + } + + function toggle_sidebars() { + window.SIDEBARS_OPEN = !window.SIDEBARS_OPEN; + + for (const anchor of document.querySelectorAll( + \"[data-turbo=false]\", + )) { + anchor.href = anchor.href.replace( + `?nav=${!window.SIDEBARS_OPEN}`, + `?nav=${window.SIDEBARS_OPEN}`, + ); + } + + const notes_list = document.getElementById(\"notes_list\"); + + if (document.body.classList.contains(\"sidebars_shown\")) { + // hide + document.body.classList.remove(\"sidebars_shown\"); + notes_list.style.left = \"-200%\"; + } else { + // show + document.body.classList.add(\"sidebars_shown\"); + notes_list.style.left = \"0\"; + } + }"))) +(text "{% endblock %}") diff --git a/crates/app/src/public/html/root.lisp b/crates/app/src/public/html/root.lisp index d126e16..83dd9af 100644 --- a/crates/app/src/public/html/root.lisp +++ b/crates/app/src/public/html/root.lisp @@ -9,7 +9,7 @@ (meta ("http-equiv" "X-UA-Compatible") ("content" "ie=edge")) (link ("rel" "icon") ("href" "/public/favicon.svg")) - (link ("rel" "stylesheet") ("href" "/css/style.css")) + (link ("rel" "stylesheet") ("href" "/css/style.css?v=tetratto-{{ random_cache_breaker }}")) (text "{% if user -%}