Compare commits
9 commits
Author | SHA1 | Date | |
---|---|---|---|
16843a6ab8 | |||
6be729de50 | |||
ffdf320c14 | |||
fa72d6a59d | |||
dc50f3a8af | |||
f0d1a1e8e4 | |||
eb5a0d146f | |||
1b1c1c0bea | |||
97b7e873ed |
46 changed files with 1558 additions and 83 deletions
|
@ -39,6 +39,7 @@ pub const LOADER_JS: &str = include_str!("./public/js/loader.js");
|
||||||
pub const ATTO_JS: &str = include_str!("./public/js/atto.js");
|
pub const ATTO_JS: &str = include_str!("./public/js/atto.js");
|
||||||
pub const ME_JS: &str = include_str!("./public/js/me.js");
|
pub const ME_JS: &str = include_str!("./public/js/me.js");
|
||||||
pub const STREAMS_JS: &str = include_str!("./public/js/streams.js");
|
pub const STREAMS_JS: &str = include_str!("./public/js/streams.js");
|
||||||
|
pub const CARP_JS: &str = include_str!("./public/js/carp.js");
|
||||||
|
|
||||||
// html
|
// html
|
||||||
pub const BODY: &str = include_str!("./public/html/body.lisp");
|
pub const BODY: &str = include_str!("./public/html/body.lisp");
|
||||||
|
|
|
@ -135,6 +135,8 @@ version = "1.0.0"
|
||||||
"communities:label.file" = "File"
|
"communities:label.file" = "File"
|
||||||
"communities:label.drafts" = "Drafts"
|
"communities:label.drafts" = "Drafts"
|
||||||
"communities:label.load" = "Load"
|
"communities:label.load" = "Load"
|
||||||
|
"communities:action.draw" = "Draw"
|
||||||
|
"communities:action.remove_drawing" = "Remove drawing"
|
||||||
|
|
||||||
"notifs:action.mark_as_read" = "Mark as read"
|
"notifs:action.mark_as_read" = "Mark as read"
|
||||||
"notifs:action.mark_as_unread" = "Mark as unread"
|
"notifs:action.mark_as_unread" = "Mark as unread"
|
||||||
|
|
|
@ -389,3 +389,11 @@ blockquote {
|
||||||
transform: rotateZ(360deg);
|
transform: rotateZ(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: solid 5px var(--color-primary);
|
||||||
|
background: white;
|
||||||
|
box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size)
|
||||||
|
var(--color-shadow);
|
||||||
|
}
|
||||||
|
|
|
@ -1250,3 +1250,32 @@ details.accordion .inner {
|
||||||
.CodeMirror-focused .CodeMirror-placeholder {
|
.CodeMirror-focused .CodeMirror-placeholder {
|
||||||
opacity: 50%;
|
opacity: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.CodeMirror-gutters {
|
||||||
|
border-color: var(--color-super-lowered) !important;
|
||||||
|
background-color: var(--color-lowered) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CodeMirror-hints {
|
||||||
|
background: var(--color-raised) !important;
|
||||||
|
box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size)
|
||||||
|
var(--color-shadow);
|
||||||
|
border-radius: var(--radius) !important;
|
||||||
|
padding: var(--pad-1) !important;
|
||||||
|
border-color: var(--color-super-lowered) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CodeMirror-hints li {
|
||||||
|
color: var(--color-text-raised) !important;
|
||||||
|
border-radius: var(--radius) !important;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
color 0.15s;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: calc(var(--pad-1) / 2) var(--pad-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.CodeMirror-hints li.CodeMirror-hint-active {
|
||||||
|
background-color: var(--color-primary) !important;
|
||||||
|
color: var(--color-text-primary) !important;
|
||||||
|
}
|
||||||
|
|
|
@ -18,6 +18,42 @@
|
||||||
(div ("class" "skel") ("style" "width: 25%; height: 25px;"))
|
(div ("class" "skel") ("style" "width: 25%; height: 25px;"))
|
||||||
(div ("class" "skel") ("style" "width: 100%; height: 150px"))))))
|
(div ("class" "skel") ("style" "width: 100%; height: 150px"))))))
|
||||||
|
|
||||||
|
(template
|
||||||
|
("id" "carp_canvas")
|
||||||
|
(div
|
||||||
|
("class" "flex flex-col gap-2")
|
||||||
|
(div ("ui_ident" "canvas_loc"))
|
||||||
|
(div
|
||||||
|
("class" "flex justify-between gap-2")
|
||||||
|
(div
|
||||||
|
("class" "flex gap-2")
|
||||||
|
(input
|
||||||
|
("type" "color")
|
||||||
|
("style" "width: 5rem")
|
||||||
|
("ui_ident" "color_picker"))
|
||||||
|
|
||||||
|
(input
|
||||||
|
("type" "range")
|
||||||
|
("min" "1")
|
||||||
|
("max" "25")
|
||||||
|
("step" "1")
|
||||||
|
("value" "2")
|
||||||
|
("ui_ident" "stroke_range")))
|
||||||
|
|
||||||
|
(div
|
||||||
|
("class" "flex gap-2")
|
||||||
|
(button
|
||||||
|
("title" "Undo")
|
||||||
|
("ui_ident" "undo")
|
||||||
|
("type" "button")
|
||||||
|
(icon (text "undo")))
|
||||||
|
|
||||||
|
(button
|
||||||
|
("title" "Redo")
|
||||||
|
("ui_ident" "redo")
|
||||||
|
("type" "button")
|
||||||
|
(icon (text "redo")))))))
|
||||||
|
|
||||||
; random js
|
; random js
|
||||||
(text "<script data-turbo-permanent=\"true\" id=\"init-script\">
|
(text "<script data-turbo-permanent=\"true\" id=\"init-script\">
|
||||||
document.documentElement.addEventListener(\"turbo:load\", () => {
|
document.documentElement.addEventListener(\"turbo:load\", () => {
|
||||||
|
|
|
@ -405,7 +405,7 @@
|
||||||
(text "{%- endif %}"))))
|
(text "{%- endif %}"))))
|
||||||
(text "{% if community and show_community and community.id != config.town_square or question %}"))
|
(text "{% if community and show_community and community.id != config.town_square or question %}"))
|
||||||
|
|
||||||
(text "{%- endif %} {%- endmacro %} {% macro post_media(upload_ids) -%} {% if upload_ids|length > 0%}")
|
(text "{%- endif %} {%- endmacro %} {% macro post_media(upload_ids) -%} {% if upload_ids|length > 0 -%}")
|
||||||
(div
|
(div
|
||||||
("class" "media_gallery gap-2")
|
("class" "media_gallery gap-2")
|
||||||
(text "{% for upload in upload_ids %}")
|
(text "{% for upload in upload_ids %}")
|
||||||
|
@ -677,6 +677,8 @@
|
||||||
("class" "no_p_margin")
|
("class" "no_p_margin")
|
||||||
("style" "font-weight: 500")
|
("style" "font-weight: 500")
|
||||||
(text "{{ question.content|markdown|safe }}"))
|
(text "{{ question.content|markdown|safe }}"))
|
||||||
|
; question drawings
|
||||||
|
(text "{{ self::post_media(upload_ids=question.drawings) }}")
|
||||||
; anonymous user ip thing
|
; anonymous user ip thing
|
||||||
; this is only shown if the post author is anonymous AND we are a helper
|
; this is only shown if the post author is anonymous AND we are a helper
|
||||||
(text "{% if is_helper and owner.id == 0 %}")
|
(text "{% if is_helper and owner.id == 0 %}")
|
||||||
|
@ -693,7 +695,7 @@
|
||||||
(div
|
(div
|
||||||
("class" "flex gap-2 items-center justify-between"))))
|
("class" "flex gap-2 items-center justify-between"))))
|
||||||
|
|
||||||
(text "{%- endmacro %} {% macro create_question_form(receiver=\"0\", community=\"\", header=\"\", is_global=false) -%}")
|
(text "{%- endmacro %} {% macro create_question_form(receiver=\"0\", community=\"\", header=\"\", is_global=false, drawing_enabled=false) -%}")
|
||||||
(div
|
(div
|
||||||
("class" "card-nest")
|
("class" "card-nest")
|
||||||
(div
|
(div
|
||||||
|
@ -707,6 +709,12 @@
|
||||||
("onsubmit" "create_question_from_form(event)")
|
("onsubmit" "create_question_from_form(event)")
|
||||||
(div
|
(div
|
||||||
("class" "flex flex-col gap-1")
|
("class" "flex flex-col gap-1")
|
||||||
|
; carp canvas
|
||||||
|
(text "{% if drawing_enabled -%}")
|
||||||
|
(div ("ui_ident" "carp_canvas_field"))
|
||||||
|
(text "{%- endif %}")
|
||||||
|
|
||||||
|
; form
|
||||||
(label
|
(label
|
||||||
("for" "content")
|
("for" "content")
|
||||||
(text "{{ text \"communities:label.content\" }}"))
|
(text "{{ text \"communities:label.content\" }}"))
|
||||||
|
@ -718,25 +726,83 @@
|
||||||
("required" "")
|
("required" "")
|
||||||
("minlength" "2")
|
("minlength" "2")
|
||||||
("maxlength" "4096")))
|
("maxlength" "4096")))
|
||||||
(button
|
(div
|
||||||
("class" "primary")
|
("class" "flex gap-2")
|
||||||
(text "{{ text \"communities:action.create\" }}"))))
|
(button
|
||||||
|
("class" "primary")
|
||||||
|
(text "{{ text \"communities:action.create\" }}"))
|
||||||
|
|
||||||
|
(text "{% if drawing_enabled -%}")
|
||||||
|
(button
|
||||||
|
("class" "lowered")
|
||||||
|
("ui_ident" "add_drawing")
|
||||||
|
("onclick" "attach_drawing()")
|
||||||
|
("type" "button")
|
||||||
|
(text "{{ text \"communities:action.draw\" }}"))
|
||||||
|
|
||||||
|
(button
|
||||||
|
("class" "lowered red hidden")
|
||||||
|
("ui_ident" "remove_drawing")
|
||||||
|
("onclick" "remove_drawing()")
|
||||||
|
("type" "button")
|
||||||
|
(text "{{ text \"communities:action.remove_drawing\" }}"))
|
||||||
|
|
||||||
|
(script
|
||||||
|
(text "globalThis.attach_drawing = () => {
|
||||||
|
globalThis.gerald = trigger(\"carp::new\", [document.querySelector(\"[ui_ident=carp_canvas_field]\")]);
|
||||||
|
globalThis.gerald.create_canvas();
|
||||||
|
|
||||||
|
document.querySelector(\"[ui_ident=add_drawing]\").classList.add(\"hidden\");
|
||||||
|
document.querySelector(\"[ui_ident=remove_drawing]\").classList.remove(\"hidden\");
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.remove_drawing = async () => {
|
||||||
|
if (
|
||||||
|
!(await trigger(\"atto::confirm\", [
|
||||||
|
\"Are you sure you would like to do this?\",
|
||||||
|
]))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelector(\"[ui_ident=carp_canvas_field]\").innerHTML = \"\";
|
||||||
|
globalThis.gerald = null;
|
||||||
|
|
||||||
|
document.querySelector(\"[ui_ident=add_drawing]\").classList.remove(\"hidden\");
|
||||||
|
document.querySelector(\"[ui_ident=remove_drawing]\").classList.add(\"hidden\");
|
||||||
|
}"))
|
||||||
|
(text "{%- endif %}"))))
|
||||||
|
|
||||||
(script
|
(script
|
||||||
(text "async function create_question_from_form(e) {
|
(text "globalThis.gerald = null;
|
||||||
|
async function create_question_from_form(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
await trigger(\"atto::debounce\", [\"questions::create\"]);
|
await trigger(\"atto::debounce\", [\"questions::create\"]);
|
||||||
fetch(\"/api/v1/questions\", {
|
|
||||||
method: \"POST\",
|
// create body
|
||||||
headers: {
|
const body = new FormData();
|
||||||
\"Content-Type\": \"application/json\",
|
|
||||||
},
|
if (globalThis.gerald) {
|
||||||
body: JSON.stringify({
|
body.append(\"drawing.carpgraph\", new Blob([new Uint8Array(globalThis.gerald.as_carp2())], {
|
||||||
|
type: \"application/octet-stream\"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
body.append(
|
||||||
|
\"body\",
|
||||||
|
JSON.stringify({
|
||||||
content: e.target.content.value,
|
content: e.target.content.value,
|
||||||
receiver: \"{{ receiver }}\",
|
receiver: \"{{ receiver }}\",
|
||||||
community: \"{{ community }}\",
|
community: \"{{ community }}\",
|
||||||
is_global: \"{{ is_global }}\" == \"true\",
|
is_global: \"{{ is_global }}\" == \"true\",
|
||||||
}),
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ...
|
||||||
|
fetch(\"/api/v1/questions\", {
|
||||||
|
method: \"POST\",
|
||||||
|
body,
|
||||||
})
|
})
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
|
@ -747,6 +813,10 @@
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
e.target.reset();
|
e.target.reset();
|
||||||
|
|
||||||
|
if (globalThis.gerald) {
|
||||||
|
globalThis.gerald.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}"))
|
}"))
|
||||||
|
@ -1928,7 +1998,7 @@
|
||||||
(text "{%- endif %}")
|
(text "{%- endif %}")
|
||||||
|
|
||||||
; note listings
|
; note listings
|
||||||
(text "{% for note in notes %}")
|
(text "{% for note in notes %} {% if not view_mode or note.title != \"journal.css\" -%}")
|
||||||
(div
|
(div
|
||||||
("class" "flex flex-row gap-1")
|
("class" "flex flex-row gap-1")
|
||||||
(a
|
(a
|
||||||
|
@ -1958,6 +2028,6 @@
|
||||||
(icon (text "trash"))
|
(icon (text "trash"))
|
||||||
(str (text "general:action.delete")))))
|
(str (text "general:action.delete")))))
|
||||||
(text "{%- endif %}"))
|
(text "{%- endif %}"))
|
||||||
(text "{% endfor %}"))
|
(text "{%- endif %} {% endfor %}"))
|
||||||
(text "{%- endif %}")
|
(text "{%- endif %}")
|
||||||
(text "{%- endmacro %}")
|
(text "{%- endmacro %}")
|
||||||
|
|
|
@ -2,6 +2,27 @@
|
||||||
(text "{% if journal -%}") (title (text "{{ journal.title }}")) (text "{% else %}") (title (text "Journals - {{ config.name }}")) (text "{%- endif %}")
|
(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 }}"))
|
(link ("rel" "stylesheet") ("data-turbo-temporary" "true") ("href" "/css/chats.css?v=tetratto-{{ random_cache_breaker }}"))
|
||||||
|
|
||||||
|
(style
|
||||||
|
(text "html, body {
|
||||||
|
overflow: hidden auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 900px) {
|
||||||
|
.sidebar {
|
||||||
|
position: absolute;
|
||||||
|
top: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.sidebars_shown {
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
}"))
|
||||||
|
|
||||||
(text "{% if view_mode and journal and is_editor -%} {% if note -%}")
|
(text "{% if view_mode and journal and is_editor -%} {% if note -%}")
|
||||||
; redirect to note
|
; redirect to note
|
||||||
(meta ("http-equiv" "refresh") ("content" "0; url=/@{{ user.username }}/{{ journal.title }}/{{ note.title }}"))
|
(meta ("http-equiv" "refresh") ("content" "0; url=/@{{ user.username }}/{{ journal.title }}/{{ note.title }}"))
|
||||||
|
@ -9,6 +30,11 @@
|
||||||
; redirect to journal homepage
|
; redirect to journal homepage
|
||||||
(meta ("http-equiv" "refresh") ("content" "0; url=/@{{ user.username }}/{{ journal.title }}"))
|
(meta ("http-equiv" "refresh") ("content" "0; url=/@{{ user.username }}/{{ journal.title }}"))
|
||||||
(text "{%- endif %} {%- endif %}")
|
(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 "{% endblock %} {% block body %} {{ macros::nav(selected=\"journals\") }}")
|
||||||
(text "{% if not view_mode -%}")
|
(text "{% if not view_mode -%}")
|
||||||
(nav
|
(nav
|
||||||
|
@ -63,17 +89,19 @@
|
||||||
("href" "/api/v1/auth/user/find/{{ journal.owner }}")
|
("href" "/api/v1/auth/user/find/{{ journal.owner }}")
|
||||||
(text "{{ components::avatar(username=journal.owner, selector_type=\"id\", size=\"18px\") }}"))
|
(text "{{ components::avatar(username=journal.owner, selector_type=\"id\", size=\"18px\") }}"))
|
||||||
|
|
||||||
|
(text "{% if (view_mode and owner) or not view_mode -%}")
|
||||||
(a
|
(a
|
||||||
("class" "flush")
|
("class" "flush")
|
||||||
("href" "{% if view_mode -%} /@{{ owner.username }}/{{ journal.title }}/index {%- else -%} /journals/{{ journal.id }}/0 {%- endif %}")
|
("href" "{% if view_mode -%} /@{{ owner.username }}/{{ journal.title }} {%- else -%} /journals/{{ journal.id }}/0 {%- endif %}")
|
||||||
(b (text "{{ journal.title }}")))
|
(b (text "{{ journal.title }}")))
|
||||||
|
(text "{%- endif %}")
|
||||||
|
|
||||||
(text "{% if note -%}")
|
(text "{% if note -%}")
|
||||||
(span (text "/"))
|
(span (text "/"))
|
||||||
(b (text "{{ note.title }}"))
|
(b (text "{{ note.title }}"))
|
||||||
(text "{%- endif %}"))
|
(text "{%- endif %}"))
|
||||||
|
|
||||||
(text "{% if user and user.id == journal.owner -%}")
|
(text "{% if user and user.id == journal.owner and (not note or note.title != \"journal.css\") -%}")
|
||||||
(div
|
(div
|
||||||
("class" "pillmenu")
|
("class" "pillmenu")
|
||||||
(a
|
(a
|
||||||
|
@ -83,7 +111,7 @@
|
||||||
(icon (text "pencil")))
|
(icon (text "pencil")))
|
||||||
(a
|
(a
|
||||||
("class" "{% if view_mode -%}active{%- endif %}")
|
("class" "{% if view_mode -%}active{%- endif %}")
|
||||||
("href" "/@{{ user.username }}/{{ journal.title }}/{% if note -%} {{ note.title }} {%- else -%} index {%- endif %}")
|
("href" "/@{{ user.username }}/{{ journal.title }}{% if note -%} /{{ note.title }} {%- endif %}")
|
||||||
(icon (text "eye"))))
|
(icon (text "eye"))))
|
||||||
(text "{%- endif %}"))
|
(text "{%- endif %}"))
|
||||||
(text "{%- endif %}")
|
(text "{%- endif %}")
|
||||||
|
@ -96,6 +124,7 @@
|
||||||
("class" "card w-full flex flex-col gap-2")
|
("class" "card w-full flex flex-col gap-2")
|
||||||
(h3 (str (text "journals:label.welcome")))
|
(h3 (str (text "journals:label.welcome")))
|
||||||
(span (str (text "journals:label.select_a_journal")))
|
(span (str (text "journals:label.select_a_journal")))
|
||||||
|
(span ("class" "mobile") (str (text "journals:label.mobile_click_my_journals")))
|
||||||
(button
|
(button
|
||||||
("onclick" "create_journal()")
|
("onclick" "create_journal()")
|
||||||
(icon (text "plus"))
|
(icon (text "plus"))
|
||||||
|
@ -180,10 +209,36 @@
|
||||||
; import codemirror
|
; 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/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/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"))
|
(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
|
; tab bar
|
||||||
|
(text "{% if note.title != \"journal.css\" -%}")
|
||||||
(div
|
(div
|
||||||
("class" "pillmenu")
|
("class" "pillmenu")
|
||||||
(a
|
(a
|
||||||
|
@ -198,6 +253,7 @@
|
||||||
("data-tab-button" "preview")
|
("data-tab-button" "preview")
|
||||||
("data-turbo" "false")
|
("data-turbo" "false")
|
||||||
(str (text "journals:label.preview_pane"))))
|
(str (text "journals:label.preview_pane"))))
|
||||||
|
(text "{%- endif %}")
|
||||||
|
|
||||||
; tabs
|
; tabs
|
||||||
(div
|
(div
|
||||||
|
@ -220,11 +276,16 @@
|
||||||
; init codemirror
|
; init codemirror
|
||||||
(script ("id" "editor_content") ("type" "text/markdown") (text "{{ note.content|remove_script_tags|safe }}"))
|
(script ("id" "editor_content") ("type" "text/markdown") (text "{{ note.content|remove_script_tags|safe }}"))
|
||||||
(script
|
(script
|
||||||
(text "setTimeout(() => {
|
(text "setTimeout(async () => {
|
||||||
|
if (!document.getElementById(\"preview_tab\").shadowRoot) {
|
||||||
|
document.getElementById(\"preview_tab\").attachShadow({ mode: \"open\" });
|
||||||
|
}
|
||||||
|
|
||||||
globalThis.editor = CodeMirror(document.getElementById(\"editor_tab\"), {
|
globalThis.editor = CodeMirror(document.getElementById(\"editor_tab\"), {
|
||||||
value: document.getElementById(\"editor_content\").innerHTML,
|
value: document.getElementById(\"editor_content\").innerHTML,
|
||||||
mode: \"markdown\",
|
mode: \"{% if note.title == 'journal.css' -%} css {%- else -%} markdown {%- endif %}\",
|
||||||
lineWrapping: true,
|
lineWrapping: true,
|
||||||
|
lineNumbers: \"{{ note.title }}\" === \"journal.css\",
|
||||||
autoCloseBrackets: true,
|
autoCloseBrackets: true,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
viewportMargin: Number.POSITIVE_INFINITY,
|
viewportMargin: Number.POSITIVE_INFINITY,
|
||||||
|
@ -232,7 +293,8 @@
|
||||||
highlightFormatting: false,
|
highlightFormatting: false,
|
||||||
fencedCodeBlockHighlighting: false,
|
fencedCodeBlockHighlighting: false,
|
||||||
xml: false,
|
xml: false,
|
||||||
smartIndent: false,
|
smartIndent: true,
|
||||||
|
indentUnit: 4,
|
||||||
placeholder: `# {{ note.title }}`,
|
placeholder: `# {{ note.title }}`,
|
||||||
extraKeys: {
|
extraKeys: {
|
||||||
Home: \"goLineLeft\",
|
Home: \"goLineLeft\",
|
||||||
|
@ -243,6 +305,15 @@
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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) => {
|
document.querySelector(\"[data-tab-button=editor]\").addEventListener(\"click\", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
trigger(\"atto::hooks::tabs:switch\", [\"editor\"]);
|
trigger(\"atto::hooks::tabs:switch\", [\"editor\"]);
|
||||||
|
@ -262,7 +333,11 @@
|
||||||
})
|
})
|
||||||
).text();
|
).text();
|
||||||
|
|
||||||
document.getElementById(\"preview_tab\").innerHTML = res;
|
const preview_token = window.crypto.randomUUID();
|
||||||
|
document.getElementById(\"preview_tab\").shadowRoot.innerHTML = `${res}<style>
|
||||||
|
@import url(\"/css/style.css\");
|
||||||
|
@import url(\"/api/v1/journals/{{ journal.id }}/journal.css?v=preview-${preview_token}\");
|
||||||
|
</style>`;
|
||||||
trigger(\"atto::hooks::tabs:switch\", [\"preview\"]);
|
trigger(\"atto::hooks::tabs:switch\", [\"preview\"]);
|
||||||
});
|
});
|
||||||
}, 150);"))
|
}, 150);"))
|
||||||
|
@ -272,7 +347,34 @@
|
||||||
("class" "flex flex-col gap-2 card")
|
("class" "flex flex-col gap-2 card")
|
||||||
(text "{{ note.content|markdown|safe }}"))
|
(text "{{ note.content|markdown|safe }}"))
|
||||||
|
|
||||||
(span (text "Last updated: ") (span ("class" "date") (text "{{ note.edited }}")))
|
(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 %}")
|
||||||
(text "{%- endif %}")))
|
(text "{%- endif %}")))
|
||||||
(style
|
(style
|
||||||
|
@ -332,7 +434,7 @@
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title,
|
title,
|
||||||
content: `# ${title}`,
|
content: title === \"journal.css\" ? `/* ${title} */\\n` : `# ${title}`,
|
||||||
journal: \"{{ selected_journal }}\",
|
journal: \"{{ selected_journal }}\",
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
@ -431,8 +533,8 @@
|
||||||
|
|
||||||
globalThis.change_journal_privacy = async (e) => {
|
globalThis.change_journal_privacy = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const selected = event.target.selectedOptions[0];
|
const selected = e.target.selectedOptions[0];
|
||||||
fetch(\"/api/v1/journals/{{ selected_journal }}/privacy\", {
|
fetch(\"/api/v1/journals/{% if journal -%} {{ journal.id }} {%- else -%} {{ selected_journal }} {%- endif %}/privacy\", {
|
||||||
method: \"POST\",
|
method: \"POST\",
|
||||||
headers: {
|
headers: {
|
||||||
\"Content-Type\": \"application/json\",
|
\"Content-Type\": \"application/json\",
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
|
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
|
||||||
(div
|
(div
|
||||||
("style" "display: contents")
|
("style" "display: contents")
|
||||||
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header) }}"))
|
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}"))
|
||||||
|
|
||||||
(text "{%- endif %} {{ macros::profile_nav(selected=\"media\") }}")
|
(text "{%- endif %} {{ macros::profile_nav(selected=\"media\") }}")
|
||||||
(div
|
(div
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
|
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
|
||||||
(div
|
(div
|
||||||
("style" "display: contents")
|
("style" "display: contents")
|
||||||
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header) }}"))
|
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}"))
|
||||||
|
|
||||||
(text "{%- endif %} {{ macros::profile_nav(selected=\"outbox\") }}")
|
(text "{%- endif %} {{ macros::profile_nav(selected=\"outbox\") }}")
|
||||||
(div
|
(div
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
|
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
|
||||||
(div
|
(div
|
||||||
("style" "display: contents")
|
("style" "display: contents")
|
||||||
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header) }}"))
|
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}"))
|
||||||
|
|
||||||
(text "{%- endif %} {% if not tag and pinned|length != 0 -%}")
|
(text "{%- endif %} {% if not tag and pinned|length != 0 -%}")
|
||||||
(div
|
(div
|
||||||
|
@ -44,9 +44,10 @@
|
||||||
("ui_ident" "io_data_load")
|
("ui_ident" "io_data_load")
|
||||||
(div ("ui_ident" "io_data_marker"))))
|
(div ("ui_ident" "io_data_marker"))))
|
||||||
|
|
||||||
|
(text "{% set paged = user and user.settings.paged_timelines %}")
|
||||||
(script
|
(script
|
||||||
(text "setTimeout(() => {
|
(text "setTimeout(() => {
|
||||||
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?user_id={{ profile.id }}&tag={{ tag }}&page=\", Number.parseInt(\"{{ page }}\") - 1]);
|
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?user_id={{ profile.id }}&tag={{ tag }}&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
|
||||||
});"))
|
});"))
|
||||||
|
|
||||||
(text "{% endblock %}")
|
(text "{% endblock %}")
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
|
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
|
||||||
(div
|
(div
|
||||||
("style" "display: contents")
|
("style" "display: contents")
|
||||||
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header) }}"))
|
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}"))
|
||||||
|
|
||||||
(text "{%- endif %} {{ macros::profile_nav(selected=\"replies\") }}")
|
(text "{%- endif %} {{ macros::profile_nav(selected=\"replies\") }}")
|
||||||
(div
|
(div
|
||||||
|
|
|
@ -564,14 +564,14 @@
|
||||||
(li
|
(li
|
||||||
(text "Use custom CSS on your profile"))
|
(text "Use custom CSS on your profile"))
|
||||||
(li
|
(li
|
||||||
(text "Ability to use community emojis outside of
|
(text "Use community emojis outside of
|
||||||
their community"))
|
their community"))
|
||||||
(li
|
(li
|
||||||
(text "Ability to upload and use gif emojis"))
|
(text "Upload and use gif emojis"))
|
||||||
(li
|
(li
|
||||||
(text "Create infinite stack timelines"))
|
(text "Create infinite stack timelines"))
|
||||||
(li
|
(li
|
||||||
(text "Ability to upload images to posts"))
|
(text "Upload images to posts"))
|
||||||
(li
|
(li
|
||||||
(text "Save infinite post drafts"))
|
(text "Save infinite post drafts"))
|
||||||
(li
|
(li
|
||||||
|
@ -579,7 +579,7 @@
|
||||||
(li
|
(li
|
||||||
(text "Ability to create forges"))
|
(text "Ability to create forges"))
|
||||||
(li
|
(li
|
||||||
(text "Ability to create more than 1 app"))
|
(text "Create more than 1 app"))
|
||||||
(li
|
(li
|
||||||
(text "Create up to 10 stack blocks"))
|
(text "Create up to 10 stack blocks"))
|
||||||
(li
|
(li
|
||||||
|
@ -587,7 +587,9 @@
|
||||||
(li
|
(li
|
||||||
(text "Increased proxied image size"))
|
(text "Increased proxied image size"))
|
||||||
(li
|
(li
|
||||||
(text "Create infinite journals")))
|
(text "Create infinite journals"))
|
||||||
|
(li
|
||||||
|
(text "Create infinite notes in each journal")))
|
||||||
(a
|
(a
|
||||||
("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}")
|
("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}")
|
||||||
("class" "button")
|
("class" "button")
|
||||||
|
@ -1374,6 +1376,14 @@
|
||||||
\"{{ profile.settings.allow_anonymous_questions }}\",
|
\"{{ profile.settings.allow_anonymous_questions }}\",
|
||||||
\"checkbox\",
|
\"checkbox\",
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
\"enable_drawings\",
|
||||||
|
\"Allow users to create drawings and submit them with questions\",
|
||||||
|
],
|
||||||
|
\"{{ profile.settings.enable_drawings }}\",
|
||||||
|
\"checkbox\",
|
||||||
|
],
|
||||||
[
|
[
|
||||||
[\"motivational_header\", \"Motivational header\"],
|
[\"motivational_header\", \"Motivational header\"],
|
||||||
settings.motivational_header,
|
settings.motivational_header,
|
||||||
|
@ -1401,6 +1411,11 @@
|
||||||
\"Hides dislikes on all posts. Users will also no longer be able to dislike your posts.\",
|
\"Hides dislikes on all posts. Users will also no longer be able to dislike your posts.\",
|
||||||
\"text\",
|
\"text\",
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
[\"paged_timelines\", \"Make timelines paged instead of infinitely scrolled\"],
|
||||||
|
\"{{ profile.settings.paged_timelines }}\",
|
||||||
|
\"checkbox\",
|
||||||
|
],
|
||||||
[[], \"Fun\", \"title\"],
|
[[], \"Fun\", \"title\"],
|
||||||
[
|
[
|
||||||
[\"disable_gpa_fun\", \"Disable GPA\"],
|
[\"disable_gpa_fun\", \"Disable GPA\"],
|
||||||
|
|
|
@ -83,9 +83,10 @@
|
||||||
("ui_ident" "io_data_load")
|
("ui_ident" "io_data_load")
|
||||||
(div ("ui_ident" "io_data_marker")))
|
(div ("ui_ident" "io_data_marker")))
|
||||||
|
|
||||||
|
(text "{% set paged = user and user.settings.paged_timelines %}")
|
||||||
(script
|
(script
|
||||||
(text "setTimeout(() => {
|
(text "setTimeout(() => {
|
||||||
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?stack_id={{ stack.id }}&page=\", Number.parseInt(\"{{ page }}\") - 1]);
|
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?stack_id={{ stack.id }}&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
|
||||||
});"))
|
});"))
|
||||||
(text "{%- endif %}"))))
|
(text "{%- endif %}"))))
|
||||||
|
|
||||||
|
|
|
@ -33,9 +33,10 @@
|
||||||
("ui_ident" "io_data_load")
|
("ui_ident" "io_data_load")
|
||||||
(div ("ui_ident" "io_data_marker"))))
|
(div ("ui_ident" "io_data_marker"))))
|
||||||
|
|
||||||
|
(text "{% set paged = user and user.settings.paged_timelines %}")
|
||||||
(script
|
(script
|
||||||
(text "setTimeout(() => {
|
(text "setTimeout(() => {
|
||||||
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&page=\", Number.parseInt(\"{{ page }}\") - 1]);
|
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
|
||||||
});"))
|
});"))
|
||||||
|
|
||||||
(text "{% endblock %}")
|
(text "{% endblock %}")
|
||||||
|
|
|
@ -11,9 +11,10 @@
|
||||||
("ui_ident" "io_data_load")
|
("ui_ident" "io_data_load")
|
||||||
(div ("ui_ident" "io_data_marker"))))
|
(div ("ui_ident" "io_data_marker"))))
|
||||||
|
|
||||||
|
(text "{% set paged = user and user.settings.paged_timelines %}")
|
||||||
(script
|
(script
|
||||||
(text "setTimeout(() => {
|
(text "setTimeout(() => {
|
||||||
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=FollowingPosts&page=\", Number.parseInt(\"{{ page }}\") - 1]);
|
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=FollowingPosts&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
|
||||||
});"))
|
});"))
|
||||||
|
|
||||||
(text "{% endblock %}")
|
(text "{% endblock %}")
|
||||||
|
|
|
@ -31,9 +31,10 @@
|
||||||
(div ("ui_ident" "io_data_marker")))
|
(div ("ui_ident" "io_data_marker")))
|
||||||
(text "{%- endif %}"))
|
(text "{%- endif %}"))
|
||||||
|
|
||||||
|
(text "{% set paged = user and user.settings.paged_timelines %}")
|
||||||
(script
|
(script
|
||||||
(text "setTimeout(() => {
|
(text "setTimeout(() => {
|
||||||
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=MyCommunities&page=\", Number.parseInt(\"{{ page }}\") - 1]);
|
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=MyCommunities&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
|
||||||
});"))
|
});"))
|
||||||
|
|
||||||
(text "{% endblock %}")
|
(text "{% endblock %}")
|
||||||
|
|
|
@ -11,9 +11,10 @@
|
||||||
("ui_ident" "io_data_load")
|
("ui_ident" "io_data_load")
|
||||||
(div ("ui_ident" "io_data_marker"))))
|
(div ("ui_ident" "io_data_marker"))))
|
||||||
|
|
||||||
|
(text "{% set paged = user and user.settings.paged_timelines %}")
|
||||||
(script
|
(script
|
||||||
(text "setTimeout(() => {
|
(text "setTimeout(() => {
|
||||||
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=PopularPosts&page=\", Number.parseInt(\"{{ page }}\") - 1]);
|
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=PopularPosts&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
|
||||||
});"))
|
});"))
|
||||||
|
|
||||||
(text "{% endblock %}")
|
(text "{% endblock %}")
|
||||||
|
|
|
@ -30,3 +30,7 @@
|
||||||
(str (text "chats:label.go_back")))
|
(str (text "chats:label.go_back")))
|
||||||
(text "{%- endif %}"))
|
(text "{%- endif %}"))
|
||||||
(text "{%- endif %}")
|
(text "{%- endif %}")
|
||||||
|
|
||||||
|
(text "{% if paginated -%}")
|
||||||
|
(text "{{ components::pagination(page=page, items=list|length) }}")
|
||||||
|
(text "{%- endif %}")
|
||||||
|
|
|
@ -39,6 +39,7 @@ media_theme_pref();
|
||||||
// init
|
// init
|
||||||
use("me", () => {});
|
use("me", () => {});
|
||||||
use("streams", () => {});
|
use("streams", () => {});
|
||||||
|
use("carp", () => {});
|
||||||
|
|
||||||
// env
|
// env
|
||||||
self.DEBOUNCE = [];
|
self.DEBOUNCE = [];
|
||||||
|
@ -1141,7 +1142,7 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
self.define("io_data_load", (_, tmpl, page) => {
|
self.define("io_data_load", (_, tmpl, page, paginated_mode = false) => {
|
||||||
self.IO_DATA_MARKER = document.querySelector(
|
self.IO_DATA_MARKER = document.querySelector(
|
||||||
"[ui_ident=io_data_marker]",
|
"[ui_ident=io_data_marker]",
|
||||||
);
|
);
|
||||||
|
@ -1164,7 +1165,16 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
|
||||||
self.IO_DATA_PAGE = page;
|
self.IO_DATA_PAGE = page;
|
||||||
self.IO_DATA_SEEN_IDS = [];
|
self.IO_DATA_SEEN_IDS = [];
|
||||||
|
|
||||||
self.IO_DATA_OBSERVER.observe(self.IO_DATA_MARKER);
|
if (!paginated_mode) {
|
||||||
|
self.IO_DATA_OBSERVER.observe(self.IO_DATA_MARKER);
|
||||||
|
} else {
|
||||||
|
// immediately load first page
|
||||||
|
self.IO_DATA_TMPL = self.IO_DATA_TMPL.replace("&page=", "");
|
||||||
|
self.IO_DATA_TMPL += `&paginated=true&page=`;
|
||||||
|
self.io_load_data();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.IO_PAGINATED = paginated_mode;
|
||||||
});
|
});
|
||||||
|
|
||||||
self.define("io_load_data", async () => {
|
self.define("io_load_data", async () => {
|
||||||
|
|
624
crates/app/src/public/js/carp.js
Normal file
624
crates/app/src/public/js/carp.js
Normal file
|
@ -0,0 +1,624 @@
|
||||||
|
(() => {
|
||||||
|
const self = reg_ns("carp");
|
||||||
|
|
||||||
|
const END_OF_HEADER = 0x1a;
|
||||||
|
const COLOR = 0x1b;
|
||||||
|
const SIZE = 0x2b;
|
||||||
|
const LINE = 0x3b;
|
||||||
|
const POINT = 0x4b;
|
||||||
|
const EOF = 0x1f;
|
||||||
|
|
||||||
|
function enc(s, as = "guess") {
|
||||||
|
if ((as === "guess" && typeof s === "number") || as === "u32") {
|
||||||
|
// encode u32
|
||||||
|
const view = new DataView(new ArrayBuffer(16));
|
||||||
|
view.setUint32(0, s);
|
||||||
|
return new Uint8Array(view.buffer).slice(0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (as === "u16") {
|
||||||
|
// encode u16
|
||||||
|
const view = new DataView(new ArrayBuffer(16));
|
||||||
|
view.setUint16(0, s);
|
||||||
|
return new Uint8Array(view.buffer).slice(0, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// encode string
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
return encoder.encode(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dec(as, from) {
|
||||||
|
if (as === "u32") {
|
||||||
|
// decode u32
|
||||||
|
const view = new DataView(new Uint8Array(from).buffer);
|
||||||
|
return view.getUint32(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (as === "u16") {
|
||||||
|
// decode u16
|
||||||
|
const view = new DataView(new Uint8Array(from).buffer);
|
||||||
|
return view.getUint16(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// decode string
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
return decoder.decode(from);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lpad(size, input) {
|
||||||
|
if (input.length === size) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < size - (input.length - 1); i++) {
|
||||||
|
input = [0, ...input];
|
||||||
|
}
|
||||||
|
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.enc = enc;
|
||||||
|
self.dec = dec;
|
||||||
|
self.lpad = lpad;
|
||||||
|
|
||||||
|
self.CARPS = {};
|
||||||
|
self.define("new", function ({ $ }, bind_to, read_only = false) {
|
||||||
|
const canvas = new CarpCanvas(bind_to, read_only);
|
||||||
|
$.CARPS[bind_to.getAttribute("ui_ident")] = canvas;
|
||||||
|
return canvas;
|
||||||
|
});
|
||||||
|
|
||||||
|
class CarpCanvas {
|
||||||
|
#element; // HTMLElement
|
||||||
|
#ctx; // CanvasRenderingContext2D
|
||||||
|
#pos = { x: 0, y: 0 }; // Vec2
|
||||||
|
|
||||||
|
STROKE_SIZE = 2;
|
||||||
|
#stroke_size_old = 2;
|
||||||
|
COLOR = "#000000";
|
||||||
|
#color_old = "#000000";
|
||||||
|
|
||||||
|
COMMANDS = [];
|
||||||
|
HISTORY = [];
|
||||||
|
HISTORY_IDX = 0;
|
||||||
|
#cmd_store = [];
|
||||||
|
#undo_clear_future = false; // if we should clear to HISTORY_IDX on next draw
|
||||||
|
|
||||||
|
onedit;
|
||||||
|
read_only;
|
||||||
|
|
||||||
|
/// Create a new [`CarpCanvas`]
|
||||||
|
constructor(element, read_only) {
|
||||||
|
this.#element = element;
|
||||||
|
this.read_only = read_only;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push #line_store to LINES
|
||||||
|
push_state() {
|
||||||
|
this.COMMANDS = [...this.COMMANDS, ...this.#cmd_store];
|
||||||
|
this.#cmd_store = [];
|
||||||
|
|
||||||
|
this.HISTORY.push(this.COMMANDS);
|
||||||
|
this.HISTORY_IDX += 1;
|
||||||
|
|
||||||
|
if (this.#undo_clear_future) {
|
||||||
|
this.HISTORY = this.HISTORY.slice(0, this.HISTORY_IDX);
|
||||||
|
this.#undo_clear_future = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.onedit) {
|
||||||
|
this.onedit(this.as_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read current position in history and draw it.
|
||||||
|
draw_from_history() {
|
||||||
|
this.COMMANDS = this.HISTORY[this.HISTORY_IDX];
|
||||||
|
const bytes = this.as_carp2();
|
||||||
|
this.from_bytes(bytes); // draw
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Undo changes.
|
||||||
|
undo() {
|
||||||
|
if (this.HISTORY_IDX === 0) {
|
||||||
|
// cannot undo
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.HISTORY_IDX -= 1;
|
||||||
|
this.draw_from_history();
|
||||||
|
this.#undo_clear_future = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Redo changes.
|
||||||
|
redo() {
|
||||||
|
if (this.HISTORY_IDX === this.HISTORY.length - 1) {
|
||||||
|
// cannot redo
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.HISTORY_IDX += 1;
|
||||||
|
this.draw_from_history();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create canvas and init context
|
||||||
|
async create_canvas() {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
|
||||||
|
canvas.width = "300";
|
||||||
|
canvas.height = "200";
|
||||||
|
|
||||||
|
this.#ctx = canvas.getContext("2d");
|
||||||
|
|
||||||
|
if (!this.read_only) {
|
||||||
|
// desktop
|
||||||
|
canvas.addEventListener(
|
||||||
|
"mousemove",
|
||||||
|
(e) => {
|
||||||
|
this.draw_event(e);
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
canvas.addEventListener(
|
||||||
|
"mouseup",
|
||||||
|
(e) => {
|
||||||
|
this.push_state();
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
canvas.addEventListener(
|
||||||
|
"mousedown",
|
||||||
|
(e) => {
|
||||||
|
this.#cmd_store.push({
|
||||||
|
type: "Line",
|
||||||
|
data: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.move_event(e);
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
canvas.addEventListener(
|
||||||
|
"mouseenter",
|
||||||
|
(e) => {
|
||||||
|
this.move_event(e);
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// mobile
|
||||||
|
canvas.addEventListener(
|
||||||
|
"touchmove",
|
||||||
|
(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
e.clientX = e.changedTouches[0].clientX;
|
||||||
|
e.clientY = e.changedTouches[0].clientY;
|
||||||
|
|
||||||
|
this.draw_event(e, true);
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
canvas.addEventListener(
|
||||||
|
"touchstart",
|
||||||
|
(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
e.clientX = e.changedTouches[0].clientX;
|
||||||
|
e.clientY = e.changedTouches[0].clientY;
|
||||||
|
|
||||||
|
this.#cmd_store.push({
|
||||||
|
type: "Line",
|
||||||
|
data: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.move_event(e);
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
canvas.addEventListener(
|
||||||
|
"touchend",
|
||||||
|
(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
e.clientX = e.changedTouches[0].clientX;
|
||||||
|
e.clientY = e.changedTouches[0].clientY;
|
||||||
|
|
||||||
|
this.push_state();
|
||||||
|
this.move_event(e);
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// add controls
|
||||||
|
const controls_tmpl = document
|
||||||
|
.getElementById("carp_canvas")
|
||||||
|
.content.cloneNode(true);
|
||||||
|
this.#element.appendChild(controls_tmpl);
|
||||||
|
|
||||||
|
const canvas_loc = this.#element.querySelector(
|
||||||
|
"[ui_ident=canvas_loc]",
|
||||||
|
);
|
||||||
|
canvas_loc.appendChild(canvas);
|
||||||
|
|
||||||
|
const color_picker = this.#element.querySelector(
|
||||||
|
"[ui_ident=color_picker]",
|
||||||
|
);
|
||||||
|
color_picker.addEventListener("change", (e) => {
|
||||||
|
this.set_old_color(this.COLOR);
|
||||||
|
this.COLOR = e.target.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const stroke_range = this.#element.querySelector(
|
||||||
|
"[ui_ident=stroke_range]",
|
||||||
|
);
|
||||||
|
stroke_range.addEventListener("change", (e) => {
|
||||||
|
this.set_old_stroke_size(this.STROKE_SIZE);
|
||||||
|
this.STROKE_SIZE = e.target.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const undo = this.#element.querySelector("[ui_ident=undo]");
|
||||||
|
undo.addEventListener("click", () => {
|
||||||
|
this.undo();
|
||||||
|
});
|
||||||
|
|
||||||
|
const redo = this.#element.querySelector("[ui_ident=redo]");
|
||||||
|
redo.addEventListener("click", () => {
|
||||||
|
this.redo();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resize the canvas
|
||||||
|
resize(size) {
|
||||||
|
this.#ctx.canvas.width = size.x;
|
||||||
|
this.#ctx.canvas.height = size.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the canvas
|
||||||
|
clear() {
|
||||||
|
const canvas = this.#ctx.canvas;
|
||||||
|
this.#ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the old color
|
||||||
|
set_old_color(value) {
|
||||||
|
this.#color_old = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the old stroke_size
|
||||||
|
set_old_stroke_size(value) {
|
||||||
|
this.#stroke_size_old = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update position (from event)
|
||||||
|
move_event(e) {
|
||||||
|
const rect = this.#ctx.canvas.getBoundingClientRect();
|
||||||
|
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
|
||||||
|
this.move({ x, y });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update position
|
||||||
|
move(pos) {
|
||||||
|
this.#pos.x = pos.x;
|
||||||
|
this.#pos.y = pos.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw on the canvas (from event)
|
||||||
|
draw_event(e, mobile = false) {
|
||||||
|
if (e.buttons !== 1 && mobile === false) return;
|
||||||
|
const rect = this.#ctx.canvas.getBoundingClientRect();
|
||||||
|
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
|
||||||
|
this.draw({ x, y });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw on the canvas
|
||||||
|
draw(pos, skip_line_store = false) {
|
||||||
|
this.#ctx.beginPath();
|
||||||
|
|
||||||
|
this.#ctx.lineWidth = this.STROKE_SIZE;
|
||||||
|
this.#ctx.strokeStyle = this.COLOR;
|
||||||
|
this.#ctx.lineCap = "round";
|
||||||
|
|
||||||
|
this.#ctx.moveTo(this.#pos.x, this.#pos.y);
|
||||||
|
this.move(pos);
|
||||||
|
this.#ctx.lineTo(this.#pos.x, this.#pos.y);
|
||||||
|
|
||||||
|
if (!skip_line_store) {
|
||||||
|
// yes flooring the values will make the image SLIGHTLY different,
|
||||||
|
// but it also saves THOUSANDS of characters
|
||||||
|
const point = [
|
||||||
|
Math.floor(this.#pos.x),
|
||||||
|
Math.floor(this.#pos.y),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (this.#color_old !== this.COLOR) {
|
||||||
|
this.#cmd_store.push({
|
||||||
|
type: "Color",
|
||||||
|
data: enc(this.COLOR.replace("#", "")),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.#stroke_size_old !== this.STROKE_SIZE) {
|
||||||
|
this.#cmd_store.push({
|
||||||
|
type: "Size",
|
||||||
|
data: lpad(2, enc(this.STROKE_SIZE, "u16")), // u16
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#cmd_store.push({
|
||||||
|
type: "Point",
|
||||||
|
data: [
|
||||||
|
// u32
|
||||||
|
...lpad(4, enc(point[0])),
|
||||||
|
...lpad(4, enc(point[1])),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.#color_old !== this.COLOR) {
|
||||||
|
// we've already seen it once, time to update it
|
||||||
|
this.set_old_color(this.COLOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.#stroke_size_old !== this.STROKE_SIZE) {
|
||||||
|
this.set_old_stroke_size(this.STROKE_SIZE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create blob and get URL
|
||||||
|
as_blob() {
|
||||||
|
const blob = this.#ctx.canvas.toBlob();
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create Carp2 representation of the graph
|
||||||
|
as_carp2() {
|
||||||
|
// most stuff should have an lpad of 4 to make sure it's a u32 (4 bytes)
|
||||||
|
const header = [
|
||||||
|
...enc("CG"),
|
||||||
|
...enc("02"),
|
||||||
|
...lpad(4, enc(this.#ctx.canvas.width)),
|
||||||
|
...lpad(4, enc(this.#ctx.canvas.height)),
|
||||||
|
END_OF_HEADER,
|
||||||
|
];
|
||||||
|
|
||||||
|
// build commands
|
||||||
|
const commands = [];
|
||||||
|
commands.push(COLOR);
|
||||||
|
commands.push(...enc("000000"));
|
||||||
|
commands.push(SIZE);
|
||||||
|
commands.push(...lpad(4, enc(2)).slice(2));
|
||||||
|
|
||||||
|
for (const command of this.COMMANDS) {
|
||||||
|
// this is `impl Into<Vec<u8>> for Command`
|
||||||
|
switch (command.type) {
|
||||||
|
case "Point":
|
||||||
|
commands.push(POINT);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "Line":
|
||||||
|
commands.push(LINE);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "Color":
|
||||||
|
commands.push(COLOR);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "Size":
|
||||||
|
commands.push(SIZE);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
commands.push(...command.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// this is so fucking stupid the fact that arraybuffers send as a fucking
|
||||||
|
// concatenated string of the NUMBERS of the bytes is so stupid this is
|
||||||
|
// actually crazy what the fuck is this shit
|
||||||
|
//
|
||||||
|
// didn't expect i'd have to do this shit myself considering it's done
|
||||||
|
// for you with File prototypes from a file input
|
||||||
|
const bin = [...header, ...commands, EOF];
|
||||||
|
let bin_str = "";
|
||||||
|
|
||||||
|
for (const byte of bin) {
|
||||||
|
bin_str += String.fromCharCode(byte);
|
||||||
|
}
|
||||||
|
|
||||||
|
// return
|
||||||
|
return bin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export lines as string
|
||||||
|
as_string() {
|
||||||
|
return JSON.stringify(this.COMMANDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// From an array of bytes
|
||||||
|
from_bytes(input) {
|
||||||
|
this.clear();
|
||||||
|
|
||||||
|
let idx = -1;
|
||||||
|
function next() {
|
||||||
|
idx += 1;
|
||||||
|
return [idx, input[idx]];
|
||||||
|
}
|
||||||
|
|
||||||
|
function select_bytes(count) {
|
||||||
|
// select_bytes! macro
|
||||||
|
const data = [];
|
||||||
|
let seen_bytes = 0;
|
||||||
|
|
||||||
|
let [_, byte] = next();
|
||||||
|
while (byte !== undefined) {
|
||||||
|
seen_bytes += 1;
|
||||||
|
data.push(byte);
|
||||||
|
|
||||||
|
if (seen_bytes === count) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
[_, byte] = next();
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// everything past this is just a reverse implementation of carp2.rs in js
|
||||||
|
const commands = [];
|
||||||
|
const dimensions = { x: 0, y: 0 };
|
||||||
|
let in_header = true;
|
||||||
|
let seen_point = false;
|
||||||
|
let byte_buffer = [];
|
||||||
|
|
||||||
|
let [i, byte] = next();
|
||||||
|
while (byte !== undefined) {
|
||||||
|
switch (byte) {
|
||||||
|
case END_OF_HEADER:
|
||||||
|
in_header = false;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case COLOR:
|
||||||
|
{
|
||||||
|
const data = select_bytes(6);
|
||||||
|
commands.push({
|
||||||
|
type: "Color",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
this.COLOR = `#${dec("string", new Uint8Array(data))}`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SIZE:
|
||||||
|
{
|
||||||
|
const data = select_bytes(2);
|
||||||
|
commands.push({
|
||||||
|
type: "Size",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
this.STROKE_SIZE = dec("u16", data);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case POINT:
|
||||||
|
{
|
||||||
|
const data = select_bytes(8);
|
||||||
|
commands.push({
|
||||||
|
type: "Point",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
|
const point = {
|
||||||
|
x: dec("u32", data.slice(0, 4)),
|
||||||
|
y: dec("u32", data.slice(4, 8)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!seen_point) {
|
||||||
|
// this is the FIRST POINT that has been seen...
|
||||||
|
// we need to start drawing from here to avoid a line
|
||||||
|
// from 0,0 to the point
|
||||||
|
this.move(point);
|
||||||
|
seen_point = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.draw(point, true);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case LINE:
|
||||||
|
// each line starts at a new place (probably)
|
||||||
|
seen_point = false;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case EOF:
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
if (in_header) {
|
||||||
|
if (0 <= i < 2) {
|
||||||
|
// tag
|
||||||
|
} else if (2 <= i < 4) {
|
||||||
|
//version
|
||||||
|
} else if (4 <= i < 8) {
|
||||||
|
// width
|
||||||
|
byte_buffer.push(byte);
|
||||||
|
|
||||||
|
if (i === 7) {
|
||||||
|
dimensions.x = dec("u32", byte_buffer);
|
||||||
|
byte_buffer = [];
|
||||||
|
}
|
||||||
|
} else if (8 <= i < 12) {
|
||||||
|
// height
|
||||||
|
byte_buffer.push(byte);
|
||||||
|
|
||||||
|
if (i === 7) {
|
||||||
|
dimensions.y = dec("u32", byte_buffer);
|
||||||
|
byte_buffer = [];
|
||||||
|
this.resize(dimensions); // update canvas
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// misc byte
|
||||||
|
console.log(`extraneous byte at ${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...
|
||||||
|
[i, byte] = next();
|
||||||
|
}
|
||||||
|
|
||||||
|
return commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download image as `.carpgraph`
|
||||||
|
download() {
|
||||||
|
const blob = new Blob([new Uint8Array(this.as_carp2())], {
|
||||||
|
type: "image/carpgraph",
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.setAttribute("download", "image.carpgraph");
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download image as `.carpgraph1`
|
||||||
|
download_json() {
|
||||||
|
const string = this.as_string();
|
||||||
|
const blob = new Blob([string], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.setAttribute("download", "image.carpgraph_json");
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
|
@ -24,7 +24,7 @@ globalThis.ns = (ns) => {
|
||||||
|
|
||||||
if (!res) {
|
if (!res) {
|
||||||
return console.error(
|
return console.error(
|
||||||
"namespace does not exist, please use one of the following:",
|
`namespace "${ns}" does not exist, please use one of the following:`,
|
||||||
Object.keys(globalThis._app_base.ns_store),
|
Object.keys(globalThis._app_base.ns_store),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -213,7 +213,7 @@ pub async fn upload_avatar_request(
|
||||||
if mime == "image/gif" {
|
if mime == "image/gif" {
|
||||||
// gif image, don't encode
|
// gif image, don't encode
|
||||||
if img.0.len() > MAXIMUM_GIF_FILE_SIZE {
|
if img.0.len() > MAXIMUM_GIF_FILE_SIZE {
|
||||||
return Json(Error::DataTooLong("gif".to_string()).into());
|
return Json(Error::FileTooLarge.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
std::fs::write(&path, img.0).unwrap();
|
std::fs::write(&path, img.0).unwrap();
|
||||||
|
@ -226,7 +226,7 @@ pub async fn upload_avatar_request(
|
||||||
|
|
||||||
// check file size
|
// check file size
|
||||||
if img.0.len() > MAXIMUM_FILE_SIZE {
|
if img.0.len() > MAXIMUM_FILE_SIZE {
|
||||||
return Json(Error::DataTooLong("image".to_string()).into());
|
return Json(Error::FileTooLarge.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// upload image
|
// upload image
|
||||||
|
@ -314,7 +314,7 @@ pub async fn upload_banner_request(
|
||||||
if mime == "image/gif" {
|
if mime == "image/gif" {
|
||||||
// gif image, don't encode
|
// gif image, don't encode
|
||||||
if img.0.len() > MAXIMUM_GIF_FILE_SIZE {
|
if img.0.len() > MAXIMUM_GIF_FILE_SIZE {
|
||||||
return Json(Error::DataTooLong("gif".to_string()).into());
|
return Json(Error::FileTooLarge.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
std::fs::write(&path, img.0).unwrap();
|
std::fs::write(&path, img.0).unwrap();
|
||||||
|
@ -327,7 +327,7 @@ pub async fn upload_banner_request(
|
||||||
|
|
||||||
// check file size
|
// check file size
|
||||||
if img.0.len() > MAXIMUM_FILE_SIZE {
|
if img.0.len() > MAXIMUM_FILE_SIZE {
|
||||||
return Json(Error::DataTooLong("image".to_string()).into());
|
return Json(Error::FileTooLarge.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// upload image
|
// upload image
|
||||||
|
|
|
@ -136,7 +136,7 @@ pub async fn upload_avatar_request(
|
||||||
|
|
||||||
// check file size
|
// check file size
|
||||||
if img.0.len() > MAXIMUM_FILE_SIZE {
|
if img.0.len() > MAXIMUM_FILE_SIZE {
|
||||||
return Json(Error::DataTooLong("image".to_string()).into());
|
return Json(Error::FileTooLarge.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// upload image
|
// upload image
|
||||||
|
@ -191,7 +191,7 @@ pub async fn upload_banner_request(
|
||||||
|
|
||||||
// check file size
|
// check file size
|
||||||
if img.0.len() > MAXIMUM_FILE_SIZE {
|
if img.0.len() > MAXIMUM_FILE_SIZE {
|
||||||
return Json(Error::DataTooLong("image".to_string()).into());
|
return Json(Error::FileTooLarge.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// upload image
|
// upload image
|
||||||
|
|
|
@ -133,7 +133,7 @@ pub async fn create_request(
|
||||||
// check sizes
|
// check sizes
|
||||||
for img in &images {
|
for img in &images {
|
||||||
if img.len() > MAXIMUM_FILE_SIZE {
|
if img.len() > MAXIMUM_FILE_SIZE {
|
||||||
return Json(Error::DataTooLong("image".to_string()).into());
|
return Json(Error::FileTooLarge.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,7 @@ use tetratto_core::model::{
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
get_user_from_token,
|
get_user_from_token,
|
||||||
|
image::JsonMultipart,
|
||||||
routes::{api::v1::CreateQuestion, pages::PaginatedQuery},
|
routes::{api::v1::CreateQuestion, pages::PaginatedQuery},
|
||||||
State,
|
State,
|
||||||
};
|
};
|
||||||
|
@ -23,7 +24,7 @@ pub async fn create_request(
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Extension(data): Extension<State>,
|
Extension(data): Extension<State>,
|
||||||
Json(req): Json<CreateQuestion>,
|
JsonMultipart(drawings, req): JsonMultipart<CreateQuestion>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let data = &(data.read().await).0;
|
let data = &(data.read().await).0;
|
||||||
let user = get_user_from_token!(jar, data, oauth::AppScope::UserCreateQuestions);
|
let user = get_user_from_token!(jar, data, oauth::AppScope::UserCreateQuestions);
|
||||||
|
@ -70,7 +71,10 @@ pub async fn create_request(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match data.create_question(props).await {
|
match data
|
||||||
|
.create_question(props, drawings.iter().map(|x| x.to_vec()).collect())
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(id) => Json(ApiReturn {
|
Ok(id) => Json(ApiReturn {
|
||||||
ok: true,
|
ok: true,
|
||||||
message: "Question created".to_string(),
|
message: "Question created".to_string(),
|
||||||
|
|
|
@ -9,11 +9,14 @@ use crate::{
|
||||||
routes::api::v1::{UpdateJournalPrivacy, CreateJournal, UpdateJournalTitle},
|
routes::api::v1::{UpdateJournalPrivacy, CreateJournal, UpdateJournalTitle},
|
||||||
State,
|
State,
|
||||||
};
|
};
|
||||||
use tetratto_core::model::{
|
use tetratto_core::{
|
||||||
journals::{Journal, JournalPrivacyPermission},
|
database::NAME_REGEX,
|
||||||
oauth,
|
model::{
|
||||||
permissions::FinePermission,
|
journals::{Journal, JournalPrivacyPermission},
|
||||||
ApiReturn, Error,
|
oauth,
|
||||||
|
permissions::FinePermission,
|
||||||
|
ApiReturn, Error,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn get_request(
|
pub async fn get_request(
|
||||||
|
@ -46,6 +49,28 @@ pub async fn get_request(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_css_request(
|
||||||
|
Path(id): Path<usize>,
|
||||||
|
Extension(data): Extension<State>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let data = &(data.read().await).0;
|
||||||
|
|
||||||
|
let note = match data.get_note_by_journal_title(id, "journal.css").await {
|
||||||
|
Ok(x) => x,
|
||||||
|
Err(e) => {
|
||||||
|
return (
|
||||||
|
[("Content-Type", "text/css"), ("Cache-Control", "no-cache")],
|
||||||
|
format!("/* {e} */"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(
|
||||||
|
[("Content-Type", "text/css"), ("Cache-Control", "no-cache")],
|
||||||
|
note.content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
|
pub async fn list_request(jar: CookieJar, Extension(data): Extension<State>) -> 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::UserReadJournals) {
|
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadJournals) {
|
||||||
|
@ -99,7 +124,17 @@ pub async fn update_title_request(
|
||||||
None => return Json(Error::NotAllowed.into()),
|
None => return Json(Error::NotAllowed.into()),
|
||||||
};
|
};
|
||||||
|
|
||||||
props.title = props.title.replace(" ", "_");
|
props.title = props.title.replace(" ", "_").to_lowercase();
|
||||||
|
|
||||||
|
// check name
|
||||||
|
let regex = regex::RegexBuilder::new(NAME_REGEX)
|
||||||
|
.multi_line(true)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if regex.captures(&props.title).is_some() {
|
||||||
|
return Json(Error::MiscError("This title contains invalid characters".to_string()).into());
|
||||||
|
}
|
||||||
|
|
||||||
// make sure this title isn't already in use
|
// make sure this title isn't already in use
|
||||||
if data
|
if data
|
||||||
|
|
|
@ -551,6 +551,7 @@ pub fn routes() -> Router {
|
||||||
.route("/journals", post(journals::create_request))
|
.route("/journals", post(journals::create_request))
|
||||||
.route("/journals/{id}", get(journals::get_request))
|
.route("/journals/{id}", get(journals::get_request))
|
||||||
.route("/journals/{id}", delete(journals::delete_request))
|
.route("/journals/{id}", delete(journals::delete_request))
|
||||||
|
.route("/journals/{id}/journal.css", get(journals::get_css_request))
|
||||||
.route("/journals/{id}/title", post(journals::update_title_request))
|
.route("/journals/{id}/title", post(journals::update_title_request))
|
||||||
.route(
|
.route(
|
||||||
"/journals/{id}/privacy",
|
"/journals/{id}/privacy",
|
||||||
|
|
|
@ -10,12 +10,15 @@ use crate::{
|
||||||
routes::api::v1::{CreateNote, RenderMarkdown, UpdateNoteContent, UpdateNoteTitle},
|
routes::api::v1::{CreateNote, RenderMarkdown, UpdateNoteContent, UpdateNoteTitle},
|
||||||
State,
|
State,
|
||||||
};
|
};
|
||||||
use tetratto_core::model::{
|
use tetratto_core::{
|
||||||
journals::{JournalPrivacyPermission, Note},
|
database::NAME_REGEX,
|
||||||
oauth,
|
model::{
|
||||||
permissions::FinePermission,
|
journals::{JournalPrivacyPermission, Note},
|
||||||
uploads::CustomEmoji,
|
oauth,
|
||||||
ApiReturn, Error,
|
permissions::FinePermission,
|
||||||
|
uploads::CustomEmoji,
|
||||||
|
ApiReturn, Error,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn get_request(
|
pub async fn get_request(
|
||||||
|
@ -135,7 +138,17 @@ pub async fn update_title_request(
|
||||||
Err(e) => return Json(e.into()),
|
Err(e) => return Json(e.into()),
|
||||||
};
|
};
|
||||||
|
|
||||||
props.title = props.title.replace(" ", "_");
|
props.title = props.title.replace(" ", "_").to_lowercase();
|
||||||
|
|
||||||
|
// check name
|
||||||
|
let regex = regex::RegexBuilder::new(NAME_REGEX)
|
||||||
|
.multi_line(true)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if regex.captures(&props.title).is_some() {
|
||||||
|
return Json(Error::MiscError("This title contains invalid characters".to_string()).into());
|
||||||
|
}
|
||||||
|
|
||||||
// make sure this title isn't already in use
|
// make sure this title isn't already in use
|
||||||
if data
|
if data
|
||||||
|
|
|
@ -4,7 +4,7 @@ use axum_extra::extract::CookieJar;
|
||||||
use pathbufd::PathBufD;
|
use pathbufd::PathBufD;
|
||||||
use crate::{get_user_from_token, State};
|
use crate::{get_user_from_token, State};
|
||||||
use super::auth::images::read_image;
|
use super::auth::images::read_image;
|
||||||
use tetratto_core::model::{oauth, ApiReturn, Error};
|
use tetratto_core::model::{carp::CarpGraph, oauth, uploads::MediaType, ApiReturn, Error};
|
||||||
|
|
||||||
pub async fn get_request(
|
pub async fn get_request(
|
||||||
Path(id): Path<usize>,
|
Path(id): Path<usize>,
|
||||||
|
@ -39,10 +39,17 @@ pub async fn get_request(
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((
|
let bytes = read_image(path);
|
||||||
[("Content-Type", upload.what.mime())],
|
|
||||||
Body::from(read_image(path)),
|
if upload.what == MediaType::Carpgraph {
|
||||||
))
|
// conver to svg and return
|
||||||
|
return Ok((
|
||||||
|
[("Content-Type", "image/svg+xml".to_string())],
|
||||||
|
Body::from(CarpGraph::from_bytes(bytes).to_svg()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(([("Content-Type", upload.what.mime())], Body::from(bytes)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_request(
|
pub async fn delete_request(
|
||||||
|
|
|
@ -18,3 +18,4 @@ 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"));
|
||||||
serve_asset!(me_js_request: ME_JS("text/javascript"));
|
serve_asset!(me_js_request: ME_JS("text/javascript"));
|
||||||
serve_asset!(streams_js_request: STREAMS_JS("text/javascript"));
|
serve_asset!(streams_js_request: STREAMS_JS("text/javascript"));
|
||||||
|
serve_asset!(carp_js_request: CARP_JS("text/javascript"));
|
||||||
|
|
|
@ -19,6 +19,7 @@ pub fn routes(config: &Config) -> Router {
|
||||||
.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))
|
||||||
.route("/js/streams.js", get(assets::streams_js_request))
|
.route("/js/streams.js", get(assets::streams_js_request))
|
||||||
|
.route("/js/carp.js", get(assets::carp_js_request))
|
||||||
.nest_service(
|
.nest_service(
|
||||||
"/public",
|
"/public",
|
||||||
get_service(tower_http::services::ServeDir::new(&config.dirs.assets)),
|
get_service(tower_http::services::ServeDir::new(&config.dirs.assets)),
|
||||||
|
|
|
@ -116,7 +116,7 @@ pub async fn view_request(
|
||||||
}
|
}
|
||||||
|
|
||||||
// if we don't have a selected journal, we shouldn't be here probably
|
// if we don't have a selected journal, we shouldn't be here probably
|
||||||
if selected_journal.is_empty() {
|
if selected_journal.is_empty() | (selected_note == "journal.css") {
|
||||||
return Err(Html(
|
return Err(Html(
|
||||||
render_error(Error::NotAllowed, &jar, &data, &user).await,
|
render_error(Error::NotAllowed, &jar, &data, &user).await,
|
||||||
));
|
));
|
||||||
|
@ -207,3 +207,81 @@ pub async fn view_request(
|
||||||
// return
|
// return
|
||||||
Ok(Html(data.1.render("journals/app.html", &context).unwrap()))
|
Ok(Html(data.1.render("journals/app.html", &context).unwrap()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `/@{owner}/{journal}`
|
||||||
|
pub async fn index_view_request(
|
||||||
|
jar: CookieJar,
|
||||||
|
Extension(data): Extension<State>,
|
||||||
|
Path((owner, selected_journal)): Path<(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,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.insert("selected_note", &0);
|
||||||
|
context.insert("journal", &journal);
|
||||||
|
|
||||||
|
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()))
|
||||||
|
}
|
||||||
|
|
|
@ -576,6 +576,8 @@ pub struct TimelineQuery {
|
||||||
pub user_id: usize,
|
pub user_id: usize,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub tag: String,
|
pub tag: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub paginated: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `/_swiss_army_timeline`
|
/// `/_swiss_army_timeline`
|
||||||
|
@ -697,6 +699,7 @@ pub async fn swiss_army_timeline_request(
|
||||||
|
|
||||||
context.insert("list", &list);
|
context.insert("list", &list);
|
||||||
context.insert("page", &req.page);
|
context.insert("page", &req.page);
|
||||||
|
context.insert("paginated", &req.paginated);
|
||||||
Ok(Html(
|
Ok(Html(
|
||||||
data.1
|
data.1
|
||||||
.render("timelines/swiss_army.html", &context)
|
.render("timelines/swiss_army.html", &context)
|
||||||
|
|
|
@ -134,7 +134,7 @@ pub fn routes() -> Router {
|
||||||
// journals
|
// journals
|
||||||
.route("/journals", get(journals::redirect_request))
|
.route("/journals", get(journals::redirect_request))
|
||||||
.route("/journals/{journal}/{note}", get(journals::app_request))
|
.route("/journals/{journal}/{note}", get(journals::app_request))
|
||||||
.route("/@{owner}/{journal}", get(journals::view_request))
|
.route("/@{owner}/{journal}", get(journals::index_view_request))
|
||||||
.route("/@{owner}/{journal}/{note}", get(journals::view_request))
|
.route("/@{owner}/{journal}/{note}", get(journals::view_request))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -216,7 +216,7 @@ impl DataManager {
|
||||||
&0_i32,
|
&0_i32,
|
||||||
&(data.last_seen as i64),
|
&(data.last_seen as i64),
|
||||||
&String::new(),
|
&String::new(),
|
||||||
&"[]",
|
"[]",
|
||||||
&0_i32,
|
&0_i32,
|
||||||
&0_i32,
|
&0_i32,
|
||||||
&serde_json::to_string(&data.connections).unwrap(),
|
&serde_json::to_string(&data.connections).unwrap(),
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
use oiseau::{cache::Cache, query_row};
|
use oiseau::{cache::Cache, query_row};
|
||||||
use crate::{
|
use crate::{
|
||||||
|
database::common::NAME_REGEX,
|
||||||
model::{
|
model::{
|
||||||
auth::User,
|
auth::User,
|
||||||
permissions::FinePermission,
|
|
||||||
journals::{Journal, JournalPrivacyPermission},
|
journals::{Journal, JournalPrivacyPermission},
|
||||||
|
permissions::FinePermission,
|
||||||
Error, Result,
|
Error, Result,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -69,7 +70,7 @@ impl DataManager {
|
||||||
Ok(res.unwrap())
|
Ok(res.unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAXIMUM_FREE_JOURNALS: usize = 15;
|
const MAXIMUM_FREE_JOURNALS: usize = 5;
|
||||||
|
|
||||||
/// Create a new journal in the database.
|
/// Create a new journal in the database.
|
||||||
///
|
///
|
||||||
|
@ -83,7 +84,19 @@ impl DataManager {
|
||||||
return Err(Error::DataTooLong("title".to_string()));
|
return Err(Error::DataTooLong("title".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
data.title = data.title.replace(" ", "_");
|
data.title = data.title.replace(" ", "_").to_lowercase();
|
||||||
|
|
||||||
|
// check name
|
||||||
|
let regex = regex::RegexBuilder::new(NAME_REGEX)
|
||||||
|
.multi_line(true)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if regex.captures(&data.title).is_some() {
|
||||||
|
return Err(Error::MiscError(
|
||||||
|
"This title contains invalid characters".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// make sure this title isn't already in use
|
// make sure this title isn't already in use
|
||||||
if self
|
if self
|
||||||
|
|
|
@ -30,3 +30,4 @@ mod userblocks;
|
||||||
mod userfollows;
|
mod userfollows;
|
||||||
|
|
||||||
pub use drivers::DataManager;
|
pub use drivers::DataManager;
|
||||||
|
pub use common::NAME_REGEX;
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
use oiseau::cache::Cache;
|
use oiseau::cache::Cache;
|
||||||
|
use crate::database::common::NAME_REGEX;
|
||||||
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::{execute, get, params, query_row, query_rows, PostgresRow};
|
use oiseau::{execute, get, params, query_row, query_rows, PostgresRow};
|
||||||
|
@ -64,6 +65,8 @@ impl DataManager {
|
||||||
Ok(res.unwrap())
|
Ok(res.unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAXIMUM_FREE_NOTES_PER_JOURNAL: usize = 10;
|
||||||
|
|
||||||
/// Create a new note in the database.
|
/// Create a new note in the database.
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
|
@ -82,7 +85,33 @@ impl DataManager {
|
||||||
return Err(Error::DataTooLong("content".to_string()));
|
return Err(Error::DataTooLong("content".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
data.title = data.title.replace(" ", "_");
|
data.title = data.title.replace(" ", "_").to_lowercase();
|
||||||
|
|
||||||
|
// check number of notes
|
||||||
|
let owner = self.get_user_by_id(data.owner).await?;
|
||||||
|
|
||||||
|
if !owner.permissions.check(FinePermission::SUPPORTER) {
|
||||||
|
let journals = self.get_notes_by_journal(data.owner).await?;
|
||||||
|
|
||||||
|
if journals.len() >= Self::MAXIMUM_FREE_NOTES_PER_JOURNAL {
|
||||||
|
return Err(Error::MiscError(
|
||||||
|
"You already have the maximum number of notes you can have in this journal"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check name
|
||||||
|
let regex = regex::RegexBuilder::new(NAME_REGEX)
|
||||||
|
.multi_line(true)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if regex.captures(&data.title).is_some() {
|
||||||
|
return Err(Error::MiscError(
|
||||||
|
"This title contains invalid characters".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// make sure this title isn't already in use
|
// make sure this title isn't already in use
|
||||||
if self
|
if self
|
||||||
|
|
|
@ -1952,6 +1952,15 @@ impl DataManager {
|
||||||
self.delete_poll(y.poll_id, &user).await?;
|
self.delete_poll(y.poll_id, &user).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// delete question (if not global question)
|
||||||
|
if y.context.answering != 0 {
|
||||||
|
let question = self.get_question_by_id(y.context.answering).await?;
|
||||||
|
|
||||||
|
if !question.is_global {
|
||||||
|
self.delete_question(question.id, &user).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// return
|
// return
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
@ -2031,6 +2040,15 @@ impl DataManager {
|
||||||
for upload in y.uploads {
|
for upload in y.uploads {
|
||||||
self.delete_upload(upload).await?;
|
self.delete_upload(upload).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// delete question (if not global question)
|
||||||
|
if y.context.answering != 0 {
|
||||||
|
let question = self.get_question_by_id(y.context.answering).await?;
|
||||||
|
|
||||||
|
if !question.is_global {
|
||||||
|
self.delete_question(question.id, &user).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// incr parent comment count
|
// incr parent comment count
|
||||||
if let Some(replying_to) = y.replying_to {
|
if let Some(replying_to) = y.replying_to {
|
||||||
|
|
|
@ -2,6 +2,7 @@ use std::collections::HashMap;
|
||||||
use oiseau::cache::Cache;
|
use oiseau::cache::Cache;
|
||||||
use tetratto_shared::unix_epoch_timestamp;
|
use tetratto_shared::unix_epoch_timestamp;
|
||||||
use crate::model::communities_permissions::CommunityPermission;
|
use crate::model::communities_permissions::CommunityPermission;
|
||||||
|
use crate::model::uploads::{MediaType, MediaUpload};
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
Error, Result,
|
Error, Result,
|
||||||
communities::Question,
|
communities::Question,
|
||||||
|
@ -33,6 +34,7 @@ impl DataManager {
|
||||||
// ...
|
// ...
|
||||||
context: serde_json::from_str(&get!(x->10(String))).unwrap(),
|
context: serde_json::from_str(&get!(x->10(String))).unwrap(),
|
||||||
ip: get!(x->11(String)),
|
ip: get!(x->11(String)),
|
||||||
|
drawings: serde_json::from_str(&get!(x->12(String))).unwrap(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -333,13 +335,20 @@ impl DataManager {
|
||||||
Ok(res.unwrap())
|
Ok(res.unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAXIMUM_DRAWING_SIZE: usize = 32768; // 32 KiB
|
||||||
|
|
||||||
/// Create a new question in the database.
|
/// Create a new question in the database.
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
/// * `data` - a mock [`Question`] object to insert
|
/// * `data` - a mock [`Question`] object to insert
|
||||||
pub async fn create_question(&self, mut data: Question) -> Result<usize> {
|
pub async fn create_question(
|
||||||
|
&self,
|
||||||
|
mut data: Question,
|
||||||
|
drawings: Vec<Vec<u8>>,
|
||||||
|
) -> Result<usize> {
|
||||||
// check if we can post this
|
// check if we can post this
|
||||||
if data.is_global {
|
if data.is_global {
|
||||||
|
// global
|
||||||
if data.community > 0 {
|
if data.community > 0 {
|
||||||
// posting to community
|
// posting to community
|
||||||
data.receiver = 0;
|
data.receiver = 0;
|
||||||
|
@ -370,6 +379,7 @@ impl DataManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// single
|
||||||
let receiver = self.get_user_by_id(data.receiver).await?;
|
let receiver = self.get_user_by_id(data.receiver).await?;
|
||||||
|
|
||||||
if !receiver.settings.enable_questions {
|
if !receiver.settings.enable_questions {
|
||||||
|
@ -380,6 +390,10 @@ impl DataManager {
|
||||||
return Err(Error::NotAllowed);
|
return Err(Error::NotAllowed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !receiver.settings.enable_drawings && drawings.len() > 0 {
|
||||||
|
return Err(Error::DrawingsDisabled);
|
||||||
|
}
|
||||||
|
|
||||||
// check for ip block
|
// check for ip block
|
||||||
if self
|
if self
|
||||||
.get_ipblock_by_initiator_receiver(receiver.id, &data.ip)
|
.get_ipblock_by_initiator_receiver(receiver.id, &data.ip)
|
||||||
|
@ -390,6 +404,28 @@ impl DataManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// create uploads
|
||||||
|
if drawings.len() > 2 {
|
||||||
|
return Err(Error::MiscError(
|
||||||
|
"Too many uploads. Please use a maximum of 2".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for drawing in &drawings {
|
||||||
|
// this is the initial iter to check sizes, we'll do uploads after
|
||||||
|
if drawing.len() > Self::MAXIMUM_DRAWING_SIZE {
|
||||||
|
return Err(Error::FileTooLarge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _ in 0..drawings.len() {
|
||||||
|
data.drawings.push(
|
||||||
|
self.create_upload(MediaUpload::new(MediaType::Carpgraph, data.owner))
|
||||||
|
.await?
|
||||||
|
.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ...
|
// ...
|
||||||
let conn = match self.0.connect().await {
|
let conn = match self.0.connect().await {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
|
@ -398,7 +434,7 @@ impl DataManager {
|
||||||
|
|
||||||
let res = execute!(
|
let res = execute!(
|
||||||
&conn,
|
&conn,
|
||||||
"INSERT INTO questions VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)",
|
"INSERT INTO questions VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)",
|
||||||
params![
|
params![
|
||||||
&(data.id as i64),
|
&(data.id as i64),
|
||||||
&(data.created as i64),
|
&(data.created as i64),
|
||||||
|
@ -411,7 +447,8 @@ impl DataManager {
|
||||||
&0_i32,
|
&0_i32,
|
||||||
&0_i32,
|
&0_i32,
|
||||||
&serde_json::to_string(&data.context).unwrap(),
|
&serde_json::to_string(&data.context).unwrap(),
|
||||||
&data.ip
|
&data.ip,
|
||||||
|
&serde_json::to_string(&data.drawings).unwrap(),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -430,6 +467,23 @@ impl DataManager {
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// write to uploads
|
||||||
|
for (i, drawing_id) in data.drawings.iter().enumerate() {
|
||||||
|
let drawing = match drawings.get(i) {
|
||||||
|
Some(d) => d,
|
||||||
|
None => {
|
||||||
|
self.delete_upload(*drawing_id).await?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let upload = self.get_upload_by_id(*drawing_id).await?;
|
||||||
|
|
||||||
|
if let Err(e) = std::fs::write(&upload.path(&self.0.0).to_string(), drawing.to_vec()) {
|
||||||
|
return Err(Error::MiscError(e.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// return
|
// return
|
||||||
Ok(data.id)
|
Ok(data.id)
|
||||||
}
|
}
|
||||||
|
@ -495,6 +549,11 @@ impl DataManager {
|
||||||
return Err(Error::DatabaseError(e.to_string()));
|
return Err(Error::DatabaseError(e.to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// delete uploads
|
||||||
|
for upload in y.drawings {
|
||||||
|
self.delete_upload(upload).await?;
|
||||||
|
}
|
||||||
|
|
||||||
// return
|
// return
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
@ -231,6 +231,12 @@ pub struct UserSettings {
|
||||||
/// A list of strings the user has muted.
|
/// A list of strings the user has muted.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub muted: Vec<String>,
|
pub muted: Vec<String>,
|
||||||
|
/// If timelines are paged instead of infinitely scrolled.
|
||||||
|
#[serde(default)]
|
||||||
|
pub paged_timelines: bool,
|
||||||
|
/// If drawings are enabled for questions sent to the user.
|
||||||
|
#[serde(default)]
|
||||||
|
pub enable_drawings: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mime_avif() -> String {
|
fn mime_avif() -> String {
|
||||||
|
@ -332,7 +338,7 @@ impl User {
|
||||||
|
|
||||||
// parse
|
// parse
|
||||||
for char in input.chars() {
|
for char in input.chars() {
|
||||||
if (char == '\\') && !escape {
|
if ((char == '\\') | (char == '/')) && !escape {
|
||||||
escape = true;
|
escape = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
285
crates/core/src/model/carp.rs
Normal file
285
crates/core/src/model/carp.rs
Normal file
|
@ -0,0 +1,285 @@
|
||||||
|
use serde::{Serialize, Deserialize};
|
||||||
|
|
||||||
|
/// Starting at the beginning of the file, the header details specific information
|
||||||
|
/// about the file.
|
||||||
|
///
|
||||||
|
/// 1. `CG` tag (2 bytes)
|
||||||
|
/// 2. version number (2 bytes)
|
||||||
|
/// 3. width of graph (4 bytes)
|
||||||
|
/// 4. height of graph (4 bytes)
|
||||||
|
/// 5. `END_OF_HEADER`
|
||||||
|
///
|
||||||
|
/// The header has a total of 13 bytes. (12 of info, 1 of `END_OF_HEADER)
|
||||||
|
///
|
||||||
|
/// Everything after `END_OF_HEADER` should be another command and its parameters.
|
||||||
|
pub const END_OF_HEADER: u8 = 0x1a;
|
||||||
|
/// The color command marks the beginning of a hex-encoded color **string**.
|
||||||
|
///
|
||||||
|
/// The hastag character should **not** be included.
|
||||||
|
pub const COLOR: u8 = 0x1b;
|
||||||
|
/// The size command marks the beginning of a integer brush size.
|
||||||
|
pub const SIZE: u8 = 0x2b;
|
||||||
|
/// Marks the beginning of a new line.
|
||||||
|
pub const LINE: u8 = 0x3b;
|
||||||
|
/// A point marks the coordinates (relative to the previous `DELTA_ORIGIN`, or `(0, 0)`)
|
||||||
|
/// in which a point should be drawn.
|
||||||
|
///
|
||||||
|
/// The size and color are that of the previous `COLOR` and `SIZE` commands.
|
||||||
|
///
|
||||||
|
/// Points are two `u32`s (or 8 bytes in length).
|
||||||
|
pub const POINT: u8 = 0x4b;
|
||||||
|
/// An end-of-file marker.
|
||||||
|
pub const EOF: u8 = 0x1f;
|
||||||
|
|
||||||
|
/// A type of [`Command`].
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
#[repr(u8)]
|
||||||
|
pub enum CommandType {
|
||||||
|
/// [`END_OF_HEADER`]
|
||||||
|
EndOfHeader = END_OF_HEADER,
|
||||||
|
/// [`COLOR`]
|
||||||
|
Color = COLOR,
|
||||||
|
/// [`SIZE`]
|
||||||
|
Size = SIZE,
|
||||||
|
/// [`LINE`]
|
||||||
|
Line = LINE,
|
||||||
|
/// [`POINT`]
|
||||||
|
Point = POINT,
|
||||||
|
/// [`EOF`]
|
||||||
|
Eof = EOF,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct Command {
|
||||||
|
/// The type of the command.
|
||||||
|
pub r#type: CommandType,
|
||||||
|
/// Raw data as bytes.
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Command> for Vec<u8> {
|
||||||
|
fn from(val: Command) -> Self {
|
||||||
|
let mut d = val.data;
|
||||||
|
d.insert(0, val.r#type as u8);
|
||||||
|
d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A graph is CarpGraph's representation of an image. It's essentially just a
|
||||||
|
/// reproducable series of commands which a renderer can traverse to reconstruct
|
||||||
|
/// an image.
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct CarpGraph {
|
||||||
|
pub header: Vec<u8>,
|
||||||
|
pub dimensions: (u32, u32),
|
||||||
|
pub commands: Vec<Command>,
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! select_bytes {
|
||||||
|
($count:literal, $from:ident) => {{
|
||||||
|
let mut data: Vec<u8> = Vec::new();
|
||||||
|
let mut seen_bytes = 0;
|
||||||
|
|
||||||
|
while let Some((_, byte)) = $from.next() {
|
||||||
|
seen_bytes += 1;
|
||||||
|
data.push(byte.to_owned());
|
||||||
|
|
||||||
|
if seen_bytes == $count {
|
||||||
|
// we only need <count> bytes, stop just before we eat the next byte
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! spread {
|
||||||
|
($into:ident, $from:expr) => {
|
||||||
|
for byte in &$from {
|
||||||
|
$into.push(byte.to_owned())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CarpGraph {
|
||||||
|
pub fn to_bytes(&self) -> Vec<u8> {
|
||||||
|
let mut out: Vec<u8> = Vec::new();
|
||||||
|
|
||||||
|
// reconstruct header
|
||||||
|
spread!(out, self.header);
|
||||||
|
spread!(out, self.dimensions.0.to_be_bytes()); // width
|
||||||
|
spread!(out, self.dimensions.1.to_be_bytes()); // height
|
||||||
|
out.push(END_OF_HEADER);
|
||||||
|
|
||||||
|
// reconstruct commands
|
||||||
|
for command in &self.commands {
|
||||||
|
out.push(command.r#type as u8);
|
||||||
|
spread!(out, command.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...
|
||||||
|
out.push(EOF);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_bytes(bytes: Vec<u8>) -> Self {
|
||||||
|
let mut header: Vec<u8> = Vec::new();
|
||||||
|
let mut dimensions: (u32, u32) = (0, 0);
|
||||||
|
let mut commands: Vec<Command> = Vec::new();
|
||||||
|
|
||||||
|
let mut in_header: bool = true;
|
||||||
|
let mut byte_buffer: Vec<u8> = Vec::new(); // storage for bytes which need to construct a bigger type (like `u32`)
|
||||||
|
|
||||||
|
let mut bytes_iter = bytes.iter().enumerate();
|
||||||
|
while let Some((i, byte)) = bytes_iter.next() {
|
||||||
|
let byte = byte.to_owned();
|
||||||
|
match byte {
|
||||||
|
END_OF_HEADER => in_header = false,
|
||||||
|
COLOR => {
|
||||||
|
let data = select_bytes!(6, bytes_iter);
|
||||||
|
commands.push(Command {
|
||||||
|
r#type: CommandType::Color,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
SIZE => {
|
||||||
|
let data = select_bytes!(2, bytes_iter);
|
||||||
|
commands.push(Command {
|
||||||
|
r#type: CommandType::Size,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
POINT => {
|
||||||
|
let data = select_bytes!(8, bytes_iter);
|
||||||
|
commands.push(Command {
|
||||||
|
r#type: CommandType::Point,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
LINE => commands.push(Command {
|
||||||
|
r#type: CommandType::Line,
|
||||||
|
data: Vec::new(),
|
||||||
|
}),
|
||||||
|
EOF => break,
|
||||||
|
_ => {
|
||||||
|
if in_header {
|
||||||
|
if (0..2).contains(&i) {
|
||||||
|
// tag
|
||||||
|
header.push(byte);
|
||||||
|
} else if (2..4).contains(&i) {
|
||||||
|
// version
|
||||||
|
header.push(byte);
|
||||||
|
} else if (4..8).contains(&i) {
|
||||||
|
// width
|
||||||
|
byte_buffer.push(byte);
|
||||||
|
|
||||||
|
if i == 7 {
|
||||||
|
// end, construct from byte buffer
|
||||||
|
let (bytes, _) = byte_buffer.split_at(size_of::<u32>());
|
||||||
|
dimensions.0 = u32::from_be_bytes(bytes.try_into().unwrap());
|
||||||
|
byte_buffer = Vec::new();
|
||||||
|
}
|
||||||
|
} else if (8..12).contains(&i) {
|
||||||
|
// height
|
||||||
|
byte_buffer.push(byte);
|
||||||
|
|
||||||
|
if i == 11 {
|
||||||
|
// end, construct from byte buffer
|
||||||
|
let (bytes, _) = byte_buffer.split_at(size_of::<u32>());
|
||||||
|
dimensions.1 = u32::from_be_bytes(bytes.try_into().unwrap());
|
||||||
|
byte_buffer = Vec::new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// misc byte
|
||||||
|
println!("extraneous byte at {i}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
header,
|
||||||
|
dimensions,
|
||||||
|
commands,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_svg(&self) -> String {
|
||||||
|
let mut out: String = String::new();
|
||||||
|
out.push_str(&format!(
|
||||||
|
"<svg viewBox=\"0 0 {} {}\" xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\" style=\"background: white; width: {}px; height: {}px\" class=\"carpgraph\">",
|
||||||
|
self.dimensions.0, self.dimensions.1, self.dimensions.0, self.dimensions.1, self.dimensions.0, self.dimensions.1
|
||||||
|
));
|
||||||
|
|
||||||
|
// add lines
|
||||||
|
let mut stroke_size: u16 = 2;
|
||||||
|
let mut stroke_color: String = "000000".to_string();
|
||||||
|
|
||||||
|
let mut previous_x_y: Option<(u32, u32)> = None;
|
||||||
|
let mut line_path = String::new();
|
||||||
|
|
||||||
|
for command in &self.commands {
|
||||||
|
match command.r#type {
|
||||||
|
CommandType::Size => {
|
||||||
|
let (bytes, _) = command.data.split_at(size_of::<u16>());
|
||||||
|
stroke_size = u16::from_be_bytes(bytes.try_into().unwrap_or([0, 0]));
|
||||||
|
}
|
||||||
|
CommandType::Color => {
|
||||||
|
stroke_color =
|
||||||
|
String::from_utf8(command.data.to_owned()).unwrap_or("#000000".to_string())
|
||||||
|
}
|
||||||
|
CommandType::Line => {
|
||||||
|
if !line_path.is_empty() {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"<path d=\"{line_path}\" stroke=\"#{stroke_color}\" stroke-width=\"{stroke_size}\" />"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
previous_x_y = None;
|
||||||
|
line_path = String::new();
|
||||||
|
}
|
||||||
|
CommandType::Point => {
|
||||||
|
let (x, y) = command.data.split_at(size_of::<u32>());
|
||||||
|
let point = ({ u32::from_be_bytes(x.try_into().unwrap()) }, {
|
||||||
|
u32::from_be_bytes(y.try_into().unwrap())
|
||||||
|
});
|
||||||
|
|
||||||
|
// add to path string
|
||||||
|
line_path.push_str(&format!(
|
||||||
|
" M{} {}{}",
|
||||||
|
point.0,
|
||||||
|
point.1,
|
||||||
|
if let Some(pxy) = previous_x_y {
|
||||||
|
// line to there
|
||||||
|
format!(" L{} {}", pxy.0, pxy.1)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
previous_x_y = Some((point.0, point.1));
|
||||||
|
|
||||||
|
// add circular point
|
||||||
|
out.push_str(&format!(
|
||||||
|
"<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"#{stroke_color}\" />",
|
||||||
|
point.0,
|
||||||
|
point.1,
|
||||||
|
stroke_size / 2 // the size is technically the diameter of the circle
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => unreachable!("never pushed to commands"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !line_path.is_empty() {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"<path d=\"{line_path}\" stroke=\"#{stroke_color}\" stroke-width=\"{stroke_size}\" />"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// return
|
||||||
|
format!("{out}</svg>")
|
||||||
|
}
|
||||||
|
}
|
|
@ -345,6 +345,9 @@ pub struct Question {
|
||||||
/// The IP of the question creator for IP blocking and identifying anonymous users.
|
/// The IP of the question creator for IP blocking and identifying anonymous users.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ip: String,
|
pub ip: String,
|
||||||
|
/// The IDs of all uploads which hold this question's drawings.
|
||||||
|
#[serde(default)]
|
||||||
|
pub drawings: Vec<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Question {
|
impl Question {
|
||||||
|
@ -369,6 +372,7 @@ impl Question {
|
||||||
dislikes: 0,
|
dislikes: 0,
|
||||||
context: QuestionContext::default(),
|
context: QuestionContext::default(),
|
||||||
ip,
|
ip,
|
||||||
|
drawings: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
pub mod addr;
|
pub mod addr;
|
||||||
pub mod apps;
|
pub mod apps;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod carp;
|
||||||
pub mod channels;
|
pub mod channels;
|
||||||
pub mod communities;
|
pub mod communities;
|
||||||
pub mod communities_permissions;
|
pub mod communities_permissions;
|
||||||
|
@ -41,10 +42,12 @@ pub enum Error {
|
||||||
AlreadyAuthenticated,
|
AlreadyAuthenticated,
|
||||||
DataTooLong(String),
|
DataTooLong(String),
|
||||||
DataTooShort(String),
|
DataTooShort(String),
|
||||||
|
FileTooLarge,
|
||||||
UsernameInUse,
|
UsernameInUse,
|
||||||
TitleInUse,
|
TitleInUse,
|
||||||
QuestionsDisabled,
|
QuestionsDisabled,
|
||||||
RequiresSupporter,
|
RequiresSupporter,
|
||||||
|
DrawingsDisabled,
|
||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -62,10 +65,12 @@ impl Display for Error {
|
||||||
Self::AlreadyAuthenticated => "Already authenticated".to_string(),
|
Self::AlreadyAuthenticated => "Already authenticated".to_string(),
|
||||||
Self::DataTooLong(name) => format!("Given {name} is too long!"),
|
Self::DataTooLong(name) => format!("Given {name} is too long!"),
|
||||||
Self::DataTooShort(name) => format!("Given {name} is too short!"),
|
Self::DataTooShort(name) => format!("Given {name} is too short!"),
|
||||||
|
Self::FileTooLarge => "Given file is too large".to_string(),
|
||||||
Self::UsernameInUse => "Username in use".to_string(),
|
Self::UsernameInUse => "Username in use".to_string(),
|
||||||
Self::TitleInUse => "Title in use".to_string(),
|
Self::TitleInUse => "Title in use".to_string(),
|
||||||
Self::QuestionsDisabled => "You are not allowed to ask questions there".to_string(),
|
Self::QuestionsDisabled => "You are not allowed to ask questions there".to_string(),
|
||||||
Self::RequiresSupporter => "Only site supporters can do this".to_string(),
|
Self::RequiresSupporter => "Only site supporters can do this".to_string(),
|
||||||
|
Self::DrawingsDisabled => "You are not allowed to submit drawings there".to_string(),
|
||||||
_ => format!("An unknown error as occurred: ({:?})", self),
|
_ => format!("An unknown error as occurred: ({:?})", self),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@ use crate::config::Config;
|
||||||
use std::fs::{write, exists, remove_file};
|
use std::fs::{write, exists, remove_file};
|
||||||
use super::{Error, Result};
|
use super::{Error, Result};
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub enum MediaType {
|
pub enum MediaType {
|
||||||
#[serde(alias = "image/webp")]
|
#[serde(alias = "image/webp")]
|
||||||
Webp,
|
Webp,
|
||||||
|
@ -17,6 +17,8 @@ pub enum MediaType {
|
||||||
Jpg,
|
Jpg,
|
||||||
#[serde(alias = "image/gif")]
|
#[serde(alias = "image/gif")]
|
||||||
Gif,
|
Gif,
|
||||||
|
#[serde(alias = "image/carpgraph")]
|
||||||
|
Carpgraph,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MediaType {
|
impl MediaType {
|
||||||
|
@ -27,6 +29,7 @@ impl MediaType {
|
||||||
Self::Png => "png",
|
Self::Png => "png",
|
||||||
Self::Jpg => "jpg",
|
Self::Jpg => "jpg",
|
||||||
Self::Gif => "gif",
|
Self::Gif => "gif",
|
||||||
|
Self::Carpgraph => "carpgraph",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
2
sql_changes/questions_drawings.sql
Normal file
2
sql_changes/questions_drawings.sql
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE questions
|
||||||
|
ADD COLUMN drawings TEXT NOT NULL DEFAULT '[]';
|
Loading…
Add table
Add a link
Reference in a new issue