add: journals + notes
This commit is contained in:
parent
c08a26ae8d
commit
c1568ad866
26 changed files with 1431 additions and 265 deletions
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 %}")
|
Loading…
Add table
Add a link
Reference in a new issue