add: journals + notes
This commit is contained in:
parent
c08a26ae8d
commit
c1568ad866
26 changed files with 1431 additions and 265 deletions
8
Cargo.lock
generated
8
Cargo.lock
generated
|
@ -3231,7 +3231,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tetratto"
|
name = "tetratto"
|
||||||
version = "8.0.0"
|
version = "9.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ammonia",
|
"ammonia",
|
||||||
"async-stripe",
|
"async-stripe",
|
||||||
|
@ -3262,7 +3262,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tetratto-core"
|
name = "tetratto-core"
|
||||||
version = "8.0.0"
|
version = "9.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-recursion",
|
"async-recursion",
|
||||||
"base16ct",
|
"base16ct",
|
||||||
|
@ -3284,7 +3284,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tetratto-l10n"
|
name = "tetratto-l10n"
|
||||||
version = "8.0.0"
|
version = "9.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pathbufd",
|
"pathbufd",
|
||||||
"serde",
|
"serde",
|
||||||
|
@ -3293,7 +3293,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tetratto-shared"
|
name = "tetratto-shared"
|
||||||
version = "8.0.0"
|
version = "9.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ammonia",
|
"ammonia",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "tetratto"
|
name = "tetratto"
|
||||||
version = "8.0.0"
|
version = "9.0.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|
|
@ -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 STYLE_CSS: &str = include_str!("./public/css/style.css");
|
||||||
pub const ROOT_CSS: &str = include_str!("./public/css/root.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 UTILITY_CSS: &str = include_str!("./public/css/utility.css");
|
||||||
|
pub const CHATS_CSS: &str = include_str!("./public/css/chats.css");
|
||||||
|
|
||||||
// js
|
// js
|
||||||
pub const LOADER_JS: &str = include_str!("./public/js/loader.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_APP: &str = include_str!("./public/html/developer/app.lisp");
|
||||||
pub const DEVELOPER_LINK: &str = include_str!("./public/html/developer/link.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
|
// langs
|
||||||
pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml");
|
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/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->"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
|
html_path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -16,6 +16,7 @@ version = "1.0.0"
|
||||||
"general:link.ip_bans" = "IP bans"
|
"general:link.ip_bans" = "IP bans"
|
||||||
"general:link.stats" = "Stats"
|
"general:link.stats" = "Stats"
|
||||||
"general:link.search" = "Search"
|
"general:link.search" = "Search"
|
||||||
|
"general:link.journals" = "Journals"
|
||||||
"general:action.save" = "Save"
|
"general:action.save" = "Save"
|
||||||
"general:action.delete" = "Delete"
|
"general:action.delete" = "Delete"
|
||||||
"general:action.purge" = "Purge"
|
"general:action.purge" = "Purge"
|
||||||
|
@ -231,3 +232,13 @@ version = "1.0.0"
|
||||||
"developer:label.guides_and_help" = "Guides & help"
|
"developer:label.guides_and_help" = "Guides & help"
|
||||||
"developer:action.delete" = "Delete app"
|
"developer:action.delete" = "Delete app"
|
||||||
"developer:action.authorize" = "Authorize"
|
"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"
|
||||||
|
|
|
@ -193,7 +193,10 @@ macro_rules! check_user_blocked_or_private {
|
||||||
// check if other user is banned
|
// check if other user is banned
|
||||||
if $other_user.permissions.check_banned() {
|
if $other_user.permissions.check_banned() {
|
||||||
if let Some(ref ua) = $user {
|
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);
|
$crate::user_banned!($user, $other_user, $data, $jar);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -213,7 +216,9 @@ macro_rules! check_user_blocked_or_private {
|
||||||
.get_user_stack_blocked_users($other_user.id)
|
.get_user_stack_blocked_users($other_user.id)
|
||||||
.await
|
.await
|
||||||
.contains(&ua.id))
|
.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 lang = get_lang!($jar, $data.0);
|
||||||
let mut context = initial_context(&$data.0.0.0, lang, &$user).await;
|
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 $other_user.settings.private_profile {
|
||||||
if let Some(ref ua) = $user {
|
if let Some(ref ua) = $user {
|
||||||
if (ua.id != $other_user.id)
|
if (ua.id != $other_user.id)
|
||||||
&& !ua.permissions.check(FinePermission::MANAGE_USERS)
|
&& !ua
|
||||||
|
.permissions
|
||||||
|
.check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS)
|
||||||
&& $data
|
&& $data
|
||||||
.0
|
.0
|
||||||
.get_userfollow_by_initiator_receiver($other_user.id, ua.id)
|
.get_userfollow_by_initiator_receiver($other_user.id, ua.id)
|
||||||
|
|
232
crates/app/src/public/css/chats.css
Normal file
232
crates/app/src/public/css/chats.css
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -116,7 +116,7 @@ article {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body .card:not(.card *):not(#stream *):not(.user_plate),
|
body .card:not(.card *):not(.user_plate),
|
||||||
body .pillmenu:not(.card *) > a,
|
body .pillmenu:not(.card *) > a,
|
||||||
body .card-nest:not(.card *) > .card,
|
body .card-nest:not(.card *) > .card,
|
||||||
body .banner {
|
body .banner {
|
||||||
|
|
|
@ -273,6 +273,12 @@ button,
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:disabled,
|
||||||
|
.button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
button.small,
|
button.small,
|
||||||
.button.small {
|
.button.small {
|
||||||
/* min-height: max-content; */
|
/* min-height: max-content; */
|
||||||
|
@ -714,6 +720,13 @@ nav .button:not(.title):not(.active):hover {
|
||||||
border-bottom-right-radius: var(--radius) !important;
|
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 */
|
||||||
dialog {
|
dialog {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
@ -1072,7 +1085,7 @@ details[open] summary::after {
|
||||||
animation: fadein ease-in-out 1 0.1s forwards running;
|
animation: fadein ease-in-out 1 0.1s forwards running;
|
||||||
}
|
}
|
||||||
|
|
||||||
details .card {
|
details > .card {
|
||||||
background: var(--color-super-raised);
|
background: var(--color-super-raised);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1113,3 +1126,127 @@ details.accordion .inner {
|
||||||
border: solid 1px var(--color-super-lowered);
|
border: solid 1px var(--color-super-lowered);
|
||||||
border-top: none;
|
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%;
|
||||||
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
(text "{% extends \"root.html\" %} {% block head %}")
|
(text "{% extends \"root.html\" %} {% block head %}")
|
||||||
(title
|
(title
|
||||||
(text "Chats - {{ config.name }}"))
|
(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) }}")
|
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"chats\", hide_user_menu=true) }}")
|
||||||
(nav
|
(nav
|
||||||
("class" "chats_nav")
|
("class" "chats_nav")
|
||||||
|
@ -16,7 +16,6 @@
|
||||||
(b
|
(b
|
||||||
(text "{{ text \"chats:label.my_chats\" }}"))
|
(text "{{ text \"chats:label.my_chats\" }}"))
|
||||||
(text "{%- endif %}")))
|
(text "{%- endif %}")))
|
||||||
|
|
||||||
(div
|
(div
|
||||||
("class" "flex")
|
("class" "flex")
|
||||||
(div
|
(div
|
||||||
|
@ -87,7 +86,7 @@
|
||||||
(text "{{ components::user_plate(user=user, show_menu=true) }}"))
|
(text "{{ components::user_plate(user=user, show_menu=true) }}"))
|
||||||
(text "{% if channel -%}")
|
(text "{% if channel -%}")
|
||||||
(div
|
(div
|
||||||
("class" "w-full flex flex-col gap-2")
|
("class" "w-full flex flex-col gap-2 padded_section")
|
||||||
("id" "stream")
|
("id" "stream")
|
||||||
("style" "padding: var(--pad-4)")
|
("style" "padding: var(--pad-4)")
|
||||||
(turbo-frame
|
(turbo-frame
|
||||||
|
@ -110,225 +109,6 @@
|
||||||
("title" "Send")
|
("title" "Send")
|
||||||
(text "{{ icon \"send-horizontal\" }}"))))
|
(text "{{ icon \"send-horizontal\" }}"))))
|
||||||
(text "{%- endif %}")
|
(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
|
(script
|
||||||
(text "window.CURRENT_PAGE = Number.parseInt(\"{{ page }}\");
|
(text "window.CURRENT_PAGE = Number.parseInt(\"{{ page }}\");
|
||||||
window.VIEWING_SINGLE = \"{{ message }}\".length > 0;
|
window.VIEWING_SINGLE = \"{{ message }}\".length > 0;
|
||||||
|
@ -684,5 +464,4 @@
|
||||||
}
|
}
|
||||||
}, 100);"))
|
}, 100);"))
|
||||||
(text "{%- endif %}"))
|
(text "{%- endif %}"))
|
||||||
|
|
||||||
(text "{% endblock %}")
|
(text "{% endblock %}")
|
||||||
|
|
|
@ -976,6 +976,10 @@
|
||||||
(text "{{ icon \"circle-user-round\" }}")
|
(text "{{ icon \"circle-user-round\" }}")
|
||||||
(span
|
(span
|
||||||
(text "{{ text \"auth:link.my_profile\" }}")))
|
(text "{{ text \"auth:link.my_profile\" }}")))
|
||||||
|
(a
|
||||||
|
("href" "/journals/0/0")
|
||||||
|
(icon (text "notebook"))
|
||||||
|
(str (text "general:link.journals")))
|
||||||
(a
|
(a
|
||||||
("href" "/settings")
|
("href" "/settings")
|
||||||
(text "{{ icon \"settings\" }}")
|
(text "{{ icon \"settings\" }}")
|
||||||
|
@ -1851,3 +1855,109 @@
|
||||||
(text "{{ stack.created }}"))
|
(text "{{ stack.created }}"))
|
||||||
(text "; {{ stack.privacy }}; {{ stack.users|length }} users")))
|
(text "; {{ stack.privacy }}; {{ stack.users|length }} users")))
|
||||||
(text "{%- endmacro %}")
|
(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 %}")
|
||||||
|
|
543
crates/app/src/public/html/journals/app.lisp
Normal file
543
crates/app/src/public/html/journals/app.lisp
Normal file
|
@ -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 %}")
|
|
@ -9,7 +9,7 @@
|
||||||
(meta ("http-equiv" "X-UA-Compatible") ("content" "ie=edge"))
|
(meta ("http-equiv" "X-UA-Compatible") ("content" "ie=edge"))
|
||||||
|
|
||||||
(link ("rel" "icon") ("href" "/public/favicon.svg"))
|
(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 -%}
|
(text "{% if user -%}
|
||||||
<script>
|
<script>
|
||||||
|
|
|
@ -104,7 +104,7 @@
|
||||||
(div
|
(div
|
||||||
("class" "flex flex-col gap-1")
|
("class" "flex flex-col gap-1")
|
||||||
(label
|
(label
|
||||||
("for" "new_title")
|
("for" "name")
|
||||||
(text "{{ text \"communities:label.name\" }}"))
|
(text "{{ text \"communities:label.name\" }}"))
|
||||||
(input
|
(input
|
||||||
("type" "text")
|
("type" "text")
|
||||||
|
|
|
@ -688,6 +688,8 @@ media_theme_pref();
|
||||||
});
|
});
|
||||||
|
|
||||||
self.define("hooks::tabs:switch", (_, tab) => {
|
self.define("hooks::tabs:switch", (_, tab) => {
|
||||||
|
tab = tab.split("?")[0];
|
||||||
|
|
||||||
// tab
|
// tab
|
||||||
for (const element of Array.from(
|
for (const element of Array.from(
|
||||||
document.querySelectorAll("[data-tab]"),
|
document.querySelectorAll("[data-tab]"),
|
||||||
|
|
|
@ -6,7 +6,7 @@ use axum::{
|
||||||
use axum_extra::extract::CookieJar;
|
use axum_extra::extract::CookieJar;
|
||||||
use crate::{
|
use crate::{
|
||||||
get_user_from_token,
|
get_user_from_token,
|
||||||
routes::api::v1::{UpdateJournalView, CreateJournal, UpdateJournalTitle},
|
routes::api::v1::{UpdateJournalPrivacy, CreateJournal, UpdateJournalTitle},
|
||||||
State,
|
State,
|
||||||
};
|
};
|
||||||
use tetratto_core::model::{
|
use tetratto_core::model::{
|
||||||
|
@ -81,7 +81,7 @@ pub async fn create_request(
|
||||||
Ok(x) => Json(ApiReturn {
|
Ok(x) => Json(ApiReturn {
|
||||||
ok: true,
|
ok: true,
|
||||||
message: "Journal created".to_string(),
|
message: "Journal created".to_string(),
|
||||||
payload: Some(x),
|
payload: Some(x.id.to_string()),
|
||||||
}),
|
}),
|
||||||
Err(e) => Json(e.into()),
|
Err(e) => Json(e.into()),
|
||||||
}
|
}
|
||||||
|
@ -91,7 +91,7 @@ pub async fn update_title_request(
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
Path(id): Path<usize>,
|
Path(id): Path<usize>,
|
||||||
Extension(data): Extension<State>,
|
Extension(data): Extension<State>,
|
||||||
Json(props): Json<UpdateJournalTitle>,
|
Json(mut props): Json<UpdateJournalTitle>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let data = &(data.read().await).0;
|
let data = &(data.read().await).0;
|
||||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageJournals) {
|
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageJournals) {
|
||||||
|
@ -99,6 +99,18 @@ pub async fn update_title_request(
|
||||||
None => return Json(Error::NotAllowed.into()),
|
None => return Json(Error::NotAllowed.into()),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
props.title = props.title.replace(" ", "_");
|
||||||
|
|
||||||
|
// make sure this title isn't already in use
|
||||||
|
if data
|
||||||
|
.get_journal_by_owner_title(user.id, &props.title)
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
return Json(Error::TitleInUse.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...
|
||||||
match data.update_journal_title(id, &user, &props.title).await {
|
match data.update_journal_title(id, &user, &props.title).await {
|
||||||
Ok(_) => Json(ApiReturn {
|
Ok(_) => Json(ApiReturn {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
@ -113,7 +125,7 @@ pub async fn update_privacy_request(
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
Path(id): Path<usize>,
|
Path(id): Path<usize>,
|
||||||
Extension(data): Extension<State>,
|
Extension(data): Extension<State>,
|
||||||
Json(props): Json<UpdateJournalView>,
|
Json(props): Json<UpdateJournalPrivacy>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let data = &(data.read().await).0;
|
let data = &(data.read().await).0;
|
||||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageJournals) {
|
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageJournals) {
|
||||||
|
@ -121,7 +133,7 @@ pub async fn update_privacy_request(
|
||||||
None => return Json(Error::NotAllowed.into()),
|
None => return Json(Error::NotAllowed.into()),
|
||||||
};
|
};
|
||||||
|
|
||||||
match data.update_journal_privacy(id, &user, props.view).await {
|
match data.update_journal_privacy(id, &user, props.privacy).await {
|
||||||
Ok(_) => Json(ApiReturn {
|
Ok(_) => Json(ApiReturn {
|
||||||
ok: true,
|
ok: true,
|
||||||
message: "Journal updated".to_string(),
|
message: "Journal updated".to_string(),
|
||||||
|
|
|
@ -563,6 +563,7 @@ pub fn routes() -> Router {
|
||||||
.route("/notes/{id}/title", post(notes::update_title_request))
|
.route("/notes/{id}/title", post(notes::update_title_request))
|
||||||
.route("/notes/{id}/content", post(notes::update_content_request))
|
.route("/notes/{id}/content", post(notes::update_content_request))
|
||||||
.route("/notes/from_journal/{id}", get(notes::list_request))
|
.route("/notes/from_journal/{id}", get(notes::list_request))
|
||||||
|
.route("/notes/preview", post(notes::render_markdown_request))
|
||||||
// uploads
|
// uploads
|
||||||
.route("/uploads/{id}", get(uploads::get_request))
|
.route("/uploads/{id}", get(uploads::get_request))
|
||||||
.route("/uploads/{id}", delete(uploads::delete_request))
|
.route("/uploads/{id}", delete(uploads::delete_request))
|
||||||
|
@ -887,8 +888,8 @@ pub struct UpdateJournalTitle {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct UpdateJournalView {
|
pub struct UpdateJournalPrivacy {
|
||||||
pub view: JournalPrivacyPermission,
|
pub privacy: JournalPrivacyPermission,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
@ -900,3 +901,8 @@ pub struct UpdateNoteTitle {
|
||||||
pub struct UpdateNoteContent {
|
pub struct UpdateNoteContent {
|
||||||
pub content: String,
|
pub content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RenderMarkdown {
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
|
@ -4,15 +4,17 @@ use axum::{
|
||||||
Extension,
|
Extension,
|
||||||
};
|
};
|
||||||
use axum_extra::extract::CookieJar;
|
use axum_extra::extract::CookieJar;
|
||||||
|
use tetratto_shared::unix_epoch_timestamp;
|
||||||
use crate::{
|
use crate::{
|
||||||
get_user_from_token,
|
get_user_from_token,
|
||||||
routes::api::v1::{CreateNote, UpdateNoteContent, UpdateNoteTitle},
|
routes::api::v1::{CreateNote, RenderMarkdown, UpdateNoteContent, UpdateNoteTitle},
|
||||||
State,
|
State,
|
||||||
};
|
};
|
||||||
use tetratto_core::model::{
|
use tetratto_core::model::{
|
||||||
journals::{JournalPrivacyPermission, Note},
|
journals::{JournalPrivacyPermission, Note},
|
||||||
oauth,
|
oauth,
|
||||||
permissions::FinePermission,
|
permissions::FinePermission,
|
||||||
|
uploads::CustomEmoji,
|
||||||
ApiReturn, Error,
|
ApiReturn, Error,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -110,7 +112,7 @@ pub async fn create_request(
|
||||||
Ok(x) => Json(ApiReturn {
|
Ok(x) => Json(ApiReturn {
|
||||||
ok: true,
|
ok: true,
|
||||||
message: "Note created".to_string(),
|
message: "Note created".to_string(),
|
||||||
payload: Some(x),
|
payload: Some(x.id.to_string()),
|
||||||
}),
|
}),
|
||||||
Err(e) => Json(e.into()),
|
Err(e) => Json(e.into()),
|
||||||
}
|
}
|
||||||
|
@ -120,7 +122,7 @@ pub async fn update_title_request(
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
Path(id): Path<usize>,
|
Path(id): Path<usize>,
|
||||||
Extension(data): Extension<State>,
|
Extension(data): Extension<State>,
|
||||||
Json(props): Json<UpdateNoteTitle>,
|
Json(mut props): Json<UpdateNoteTitle>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let data = &(data.read().await).0;
|
let data = &(data.read().await).0;
|
||||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
|
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
|
||||||
|
@ -128,6 +130,23 @@ pub async fn update_title_request(
|
||||||
None => return Json(Error::NotAllowed.into()),
|
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()),
|
||||||
|
};
|
||||||
|
|
||||||
|
props.title = props.title.replace(" ", "_");
|
||||||
|
|
||||||
|
// make sure this title isn't already in use
|
||||||
|
if data
|
||||||
|
.get_note_by_journal_title(note.journal, &props.title)
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
return Json(Error::TitleInUse.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...
|
||||||
match data.update_note_title(id, &user, &props.title).await {
|
match data.update_note_title(id, &user, &props.title).await {
|
||||||
Ok(_) => Json(ApiReturn {
|
Ok(_) => Json(ApiReturn {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
@ -151,11 +170,20 @@ pub async fn update_content_request(
|
||||||
};
|
};
|
||||||
|
|
||||||
match data.update_note_content(id, &user, &props.content).await {
|
match data.update_note_content(id, &user, &props.content).await {
|
||||||
Ok(_) => Json(ApiReturn {
|
Ok(_) => {
|
||||||
|
if let Err(e) = data
|
||||||
|
.update_note_edited(id, unix_epoch_timestamp() as i64)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Json(e.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Json(ApiReturn {
|
||||||
ok: true,
|
ok: true,
|
||||||
message: "Note updated".to_string(),
|
message: "Note updated".to_string(),
|
||||||
payload: (),
|
payload: (),
|
||||||
}),
|
})
|
||||||
|
}
|
||||||
Err(e) => Json(e.into()),
|
Err(e) => Json(e.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -180,3 +208,9 @@ pub async fn delete_request(
|
||||||
Err(e) => Json(e.into()),
|
Err(e) => Json(e.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn render_markdown_request(Json(req): Json<RenderMarkdown>) -> impl IntoResponse {
|
||||||
|
tetratto_shared::markdown::render_markdown(&CustomEmoji::replace(&req.content))
|
||||||
|
.replace("\\@", "@")
|
||||||
|
.replace("%5C@", "@")
|
||||||
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ serve_asset!(favicon_request: FAVICON("image/svg+xml"));
|
||||||
serve_asset!(style_css_request: STYLE_CSS("text/css"));
|
serve_asset!(style_css_request: STYLE_CSS("text/css"));
|
||||||
serve_asset!(root_css_request: ROOT_CSS("text/css"));
|
serve_asset!(root_css_request: ROOT_CSS("text/css"));
|
||||||
serve_asset!(utility_css_request: UTILITY_CSS("text/css"));
|
serve_asset!(utility_css_request: UTILITY_CSS("text/css"));
|
||||||
|
serve_asset!(chats_css_request: CHATS_CSS("text/css"));
|
||||||
|
|
||||||
serve_asset!(loader_js_request: LOADER_JS("text/javascript"));
|
serve_asset!(loader_js_request: LOADER_JS("text/javascript"));
|
||||||
serve_asset!(atto_js_request: ATTO_JS("text/javascript"));
|
serve_asset!(atto_js_request: ATTO_JS("text/javascript"));
|
||||||
|
|
|
@ -14,6 +14,7 @@ pub fn routes(config: &Config) -> Router {
|
||||||
.route("/css/style.css", get(assets::style_css_request))
|
.route("/css/style.css", get(assets::style_css_request))
|
||||||
.route("/css/root.css", get(assets::root_css_request))
|
.route("/css/root.css", get(assets::root_css_request))
|
||||||
.route("/css/utility.css", get(assets::utility_css_request))
|
.route("/css/utility.css", get(assets::utility_css_request))
|
||||||
|
.route("/css/chats.css", get(assets::chats_css_request))
|
||||||
.route("/js/loader.js", get(assets::loader_js_request))
|
.route("/js/loader.js", get(assets::loader_js_request))
|
||||||
.route("/js/atto.js", get(assets::atto_js_request))
|
.route("/js/atto.js", get(assets::atto_js_request))
|
||||||
.route("/js/me.js", get(assets::me_js_request))
|
.route("/js/me.js", get(assets::me_js_request))
|
||||||
|
|
209
crates/app/src/routes/pages/journals.rs
Normal file
209
crates/app/src/routes/pages/journals.rs
Normal file
|
@ -0,0 +1,209 @@
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, Query},
|
||||||
|
response::{Html, IntoResponse, Redirect},
|
||||||
|
Extension,
|
||||||
|
};
|
||||||
|
use axum_extra::extract::CookieJar;
|
||||||
|
use crate::{
|
||||||
|
assets::initial_context,
|
||||||
|
check_user_blocked_or_private, get_lang, get_user_from_token,
|
||||||
|
routes::pages::{render_error, JournalsAppQuery},
|
||||||
|
State,
|
||||||
|
};
|
||||||
|
use tetratto_core::model::{journals::JournalPrivacyPermission, Error};
|
||||||
|
|
||||||
|
pub async fn redirect_request() -> impl IntoResponse {
|
||||||
|
Redirect::to("/journals/0/0")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/journals/{journal}/{note}`
|
||||||
|
pub async fn app_request(
|
||||||
|
jar: CookieJar,
|
||||||
|
Extension(data): Extension<State>,
|
||||||
|
Path((selected_journal, selected_note)): Path<(usize, usize)>,
|
||||||
|
Query(props): Query<JournalsAppQuery>,
|
||||||
|
) -> 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 journals = match data.0.get_journals_by_user(user.id).await {
|
||||||
|
Ok(p) => Some(p),
|
||||||
|
Err(e) => {
|
||||||
|
return Err(Html(
|
||||||
|
render_error(e, &jar, &data, &Some(user.to_owned())).await,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let notes = match data.0.get_notes_by_journal(selected_journal).await {
|
||||||
|
Ok(p) => Some(p),
|
||||||
|
Err(e) => {
|
||||||
|
return Err(Html(render_error(e, &jar, &data, &Some(user)).await));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// get journal and check privacy settings
|
||||||
|
let journal = if selected_journal != 0 {
|
||||||
|
match data.0.get_journal_by_id(selected_journal).await {
|
||||||
|
Ok(p) => Some(p),
|
||||||
|
Err(e) => {
|
||||||
|
return Err(Html(render_error(e, &jar, &data, &Some(user)).await));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(ref j) = journal {
|
||||||
|
// if we're not the owner, we shouldn't be viewing this journal from this endpoint
|
||||||
|
if user.id != j.owner {
|
||||||
|
return Err(Html(
|
||||||
|
render_error(Error::NotAllowed, &jar, &data, &Some(user.to_owned())).await,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...
|
||||||
|
let note = if selected_note != 0 {
|
||||||
|
match data.0.get_note_by_id(selected_note).await {
|
||||||
|
Ok(p) => Some(p),
|
||||||
|
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let lang = get_lang!(jar, data.0);
|
||||||
|
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
|
||||||
|
|
||||||
|
context.insert("selected_journal", &selected_journal);
|
||||||
|
context.insert("selected_note", &selected_note);
|
||||||
|
|
||||||
|
context.insert("journal", &journal);
|
||||||
|
context.insert("note", ¬e);
|
||||||
|
|
||||||
|
context.insert("journals", &journals);
|
||||||
|
context.insert("notes", ¬es);
|
||||||
|
|
||||||
|
context.insert("view_mode", &props.view);
|
||||||
|
context.insert("is_editor", &true);
|
||||||
|
|
||||||
|
// return
|
||||||
|
Ok(Html(data.1.render("journals/app.html", &context).unwrap()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/@{owner}/{journal}/{note}`
|
||||||
|
pub async fn view_request(
|
||||||
|
jar: CookieJar,
|
||||||
|
Extension(data): Extension<State>,
|
||||||
|
Path((owner, selected_journal, mut selected_note)): Path<(String, 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,
|
||||||
|
};
|
||||||
|
|
||||||
|
if selected_note == "index" {
|
||||||
|
selected_note = String::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// if we don't have a selected journal, we shouldn't be here probably
|
||||||
|
if selected_journal.is_empty() {
|
||||||
|
return Err(Html(
|
||||||
|
render_error(Error::NotAllowed, &jar, &data, &user).await,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 note = if !selected_note.is_empty() {
|
||||||
|
match data
|
||||||
|
.0
|
||||||
|
.get_note_by_journal_title(journal.id, &selected_note)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(p) => Some(p),
|
||||||
|
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if selected_note.is_empty() {
|
||||||
|
context.insert("selected_note", &0);
|
||||||
|
} else {
|
||||||
|
context.insert("selected_note", &selected_note);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.insert("journal", &journal);
|
||||||
|
context.insert("note", ¬e);
|
||||||
|
|
||||||
|
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()))
|
||||||
|
}
|
|
@ -3,6 +3,7 @@ pub mod chats;
|
||||||
pub mod communities;
|
pub mod communities;
|
||||||
pub mod developer;
|
pub mod developer;
|
||||||
pub mod forge;
|
pub mod forge;
|
||||||
|
pub mod journals;
|
||||||
pub mod misc;
|
pub mod misc;
|
||||||
pub mod mod_panel;
|
pub mod mod_panel;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
|
@ -130,6 +131,11 @@ pub fn routes() -> Router {
|
||||||
.route("/stacks", get(stacks::list_request))
|
.route("/stacks", get(stacks::list_request))
|
||||||
.route("/stacks/{id}", get(stacks::feed_request))
|
.route("/stacks/{id}", get(stacks::feed_request))
|
||||||
.route("/stacks/{id}/manage", get(stacks::manage_request))
|
.route("/stacks/{id}/manage", get(stacks::manage_request))
|
||||||
|
// 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}/{note}", get(journals::view_request))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn render_error(
|
pub async fn render_error(
|
||||||
|
@ -185,3 +191,9 @@ pub struct RepostsQuery {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub page: usize,
|
pub page: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct JournalsAppQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
pub view: bool,
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "tetratto-core"
|
name = "tetratto-core"
|
||||||
version = "8.0.0"
|
version = "9.0.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
use oiseau::cache::Cache;
|
use oiseau::{cache::Cache, query_row};
|
||||||
use crate::{
|
use crate::{
|
||||||
model::{
|
model::{
|
||||||
auth::User,
|
auth::User,
|
||||||
|
@ -24,6 +24,27 @@ impl DataManager {
|
||||||
|
|
||||||
auto_method!(get_journal_by_id(usize as i64)@get_journal_from_row -> "SELECT * FROM journals WHERE id = $1" --name="journal" --returns=Journal --cache-key-tmpl="atto.journal:{}");
|
auto_method!(get_journal_by_id(usize as i64)@get_journal_from_row -> "SELECT * FROM journals WHERE id = $1" --name="journal" --returns=Journal --cache-key-tmpl="atto.journal:{}");
|
||||||
|
|
||||||
|
/// Get a journal by `owner` and `title`.
|
||||||
|
pub async fn get_journal_by_owner_title(&self, owner: usize, title: &str) -> Result<Journal> {
|
||||||
|
let conn = match self.0.connect().await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = query_row!(
|
||||||
|
&conn,
|
||||||
|
"SELECT * FROM journals WHERE owner = $1 AND title = $2",
|
||||||
|
params![&(owner as i64), &title],
|
||||||
|
|x| { Ok(Self::get_journal_from_row(x)) }
|
||||||
|
);
|
||||||
|
|
||||||
|
if res.is_err() {
|
||||||
|
return Err(Error::GeneralNotFound("journal".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(res.unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
/// Get all journals by user.
|
/// Get all journals by user.
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
|
@ -54,7 +75,7 @@ impl DataManager {
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
/// * `data` - a mock [`Journal`] object to insert
|
/// * `data` - a mock [`Journal`] object to insert
|
||||||
pub async fn create_journal(&self, data: Journal) -> Result<Journal> {
|
pub async fn create_journal(&self, mut data: Journal) -> Result<Journal> {
|
||||||
// check values
|
// check values
|
||||||
if data.title.len() < 2 {
|
if data.title.len() < 2 {
|
||||||
return Err(Error::DataTooShort("title".to_string()));
|
return Err(Error::DataTooShort("title".to_string()));
|
||||||
|
@ -62,6 +83,17 @@ impl DataManager {
|
||||||
return Err(Error::DataTooLong("title".to_string()));
|
return Err(Error::DataTooLong("title".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data.title = data.title.replace(" ", "_");
|
||||||
|
|
||||||
|
// make sure this title isn't already in use
|
||||||
|
if self
|
||||||
|
.get_journal_by_owner_title(data.owner, &data.title)
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
return Err(Error::TitleInUse);
|
||||||
|
}
|
||||||
|
|
||||||
// check number of journals
|
// check number of journals
|
||||||
let owner = self.get_user_by_id(data.owner).await?;
|
let owner = self.get_user_by_id(data.owner).await?;
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
use oiseau::cache::Cache;
|
use oiseau::cache::Cache;
|
||||||
use crate::model::{auth::User, journals::Note, permissions::FinePermission, Error, Result};
|
use crate::model::{auth::User, journals::Note, permissions::FinePermission, Error, Result};
|
||||||
use crate::{auto_method, DataManager};
|
use crate::{auto_method, DataManager};
|
||||||
use oiseau::{PostgresRow, execute, get, query_rows, params};
|
use oiseau::{execute, get, params, query_row, query_rows, PostgresRow};
|
||||||
|
|
||||||
impl DataManager {
|
impl DataManager {
|
||||||
/// Get a [`Note`] from an SQL row.
|
/// Get a [`Note`] from an SQL row.
|
||||||
|
@ -19,6 +19,27 @@ impl DataManager {
|
||||||
|
|
||||||
auto_method!(get_note_by_id(usize as i64)@get_note_from_row -> "SELECT * FROM notes WHERE id = $1" --name="note" --returns=Note --cache-key-tmpl="atto.note:{}");
|
auto_method!(get_note_by_id(usize as i64)@get_note_from_row -> "SELECT * FROM notes WHERE id = $1" --name="note" --returns=Note --cache-key-tmpl="atto.note:{}");
|
||||||
|
|
||||||
|
/// Get a note by `journal` and `title`.
|
||||||
|
pub async fn get_note_by_journal_title(&self, journal: usize, title: &str) -> Result<Note> {
|
||||||
|
let conn = match self.0.connect().await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = query_row!(
|
||||||
|
&conn,
|
||||||
|
"SELECT * FROM notes WHERE journal = $1 AND title = $2",
|
||||||
|
params![&(journal as i64), &title],
|
||||||
|
|x| { Ok(Self::get_note_from_row(x)) }
|
||||||
|
);
|
||||||
|
|
||||||
|
if res.is_err() {
|
||||||
|
return Err(Error::GeneralNotFound("note".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(res.unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
/// Get all notes by journal.
|
/// Get all notes by journal.
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
|
@ -31,7 +52,7 @@ impl DataManager {
|
||||||
|
|
||||||
let res = query_rows!(
|
let res = query_rows!(
|
||||||
&conn,
|
&conn,
|
||||||
"SELECT * FROM notes WHERE journal = $1 ORDER BY edited",
|
"SELECT * FROM notes WHERE journal = $1 ORDER BY edited DESC",
|
||||||
&[&(id as i64)],
|
&[&(id as i64)],
|
||||||
|x| { Self::get_note_from_row(x) }
|
|x| { Self::get_note_from_row(x) }
|
||||||
);
|
);
|
||||||
|
@ -47,7 +68,7 @@ impl DataManager {
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
/// * `data` - a mock [`Note`] object to insert
|
/// * `data` - a mock [`Note`] object to insert
|
||||||
pub async fn create_note(&self, data: Note) -> Result<Note> {
|
pub async fn create_note(&self, mut data: Note) -> Result<Note> {
|
||||||
// check values
|
// check values
|
||||||
if data.title.len() < 2 {
|
if data.title.len() < 2 {
|
||||||
return Err(Error::DataTooShort("title".to_string()));
|
return Err(Error::DataTooShort("title".to_string()));
|
||||||
|
@ -61,6 +82,24 @@ impl DataManager {
|
||||||
return Err(Error::DataTooLong("content".to_string()));
|
return Err(Error::DataTooLong("content".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data.title = data.title.replace(" ", "_");
|
||||||
|
|
||||||
|
// make sure this title isn't already in use
|
||||||
|
if self
|
||||||
|
.get_note_by_journal_title(data.journal, &data.title)
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
return Err(Error::TitleInUse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// check permission
|
||||||
|
let journal = self.get_journal_by_id(data.journal).await?;
|
||||||
|
|
||||||
|
if data.owner != journal.owner {
|
||||||
|
return Err(Error::NotAllowed);
|
||||||
|
}
|
||||||
|
|
||||||
// ...
|
// ...
|
||||||
let conn = match self.0.connect().await {
|
let conn = match self.0.connect().await {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
|
@ -108,13 +147,6 @@ impl DataManager {
|
||||||
return Err(Error::DatabaseError(e.to_string()));
|
return Err(Error::DatabaseError(e.to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// delete notes
|
|
||||||
let res = execute!(&conn, "DELETE FROM notes WHERE note = $1", &[&(id as i64)]);
|
|
||||||
|
|
||||||
if let Err(e) = res {
|
|
||||||
return Err(Error::DatabaseError(e.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ...
|
// ...
|
||||||
self.0.1.remove(format!("atto.note:{}", id)).await;
|
self.0.1.remove(format!("atto.note:{}", id)).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
@ -122,4 +154,5 @@ impl DataManager {
|
||||||
|
|
||||||
auto_method!(update_note_title(&str)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}");
|
auto_method!(update_note_title(&str)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}");
|
||||||
auto_method!(update_note_content(&str)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET content = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}");
|
auto_method!(update_note_content(&str)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET content = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}");
|
||||||
|
auto_method!(update_note_edited(i64) -> "UPDATE notes SET edited = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}");
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "tetratto-l10n"
|
name = "tetratto-l10n"
|
||||||
version = "8.0.0"
|
version = "9.0.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "tetratto-shared"
|
name = "tetratto-shared"
|
||||||
version = "8.0.0"
|
version = "9.0.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue