add: journal note tags and directories
This commit is contained in:
parent
a37312fecf
commit
af6fbdf04e
16 changed files with 722 additions and 78 deletions
|
@ -244,3 +244,9 @@ version = "1.0.0"
|
|||
"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"
|
||||
"journals:action.edit_tags" = "Edit tags"
|
||||
"journals:action.tags" = "Tags"
|
||||
"journals:label.directories" = "Directories"
|
||||
"journals:action.create_subdir" = "Create subdirectory"
|
||||
"journals:action.create_root_dir" = "Create root directory"
|
||||
"journals:action.move" = "Move"
|
||||
|
|
|
@ -1065,14 +1065,14 @@ details summary::-webkit-details-marker {
|
|||
display: none;
|
||||
}
|
||||
|
||||
details[open] summary {
|
||||
details[open] > summary {
|
||||
position: relative;
|
||||
color: var(--color-primary);
|
||||
background: var(--color-super-lowered);
|
||||
color: var(--color-text-lowered) !important;
|
||||
background: var(--color-super-lowered) !important;
|
||||
margin-bottom: var(--pad-1);
|
||||
}
|
||||
|
||||
details[open] summary::after {
|
||||
details[open] > summary::after {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 5px;
|
||||
|
|
|
@ -2028,6 +2028,22 @@
|
|||
(str (text "general:action.delete")))))
|
||||
(text "{%- endif %}"))
|
||||
|
||||
(text "{% if selected_note -%}")
|
||||
; open all details elements above the selected note
|
||||
(script
|
||||
("defer" "true")
|
||||
(text "setTimeout(() => {
|
||||
let cursor = document.querySelector(\"[ui_ident=active_note]\");
|
||||
while (cursor) {
|
||||
if (cursor.nodeName === \"DETAILS\") {
|
||||
cursor.setAttribute(\"open\", \"true\");
|
||||
}
|
||||
|
||||
cursor = cursor.parentElement;
|
||||
}
|
||||
}, 150);"))
|
||||
(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)")
|
||||
|
@ -2041,36 +2057,155 @@
|
|||
(text "{%- endif %}")
|
||||
|
||||
; note listings
|
||||
(text "{% for note in notes %} {% if not view_mode or note.title != \"journal.css\" -%}")
|
||||
(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 "{%- endif %} {% endfor %}"))
|
||||
(text "{{ self::notes_list_dir_listing_inner(dir=[0, 0, \"root\"], dirs=journal.dirs, notes=notes, owner=owner, journal=journal, view_mode=view_mode) }}"))
|
||||
(text "{%- endif %}")
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro notes_list_dir_listing(dir, dirs, notes, owner, journal, view_mode=false) -%}")
|
||||
(details
|
||||
(summary
|
||||
("class" "button w-full justify-start raised w-full")
|
||||
(icon (text "folder"))
|
||||
(text "{{ dir[2] }}"))
|
||||
|
||||
(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)")
|
||||
(text "{{ self::notes_list_dir_listing_inner(dir=dir, dirs=dirs, notes=notes, owner=owner, journal=journal, view_mode=view_mode) }}")))
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro notes_list_dir_listing_inner(dir, dirs, notes, owner, journal, view_mode=false) -%}")
|
||||
; child dirs
|
||||
(text "{% for subdir in dirs %} {% if subdir[1] == dir[0] -%}")
|
||||
(text "{{ self::notes_list_dir_listing(dir=subdir, dirs=dirs, notes=notes, owner=owner, journal=journal) }}")
|
||||
(text "{%- endif %} {% endfor %}")
|
||||
|
||||
; child notes
|
||||
(text "{% for note in notes %} {% if note.dir == dir[0] -%} {% if not view_mode or note.title != \"journal.css\" -%}")
|
||||
(text "{{ self::notes_list_note_listing(note=note, owner=owner, journal=journal) }}")
|
||||
(text "{%- endif %} {%- endif %} {% endfor %}")
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro notes_list_note_listing(owner, journal, note) -%}")
|
||||
(div
|
||||
("class" "flex flex-row gap-1")
|
||||
("ui_ident" "{% if selected_note == note.id -%} active_note {%- else -%} inactive_note {%- endif %}")
|
||||
(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")))
|
||||
(a
|
||||
("class" "button")
|
||||
("href" "/journals/{{ journal.id }}/{{ note.id }}#/tags")
|
||||
(icon (text "tag"))
|
||||
(str (text "journals:action.edit_tags")))
|
||||
(button
|
||||
("class" "button")
|
||||
("onclick" "window.NOTE_MOVER_NOTE_ID = '{{ note.id }}'; document.getElementById('note_mover_dialog').showModal()")
|
||||
(icon (text "brush-cleaning"))
|
||||
(str (text "journals:action.move")))
|
||||
(button
|
||||
("onclick" "delete_note('{{ note.id }}')")
|
||||
("class" "red")
|
||||
(icon (text "trash"))
|
||||
(str (text "general:action.delete")))))
|
||||
(text "{%- endif %}"))
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro note_tags(note) -%} {% if note and note.tags|length > 0 -%}")
|
||||
(div
|
||||
("class" "flex gap-1 flex-wrap")
|
||||
(text "{% for tag in note.tags %}")
|
||||
(a
|
||||
("href" "{% if view_mode -%} /@{{ owner.username }} {%- else -%} /@{{ user.username }} {%- endif -%} /{{ journal.title }}?tag={{ tag }}")
|
||||
("class" "notification chip")
|
||||
(span (text "{{ tag }}")))
|
||||
(text "{% endfor %}"))
|
||||
(text "{%- endif %} {%- endmacro %}")
|
||||
|
||||
(text "{% macro directories_editor(dirs) -%}")
|
||||
(button
|
||||
("onclick" "create_directory('0')")
|
||||
(icon (text "plus"))
|
||||
(str (text "journals:action.create_root_dir")))
|
||||
|
||||
(text "{% for dir in dirs %} {% if dir[1] == 0 -%}")
|
||||
(text "{{ self::directories_editor_listing(dir=dir, dirs=dirs) }}")
|
||||
(text "{%- endif %} {% endfor %}")
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro directories_editor_listing(dir, dirs) -%}")
|
||||
(div
|
||||
("class" "flex flex-row gap-1")
|
||||
(button
|
||||
("class" "justify-start lowered w-full")
|
||||
(icon (text "folder-open"))
|
||||
(text "{{ dir[2] }}"))
|
||||
|
||||
(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" "create_directory('{{ dir[0] }}')")
|
||||
(icon (text "plus"))
|
||||
(str (text "journals:action.create_subdir")))
|
||||
(button
|
||||
("onclick" "delete_directory('{{ dir[0] }}')")
|
||||
("class" "red")
|
||||
(icon (text "trash"))
|
||||
(str (text "general:action.delete"))))))
|
||||
|
||||
(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)")
|
||||
; subdir listings
|
||||
(text "{% for subdir in dirs %} {% if subdir[1] == dir[0] -%}")
|
||||
(text "{{ self::directories_editor_listing(dir=subdir, dirs=dirs) }}")
|
||||
(text "{%- endif %} {% endfor %}"))
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro note_mover_dirs(dirs) -%}")
|
||||
(text "{% for dir in dirs %} {% if dir[1] == 0 -%}")
|
||||
(text "{{ self::note_mover_dirs_listing(dir=dir, dirs=dirs) }}")
|
||||
(text "{%- endif %} {% endfor %}")
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro note_mover_dirs_listing(dir, dirs) -%}")
|
||||
(button
|
||||
("onclick" "move_note_dir(window.NOTE_MOVER_NOTE_ID, '{{ dir[0] }}'); document.getElementById('note_mover_dialog').close()")
|
||||
("class" "justify-start lowered w-full")
|
||||
(icon (text "folder-open"))
|
||||
(text "{{ dir[2] }}"))
|
||||
|
||||
(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)")
|
||||
; subdir listings
|
||||
(text "{% for subdir in dirs %} {% if subdir[1] == dir[0] -%}")
|
||||
(text "{{ self::note_mover_dirs_listing(dir=subdir, dirs=dirs) }}")
|
||||
(text "{%- endif %} {% endfor %}"))
|
||||
(text "{%- endmacro %}")
|
||||
|
|
|
@ -83,23 +83,25 @@
|
|||
(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\") }}"))
|
||||
("class" "flex flex-col 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\") }}"))
|
||||
|
||||
(text "{% if (view_mode and owner) or not view_mode -%}")
|
||||
(a
|
||||
("class" "flush")
|
||||
("href" "{% if view_mode -%} /@{{ owner.username }}/{{ journal.title }} {%- else -%} /journals/{{ journal.id }}/0 {%- endif %}")
|
||||
(b (text "{{ journal.title }}")))
|
||||
(text "{%- endif %}")
|
||||
(text "{% if (view_mode and owner) or not view_mode -%}")
|
||||
(a
|
||||
("class" "flush")
|
||||
("href" "{% if view_mode -%} /@{{ owner.username }}/{{ journal.title }} {%- else -%} /journals/{{ journal.id }}/0 {%- endif %}")
|
||||
(b (text "{{ journal.title }}")))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(text "{% if note -%}")
|
||||
(span (text "/"))
|
||||
(b (text "{{ note.title }}"))
|
||||
(text "{%- endif %}"))
|
||||
(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
|
||||
|
@ -196,10 +198,29 @@
|
|||
(text "{{ icon \"check\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.save\" }}")))))))
|
||||
|
||||
; users should also be able to manage the journal's sub directories here
|
||||
(details
|
||||
("class" "w-full")
|
||||
(summary
|
||||
("class" "button lowered w-full justify-start")
|
||||
(icon (text "folders"))
|
||||
(str (text "journals:label.directories")))
|
||||
|
||||
(div
|
||||
("class" "card flex flex-col gap-2 lowered")
|
||||
(text "{{ components::directories_editor(dirs=journal.dirs) }}")))
|
||||
(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 "{% if tag|length > 0 -%}")
|
||||
(a
|
||||
("href" "?")
|
||||
("class" "notification chip w-content")
|
||||
(text "{{ tag }}"))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(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 %}")
|
||||
|
@ -252,15 +273,30 @@
|
|||
("href" "#/preview")
|
||||
("data-tab-button" "preview")
|
||||
("data-turbo" "false")
|
||||
(str (text "journals:label.preview_pane"))))
|
||||
(str (text "journals:label.preview_pane")))
|
||||
|
||||
(a
|
||||
("href" "#/tags")
|
||||
("data-tab-button" "tags")
|
||||
("data-turbo" "false")
|
||||
("class" "hidden")
|
||||
(str (text "journals:action.edit_tags"))))
|
||||
(text "{%- endif %}")
|
||||
|
||||
; tabs
|
||||
(text "{{ components::note_tags(note=note) }}")
|
||||
|
||||
(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
|
||||
("class" "flex flex-col gap-2 card")
|
||||
("style" "animation: fadein ease-in-out 1 0.5s forwards running")
|
||||
("id" "editor_tab"))
|
||||
|
||||
(button
|
||||
("onclick" "change_note_content('{{ note.id }}')")
|
||||
(icon (text "check"))
|
||||
(str (text "general:action.save"))))
|
||||
|
||||
(div
|
||||
("data-tab" "preview")
|
||||
|
@ -268,10 +304,51 @@
|
|||
("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")))
|
||||
(div
|
||||
("data-tab" "tags")
|
||||
("class" "flex flex-col gap-2 card hidden")
|
||||
("style" "animation: fadein ease-in-out 1 0.5s forwards running")
|
||||
(form
|
||||
("onsubmit" "save_tags(event)")
|
||||
("class" "flex flex-col gap-1")
|
||||
(label
|
||||
("for" "tags")
|
||||
(str (text "journals:action.tags"))
|
||||
(textarea
|
||||
("type" "text")
|
||||
("name" "tags")
|
||||
("id" "tags")
|
||||
("placeholder" "tags")
|
||||
("required" "")
|
||||
("minlength" "2")
|
||||
("maxlength" "128")
|
||||
(text "{% for tag in note.tags -%} {{ tag }}, {% endfor %}"))
|
||||
(span ("class" "fade") (text "Tags should be separated by a comma.")))
|
||||
|
||||
(button
|
||||
(icon (text "check"))
|
||||
(str (text "general:action.save"))))
|
||||
|
||||
(script
|
||||
(text "globalThis.save_tags = (e) => {
|
||||
event.preventDefault();
|
||||
fetch(\"/api/v1/notes/{{ selected_note }}/tags\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tags: e.target.tags.value.split(\",\").map(t => t.trim()).filter(t => t),
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
}")))
|
||||
|
||||
; init codemirror
|
||||
(script ("id" "editor_content") ("type" "text/markdown") (text "{{ note.content|remove_script_tags|safe }}"))
|
||||
|
@ -281,6 +358,7 @@
|
|||
document.getElementById(\"preview_tab\").attachShadow({ mode: \"open\" });
|
||||
}
|
||||
|
||||
document.getElementById(\"editor_tab\").innerHTML = \"\";
|
||||
globalThis.editor = CodeMirror(document.getElementById(\"editor_tab\"), {
|
||||
value: document.getElementById(\"editor_content\").innerHTML,
|
||||
mode: \"{% if note.title == 'journal.css' -%} css {%- else -%} markdown {%- endif %}\",
|
||||
|
@ -349,7 +427,11 @@
|
|||
|
||||
(div
|
||||
("class" "flex w-full justify-between gap-2")
|
||||
(span (text "Last updated: ") (span ("class" "date") (text "{{ note.edited }}")))
|
||||
(div
|
||||
("class" "flex flex-col gap-2")
|
||||
(span (text "Last updated: ") (span ("class" "date") (text "{{ note.edited }}")))
|
||||
(text "{{ components::note_tags(note=note) }}"))
|
||||
|
||||
(text "{% if user and user.id == owner.id -%}")
|
||||
(button
|
||||
("class" "small")
|
||||
|
@ -600,6 +682,99 @@
|
|||
});
|
||||
}
|
||||
|
||||
globalThis.create_directory = async (parent) => {
|
||||
const name = await trigger(\"atto::prompt\", [\"Directory name:\"]);
|
||||
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(\"/api/v1/journals/{{ selected_journal }}/dirs\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
parent,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
globalThis.delete_directory = async (id) => {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this? This will delete all notes within this directory.\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(\"/api/v1/journals/{{ selected_journal }}/dirs\", {
|
||||
method: \"DELETE\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
fetch(`/api/v1/notes/{{ selected_journal }}/dir/${id}`, {
|
||||
method: \"DELETE\",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
globalThis.move_note_dir = async (id, dir) => {
|
||||
fetch(`/api/v1/notes/${id}/dir`, {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
dir,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// sidebars
|
||||
window.SIDEBARS_OPEN = false;
|
||||
if (new URLSearchParams(window.location.search).get(\"nav\") === \"true\") {
|
||||
|
@ -642,4 +817,24 @@
|
|||
notes_list.style.left = \"0\";
|
||||
}
|
||||
}")))
|
||||
|
||||
(text "{% if journal -%}")
|
||||
; note mover
|
||||
(dialog
|
||||
("id" "note_mover_dialog")
|
||||
(div
|
||||
("class" "inner flex flex-col gap-2")
|
||||
(p (text "Select a directory to move this note into:"))
|
||||
(text "{{ components::note_mover_dirs(dirs=journal.dirs) }}")
|
||||
(div
|
||||
("class" "flex justify-between")
|
||||
(div)
|
||||
(div
|
||||
("class" "flex gap-2")
|
||||
(button
|
||||
("class" "bold red lowered")
|
||||
("onclick" "document.getElementById('note_mover_dialog').close()")
|
||||
("type" "button")
|
||||
(text "{{ icon \"x\" }} {{ text \"dialog:action.close\" }}"))))))
|
||||
(text "{%- endif %}")
|
||||
(text "{% endblock %}")
|
||||
|
|
|
@ -4,9 +4,12 @@ use axum::{
|
|||
Extension,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use tetratto_shared::snow::Snowflake;
|
||||
use crate::{
|
||||
get_user_from_token,
|
||||
routes::api::v1::{UpdateJournalPrivacy, CreateJournal, UpdateJournalTitle},
|
||||
routes::api::v1::{
|
||||
AddJournalDir, CreateJournal, RemoveJournalDir, UpdateJournalPrivacy, UpdateJournalTitle,
|
||||
},
|
||||
State,
|
||||
};
|
||||
use tetratto_core::{
|
||||
|
@ -198,3 +201,86 @@ pub async fn delete_request(
|
|||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn add_dir_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
Json(props): Json<AddJournalDir>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
if props.name.len() > 32 {
|
||||
return Json(Error::DataTooLong("name".to_string()).into());
|
||||
}
|
||||
|
||||
let mut journal = match data.get_journal_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
// add dir
|
||||
journal.dirs.push((
|
||||
Snowflake::new().to_string().parse::<usize>().unwrap(),
|
||||
match props.parent.parse() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return Json(Error::Unknown.into()),
|
||||
},
|
||||
props.name,
|
||||
));
|
||||
|
||||
// ...
|
||||
match data.update_journal_dirs(id, &user, journal.dirs).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Journal updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn remove_dir_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
Json(props): Json<RemoveJournalDir>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let mut journal = match data.get_journal_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
// add dir
|
||||
let dir_id: usize = match props.dir.parse() {
|
||||
Ok(x) => x,
|
||||
Err(_) => return Json(Error::Unknown.into()),
|
||||
};
|
||||
|
||||
journal
|
||||
.dirs
|
||||
.remove(match journal.dirs.iter().position(|x| x.0 == dir_id) {
|
||||
Some(idx) => idx,
|
||||
None => return Json(Error::GeneralNotFound("directory".to_string()).into()),
|
||||
});
|
||||
|
||||
// ...
|
||||
match data.update_journal_dirs(id, &user, journal.dirs).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Journal updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -570,14 +570,22 @@ pub fn routes() -> Router {
|
|||
"/journals/{id}/privacy",
|
||||
post(journals::update_privacy_request),
|
||||
)
|
||||
.route("/journals/{id}/dirs", post(journals::add_dir_request))
|
||||
.route("/journals/{id}/dirs", delete(journals::remove_dir_request))
|
||||
// notes
|
||||
.route("/notes", post(notes::create_request))
|
||||
.route("/notes/{id}", get(notes::get_request))
|
||||
.route("/notes/{id}", delete(notes::delete_request))
|
||||
.route("/notes/{id}/title", post(notes::update_title_request))
|
||||
.route("/notes/{id}/content", post(notes::update_content_request))
|
||||
.route("/notes/{id}/dir", post(notes::update_dir_request))
|
||||
.route("/notes/{id}/tags", post(notes::update_tags_request))
|
||||
.route("/notes/from_journal/{id}", get(notes::list_request))
|
||||
.route("/notes/preview", post(notes::render_markdown_request))
|
||||
.route(
|
||||
"/notes/{journal}/dir/{dir}",
|
||||
delete(notes::delete_by_dir_request),
|
||||
)
|
||||
// uploads
|
||||
.route("/uploads/{id}", get(uploads::get_request))
|
||||
.route("/uploads/{id}", delete(uploads::delete_request))
|
||||
|
@ -926,3 +934,24 @@ pub struct CreateMessageReaction {
|
|||
pub message: String,
|
||||
pub emoji: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateNoteDir {
|
||||
pub dir: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AddJournalDir {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub parent: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RemoveJournalDir {
|
||||
pub dir: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateNoteTags {
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
|
|
@ -7,7 +7,10 @@ use axum_extra::extract::CookieJar;
|
|||
use tetratto_shared::unix_epoch_timestamp;
|
||||
use crate::{
|
||||
get_user_from_token,
|
||||
routes::api::v1::{CreateNote, RenderMarkdown, UpdateNoteContent, UpdateNoteTitle},
|
||||
routes::api::v1::{
|
||||
CreateNote, RenderMarkdown, UpdateNoteContent, UpdateNoteDir, UpdateNoteTags,
|
||||
UpdateNoteTitle,
|
||||
},
|
||||
State,
|
||||
};
|
||||
use tetratto_core::{
|
||||
|
@ -222,8 +225,96 @@ pub async fn delete_request(
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn delete_by_dir_request(
|
||||
jar: CookieJar,
|
||||
Path((journal, id)): Path<(usize, usize)>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.delete_notes_by_journal_dir(journal, id, &user).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Notes deleted".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
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@", "@")
|
||||
}
|
||||
|
||||
pub async fn update_dir_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
Json(props): Json<UpdateNoteDir>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let note = match data.get_note_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
let journal = match data.get_journal_by_id(note.journal).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
// make sure dir exists
|
||||
let dir = match props.dir.parse::<usize>() {
|
||||
Ok(d) => d,
|
||||
Err(_) => return Json(Error::Unknown.into()),
|
||||
};
|
||||
|
||||
if dir != 0 {
|
||||
if journal.dirs.iter().find(|x| x.0 == dir).is_none() {
|
||||
return Json(Error::GeneralNotFound("directory".to_string()).into());
|
||||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
match data.update_note_dir(id, &user, dir as i64).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Note updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_tags_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
Json(props): Json<UpdateNoteTags>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.update_note_tags(id, &user, props.tags).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Note updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -158,14 +158,6 @@ pub async fn view_request(
|
|||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
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
|
||||
|
@ -199,7 +191,7 @@ pub async fn view_request(
|
|||
context.insert("note", ¬e);
|
||||
|
||||
context.insert("owner", &owner);
|
||||
context.insert("notes", ¬es);
|
||||
context.insert::<[i8; 0], &str>("notes", &[]);
|
||||
|
||||
context.insert("view_mode", &true);
|
||||
context.insert("is_editor", &false);
|
||||
|
@ -213,6 +205,7 @@ pub async fn index_view_request(
|
|||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path((owner, selected_journal)): Path<(String, String)>,
|
||||
Query(props): Query<JournalsAppQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let data = data.read().await;
|
||||
let user = match get_user_from_token!(jar, data.0) {
|
||||
|
@ -257,10 +250,23 @@ pub async fn index_view_request(
|
|||
}
|
||||
|
||||
// ...
|
||||
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 notes = if props.tag.is_empty() {
|
||||
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));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match data
|
||||
.0
|
||||
.get_notes_by_journal_tag(journal.id, &props.tag)
|
||||
.await
|
||||
{
|
||||
Ok(p) => Some(p),
|
||||
Err(e) => {
|
||||
return Err(Html(render_error(e, &jar, &data, &user).await));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -281,6 +287,7 @@ pub async fn index_view_request(
|
|||
|
||||
context.insert("view_mode", &true);
|
||||
context.insert("is_editor", &false);
|
||||
context.insert("tag", &props.tag);
|
||||
|
||||
// return
|
||||
Ok(Html(data.1.render("journals/app.html", &context).unwrap()))
|
||||
|
|
|
@ -196,4 +196,6 @@ pub struct RepostsQuery {
|
|||
pub struct JournalsAppQuery {
|
||||
#[serde(default)]
|
||||
pub view: bool,
|
||||
#[serde(default)]
|
||||
pub tag: String,
|
||||
}
|
||||
|
|
|
@ -3,5 +3,6 @@ CREATE TABLE IF NOT EXISTS journals (
|
|||
created BIGINT NOT NULL,
|
||||
owner BIGINT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
privacy TEXT NOT NULL
|
||||
privacy TEXT NOT NULL,
|
||||
dirs TEXT NOT NUll
|
||||
)
|
||||
|
|
|
@ -5,5 +5,7 @@ CREATE TABLE IF NOT EXISTS notes (
|
|||
title TEXT NOT NULL,
|
||||
journal BIGINT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
edited BIGINT NOT NULL
|
||||
edited BIGINT NOT NULL,
|
||||
dir BIGINT NOT NULL,
|
||||
tags TEXT NOT NULL
|
||||
)
|
||||
|
|
|
@ -20,6 +20,7 @@ impl DataManager {
|
|||
owner: get!(x->2(i64)) as usize,
|
||||
title: get!(x->3(String)),
|
||||
privacy: serde_json::from_str(&get!(x->4(String))).unwrap(),
|
||||
dirs: serde_json::from_str(&get!(x->5(String))).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -128,13 +129,14 @@ impl DataManager {
|
|||
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"INSERT INTO journals VALUES ($1, $2, $3, $4, $5)",
|
||||
"INSERT INTO journals VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
params![
|
||||
&(data.id as i64),
|
||||
&(data.created as i64),
|
||||
&(data.owner as i64),
|
||||
&data.title,
|
||||
&serde_json::to_string(&data.privacy).unwrap(),
|
||||
&serde_json::to_string(&data.dirs).unwrap(),
|
||||
]
|
||||
);
|
||||
|
||||
|
@ -183,4 +185,5 @@ impl DataManager {
|
|||
|
||||
auto_method!(update_journal_title(&str)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.journal:{}");
|
||||
auto_method!(update_journal_privacy(JournalPrivacyPermission)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.journal:{}");
|
||||
auto_method!(update_journal_dirs(Vec<(usize, usize, String)>)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET dirs = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.journal:{}");
|
||||
}
|
||||
|
|
|
@ -15,6 +15,8 @@ impl DataManager {
|
|||
journal: get!(x->4(i64)) as usize,
|
||||
content: get!(x->5(String)),
|
||||
edited: get!(x->6(i64)) as usize,
|
||||
dir: get!(x->7(i64)) as usize,
|
||||
tags: serde_json::from_str(&get!(x->8(String))).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -65,6 +67,31 @@ impl DataManager {
|
|||
Ok(res.unwrap())
|
||||
}
|
||||
|
||||
/// Get all notes by journal with the given tag.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `id` - the ID of the journal to fetch notes for
|
||||
/// * `tag`
|
||||
pub async fn get_notes_by_journal_tag(&self, id: usize, tag: &str) -> Result<Vec<Note>> {
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = query_rows!(
|
||||
&conn,
|
||||
"SELECT * FROM notes WHERE journal = $1 AND tags::jsonb ? $2 ORDER BY edited DESC",
|
||||
params![&(id as i64), tag],
|
||||
|x| { Self::get_note_from_row(x) }
|
||||
);
|
||||
|
||||
if res.is_err() {
|
||||
return Err(Error::GeneralNotFound("note".to_string()));
|
||||
}
|
||||
|
||||
Ok(res.unwrap())
|
||||
}
|
||||
|
||||
const MAXIMUM_FREE_NOTES_PER_JOURNAL: usize = 10;
|
||||
|
||||
/// Create a new note in the database.
|
||||
|
@ -137,7 +164,7 @@ impl DataManager {
|
|||
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"INSERT INTO notes VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
||||
"INSERT INTO notes VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
||||
params![
|
||||
&(data.id as i64),
|
||||
&(data.created as i64),
|
||||
|
@ -146,6 +173,8 @@ impl DataManager {
|
|||
&(data.journal as i64),
|
||||
&data.content,
|
||||
&(data.edited as i64),
|
||||
&(data.dir as i64),
|
||||
&serde_json::to_string(&data.tags).unwrap(),
|
||||
]
|
||||
);
|
||||
|
||||
|
@ -181,7 +210,45 @@ impl DataManager {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete all notes by dir ID.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `journal`
|
||||
/// * `dir`
|
||||
pub async fn delete_notes_by_journal_dir(
|
||||
&self,
|
||||
journal: usize,
|
||||
dir: usize,
|
||||
user: &User,
|
||||
) -> Result<()> {
|
||||
let journal = self.get_journal_by_id(journal).await?;
|
||||
|
||||
if journal.owner != user.id && !user.permissions.check(FinePermission::MANAGE_NOTES) {
|
||||
return Err(Error::NotAllowed);
|
||||
}
|
||||
|
||||
// ...
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"DELETE FROM notes WHERE dir = $1 AND journal = $2 ORDER BY edited DESC",
|
||||
&[&(dir as i64), &(journal.id as i64)]
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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_dir(i64)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET dir = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}");
|
||||
auto_method!(update_note_tags(Vec<String>)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET tags = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.note:{}");
|
||||
auto_method!(update_note_edited(i64) -> "UPDATE notes SET edited = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}");
|
||||
}
|
||||
|
|
|
@ -22,6 +22,10 @@ pub struct Journal {
|
|||
pub owner: usize,
|
||||
pub title: String,
|
||||
pub privacy: JournalPrivacyPermission,
|
||||
/// An array of directories notes can be placed in.
|
||||
///
|
||||
/// `Vec<(id, parent id, name)>`
|
||||
pub dirs: Vec<(usize, usize, String)>,
|
||||
}
|
||||
|
||||
impl Journal {
|
||||
|
@ -33,6 +37,7 @@ impl Journal {
|
|||
owner,
|
||||
title,
|
||||
privacy: JournalPrivacyPermission::default(),
|
||||
dirs: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -49,6 +54,12 @@ pub struct Note {
|
|||
pub journal: usize,
|
||||
pub content: String,
|
||||
pub edited: usize,
|
||||
/// The "id" of the directoryy this note is in.
|
||||
///
|
||||
/// Directories are held in the journal in the `dirs` column.
|
||||
pub dir: usize,
|
||||
/// An array of tags associated with the note.
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
impl Note {
|
||||
|
@ -64,6 +75,8 @@ impl Note {
|
|||
journal,
|
||||
content,
|
||||
edited: created,
|
||||
dir: 0,
|
||||
tags: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue