tetratto/crates/app/src/public/html/journals/app.lisp

621 lines
26 KiB
Common Lisp

(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 "{% if view_mode and journal -%}")
; add journal css
(link ("rel" "stylesheet") ("data-turbo-temporary" "true") ("href" "/api/v1/journals/{{ journal.id }}/journal.css?v=tetratto-{{ random_cache_breaker }}"))
(text "{%- endif %}")
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"journals\") }}")
(text "{% if not view_mode -%}")
(nav
("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 }} {%- 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 and (not note or note.title != \"journal.css\") -%}")
(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 }} {%- 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")))
(span ("class" "mobile") (str (text "journals:label.mobile_click_my_journals")))
(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"))
(link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/lib/codemirror.css") ("data-turbo-temporary" "true"))
(text "{% if note.title == \"journal.css\" -%}")
; css editor
(script ("src" "https://unpkg.com/codemirror@5.39.2/addon/edit/closebrackets.js") ("data-turbo-temporary" "true"))
(script ("src" "https://unpkg.com/codemirror@5.39.2/addon/hint/css-hint.js") ("data-turbo-temporary" "true"))
(script ("src" "https://unpkg.com/codemirror@5.39.2/addon/hint/show-hint.js") ("data-turbo-temporary" "true"))
(script ("src" "https://unpkg.com/codemirror@5.39.2/addon/lint/css-lint.js") ("data-turbo-temporary" "true"))
(script ("src" "https://unpkg.com/codemirror@5.39.2/mode/css/css.js") ("data-turbo-temporary" "true"))
(link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/addon/hint/show-hint.css") ("data-turbo-temporary" "true"))
(link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/addon/lint/lint.css") ("data-turbo-temporary" "true"))
(style
(text ".CodeMirror {
font-family: monospace !important;
font-size: 16px;
border: solid 1px var(--color-super-lowered);
border-radius: var(--radius);
}
.CodeMirror-line {
padding-left: 5px !important;
}"))
(text "{% else %}")
; markdown editor
(script ("src" "https://unpkg.com/codemirror@5.39.2/mode/markdown/markdown.js") ("data-turbo-temporary" "true"))
(text "{%- endif %}")
; tab bar
(text "{% if note.title != \"journal.css\" -%}")
(div
("class" "pillmenu")
(a
("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"))))
(text "{%- endif %}")
; 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(() => {
if (!document.getElementById(\"preview_tab\").shadowRoot) {
document.getElementById(\"preview_tab\").attachShadow({ mode: \"open\" });
}
globalThis.editor = CodeMirror(document.getElementById(\"editor_tab\"), {
value: document.getElementById(\"editor_content\").innerHTML,
mode: \"{% if note.title == 'journal.css' -%} css {%- else -%} markdown {%- endif %}\",
lineWrapping: true,
lineNumbers: \"{{ note.title }}\" === \"journal.css\",
autoCloseBrackets: true,
autofocus: true,
viewportMargin: Number.POSITIVE_INFINITY,
inputStyle: \"contenteditable\",
highlightFormatting: false,
fencedCodeBlockHighlighting: false,
xml: false,
smartIndent: true,
indentUnit: 4,
placeholder: `# {{ note.title }}`,
extraKeys: {
Home: \"goLineLeft\",
End: \"goLineRight\",
Enter: (cm) => {
cm.replaceSelection(\"\\n\");
},
},
});
editor.on(\"keydown\", (cm, e) => {
if (e.key.length > 1) {
// ignore all keys that aren't a letter
return;
}
CodeMirror.showHint(cm, CodeMirror.hint.css);
});
document.querySelector(\"[data-tab-button=editor]\").addEventListener(\"click\", async (e) => {
e.preventDefault();
trigger(\"atto::hooks::tabs:switch\", [\"editor\"]);
});
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();
const preview_token = window.crypto.randomUUID();
document.getElementById(\"preview_tab\").shadowRoot.innerHTML = `${res}<style>
@import url(\"/api/v1/journals/{{ journal.id }}/journal.css?v=preview-${preview_token}\");
</style>`;
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 }}"))
(div
("class" "flex w-full justify-between gap-2")
(span (text "Last updated: ") (span ("class" "date") (text "{{ note.edited }}")))
(text "{% if user and user.id == owner.id -%}")
(button
("class" "small")
("onclick" "{% if journal.privacy == \"Public\" -%}
trigger('atto::copy_text', ['{{ config.host }}/@{{ owner.username }}/{{ journal.title }}/{{ note.title }}'])
{%- else -%}
prompt_make_public();
trigger('atto::copy_text', ['{{ config.host }}/@{{ owner.username }}/{{ journal.title }}/{{ note.title }}'])
{%- endif %}")
(icon (text "share"))
(str (text "general:label.share")))
(script
(text "globalThis.prompt_make_public = async () => {
if (
!(await trigger(\"atto::confirm\", [
\"Would you like to make this journal public? This is required for others to view this note.\",
]))
) {
return;
}
change_journal_privacy({ target: { selectedOptions: [{ value: \"Public\" }] }, preventDefault: () => {} });
}"))
(text "{%- endif %}"))
(text "{%- endif %}")
(text "{%- endif %}")))
(style
(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.css\" ? `/* ${title} */\\n` : `# ${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 = e.target.selectedOptions[0];
fetch(\"/api/v1/journals/{% if journal -%} {{ journal.id }} {%- else -%} {{ selected_journal }} {%- endif %}/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 %}")