diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index a3bb588..5533a77 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -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 ME_JS: &str = include_str!("./public/js/me.js"); pub const STREAMS_JS: &str = include_str!("./public/js/streams.js"); +pub const CARP_JS: &str = include_str!("./public/js/carp.js"); // html pub const BODY: &str = include_str!("./public/html/body.lisp"); diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index b725251..11491fc 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -135,6 +135,8 @@ version = "1.0.0" "communities:label.file" = "File" "communities:label.drafts" = "Drafts" "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_unread" = "Mark as unread" diff --git a/crates/app/src/public/css/root.css b/crates/app/src/public/css/root.css index fbb1d4d..3d7dd62 100644 --- a/crates/app/src/public/css/root.css +++ b/crates/app/src/public/css/root.css @@ -389,3 +389,11 @@ blockquote { 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); +} diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index f592c77..0603ee1 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -1250,3 +1250,32 @@ details.accordion .inner { .CodeMirror-focused .CodeMirror-placeholder { 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; +} diff --git a/crates/app/src/public/html/body.lisp b/crates/app/src/public/html/body.lisp index 16a47d8..a8e398b 100644 --- a/crates/app/src/public/html/body.lisp +++ b/crates/app/src/public/html/body.lisp @@ -18,6 +18,42 @@ (div ("class" "skel") ("style" "width: 25%; height: 25px;")) (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 (text "<script data-turbo-permanent=\"true\" id=\"init-script\"> document.documentElement.addEventListener(\"turbo:load\", () => { diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 75a24ef..a9f6142 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -405,7 +405,7 @@ (text "{%- endif %}")))) (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 ("class" "media_gallery gap-2") (text "{% for upload in upload_ids %}") @@ -677,6 +677,8 @@ ("class" "no_p_margin") ("style" "font-weight: 500") (text "{{ question.content|markdown|safe }}")) + ; question drawings + (text "{{ self::post_media(upload_ids=question.drawings) }}") ; anonymous user ip thing ; this is only shown if the post author is anonymous AND we are a helper (text "{% if is_helper and owner.id == 0 %}") @@ -693,7 +695,7 @@ (div ("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 ("class" "card-nest") (div @@ -707,6 +709,12 @@ ("onsubmit" "create_question_from_form(event)") (div ("class" "flex flex-col gap-1") + ; carp canvas + (text "{% if drawing_enabled -%}") + (div ("ui_ident" "carp_canvas_field")) + (text "{%- endif %}") + + ; form (label ("for" "content") (text "{{ text \"communities:label.content\" }}")) @@ -718,25 +726,83 @@ ("required" "") ("minlength" "2") ("maxlength" "4096"))) - (button - ("class" "primary") - (text "{{ text \"communities:action.create\" }}")))) + (div + ("class" "flex gap-2") + (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 - (text "async function create_question_from_form(e) { + (text "globalThis.gerald = null; + async function create_question_from_form(e) { e.preventDefault(); await trigger(\"atto::debounce\", [\"questions::create\"]); - fetch(\"/api/v1/questions\", { - method: \"POST\", - headers: { - \"Content-Type\": \"application/json\", - }, - body: JSON.stringify({ + + // create body + const body = new FormData(); + + if (globalThis.gerald) { + 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, receiver: \"{{ receiver }}\", community: \"{{ community }}\", is_global: \"{{ is_global }}\" == \"true\", }), + ); + + // ... + fetch(\"/api/v1/questions\", { + method: \"POST\", + body, }) .then((res) => res.json()) .then((res) => { @@ -747,6 +813,10 @@ if (res.ok) { e.target.reset(); + + if (globalThis.gerald) { + globalThis.gerald.clear(); + } } }); }")) @@ -1928,7 +1998,7 @@ (text "{%- endif %}") ; note listings - (text "{% for note in notes %}") + (text "{% for note in notes %} {% if not view_mode or note.title != \"journal.css\" -%}") (div ("class" "flex flex-row gap-1") (a @@ -1958,6 +2028,6 @@ (icon (text "trash")) (str (text "general:action.delete"))))) (text "{%- endif %}")) - (text "{% endfor %}")) + (text "{%- endif %} {% endfor %}")) (text "{%- endif %}") (text "{%- endmacro %}") diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp index 267541a..54a5415 100644 --- a/crates/app/src/public/html/journals/app.lisp +++ b/crates/app/src/public/html/journals/app.lisp @@ -2,6 +2,27 @@ (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 }}")) +(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 -%}") ; redirect to note (meta ("http-equiv" "refresh") ("content" "0; url=/@{{ user.username }}/{{ journal.title }}/{{ note.title }}")) @@ -9,6 +30,11 @@ ; redirect to journal homepage (meta ("http-equiv" "refresh") ("content" "0; url=/@{{ user.username }}/{{ journal.title }}")) (text "{%- endif %} {%- endif %}") + +(text "{% if view_mode and journal -%}") +; add journal css +(link ("rel" "stylesheet") ("data-turbo-temporary" "true") ("href" "/api/v1/journals/{{ journal.id }}/journal.css?v=tetratto-{{ random_cache_breaker }}")) +(text "{%- endif %}") (text "{% endblock %} {% block body %} {{ macros::nav(selected=\"journals\") }}") (text "{% if not view_mode -%}") (nav @@ -63,17 +89,19 @@ ("href" "/api/v1/auth/user/find/{{ journal.owner }}") (text "{{ components::avatar(username=journal.owner, selector_type=\"id\", size=\"18px\") }}")) + (text "{% if (view_mode and owner) or not view_mode -%}") (a ("class" "flush") - ("href" "{% if view_mode -%} /@{{ owner.username }}/{{ journal.title }}/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 }}"))) + (text "{%- endif %}") (text "{% if note -%}") (span (text "/")) (b (text "{{ note.title }}")) (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 ("class" "pillmenu") (a @@ -83,7 +111,7 @@ (icon (text "pencil"))) (a ("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")))) (text "{%- endif %}")) (text "{%- endif %}") @@ -96,6 +124,7 @@ ("class" "card w-full flex flex-col gap-2") (h3 (str (text "journals:label.welcome"))) (span (str (text "journals:label.select_a_journal"))) + (span ("class" "mobile") (str (text "journals:label.mobile_click_my_journals"))) (button ("onclick" "create_journal()") (icon (text "plus")) @@ -180,10 +209,36 @@ ; import codemirror (script ("src" "https://unpkg.com/codemirror@5.39.2/lib/codemirror.js") ("data-turbo-temporary" "true")) (script ("src" "https://unpkg.com/codemirror@5.39.2/addon/display/placeholder.js") ("data-turbo-temporary" "true")) - (script ("src" "https://unpkg.com/codemirror@5.39.2/mode/markdown/markdown.js") ("data-turbo-temporary" "true")) (link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/lib/codemirror.css") ("data-turbo-temporary" "true")) + (text "{% if note.title == \"journal.css\" -%}") + ; css editor + (script ("src" "https://unpkg.com/codemirror@5.39.2/addon/edit/closebrackets.js") ("data-turbo-temporary" "true")) + (script ("src" "https://unpkg.com/codemirror@5.39.2/addon/hint/css-hint.js") ("data-turbo-temporary" "true")) + (script ("src" "https://unpkg.com/codemirror@5.39.2/addon/hint/show-hint.js") ("data-turbo-temporary" "true")) + (script ("src" "https://unpkg.com/codemirror@5.39.2/addon/lint/css-lint.js") ("data-turbo-temporary" "true")) + (script ("src" "https://unpkg.com/codemirror@5.39.2/mode/css/css.js") ("data-turbo-temporary" "true")) + (link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/addon/hint/show-hint.css") ("data-turbo-temporary" "true")) + (link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/addon/lint/lint.css") ("data-turbo-temporary" "true")) + + (style + (text ".CodeMirror { + font-family: monospace !important; + font-size: 16px; + border: solid 1px var(--color-super-lowered); + border-radius: var(--radius); + } + + .CodeMirror-line { + padding-left: 5px !important; + }")) + (text "{% else %}") + ; markdown editor + (script ("src" "https://unpkg.com/codemirror@5.39.2/mode/markdown/markdown.js") ("data-turbo-temporary" "true")) + (text "{%- endif %}") + ; tab bar + (text "{% if note.title != \"journal.css\" -%}") (div ("class" "pillmenu") (a @@ -198,6 +253,7 @@ ("data-tab-button" "preview") ("data-turbo" "false") (str (text "journals:label.preview_pane")))) + (text "{%- endif %}") ; tabs (div @@ -220,11 +276,16 @@ ; init codemirror (script ("id" "editor_content") ("type" "text/markdown") (text "{{ note.content|remove_script_tags|safe }}")) (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\"), { value: document.getElementById(\"editor_content\").innerHTML, - mode: \"markdown\", + mode: \"{% if note.title == 'journal.css' -%} css {%- else -%} markdown {%- endif %}\", lineWrapping: true, + lineNumbers: \"{{ note.title }}\" === \"journal.css\", autoCloseBrackets: true, autofocus: true, viewportMargin: Number.POSITIVE_INFINITY, @@ -232,7 +293,8 @@ highlightFormatting: false, fencedCodeBlockHighlighting: false, xml: false, - smartIndent: false, + smartIndent: true, + indentUnit: 4, placeholder: `# {{ note.title }}`, extraKeys: { 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) => { e.preventDefault(); trigger(\"atto::hooks::tabs:switch\", [\"editor\"]); @@ -262,7 +333,11 @@ }) ).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\"]); }); }, 150);")) @@ -272,7 +347,34 @@ ("class" "flex flex-col gap-2 card") (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 %}"))) (style @@ -332,7 +434,7 @@ }, body: JSON.stringify({ title, - content: `# ${title}`, + content: title === \"journal.css\" ? `/* ${title} */\\n` : `# ${title}`, journal: \"{{ selected_journal }}\", }), }) @@ -431,8 +533,8 @@ globalThis.change_journal_privacy = async (e) => { e.preventDefault(); - const selected = event.target.selectedOptions[0]; - fetch(\"/api/v1/journals/{{ selected_journal }}/privacy\", { + const selected = e.target.selectedOptions[0]; + fetch(\"/api/v1/journals/{% if journal -%} {{ journal.id }} {%- else -%} {{ selected_journal }} {%- endif %}/privacy\", { method: \"POST\", headers: { \"Content-Type\": \"application/json\", diff --git a/crates/app/src/public/html/profile/media.lisp b/crates/app/src/public/html/profile/media.lisp index a276c90..a3888dc 100644 --- a/crates/app/src/public/html/profile/media.lisp +++ b/crates/app/src/public/html/profile/media.lisp @@ -1,7 +1,7 @@ (text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}") (div ("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\") }}") (div diff --git a/crates/app/src/public/html/profile/outbox.lisp b/crates/app/src/public/html/profile/outbox.lisp index 316aa62..d77d314 100644 --- a/crates/app/src/public/html/profile/outbox.lisp +++ b/crates/app/src/public/html/profile/outbox.lisp @@ -1,7 +1,7 @@ (text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}") (div ("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\") }}") (div diff --git a/crates/app/src/public/html/profile/posts.lisp b/crates/app/src/public/html/profile/posts.lisp index 0c9d79a..bbdd6a5 100644 --- a/crates/app/src/public/html/profile/posts.lisp +++ b/crates/app/src/public/html/profile/posts.lisp @@ -1,7 +1,7 @@ (text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}") (div ("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 -%}") (div @@ -44,9 +44,10 @@ ("ui_ident" "io_data_load") (div ("ui_ident" "io_data_marker")))) +(text "{% set paged = user and user.settings.paged_timelines %}") (script (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 %}") diff --git a/crates/app/src/public/html/profile/replies.lisp b/crates/app/src/public/html/profile/replies.lisp index ff54816..4afe348 100644 --- a/crates/app/src/public/html/profile/replies.lisp +++ b/crates/app/src/public/html/profile/replies.lisp @@ -1,7 +1,7 @@ (text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}") (div ("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\") }}") (div diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 8be4836..31856fb 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -564,14 +564,14 @@ (li (text "Use custom CSS on your profile")) (li - (text "Ability to use community emojis outside of + (text "Use community emojis outside of their community")) (li - (text "Ability to upload and use gif emojis")) + (text "Upload and use gif emojis")) (li (text "Create infinite stack timelines")) (li - (text "Ability to upload images to posts")) + (text "Upload images to posts")) (li (text "Save infinite post drafts")) (li @@ -579,7 +579,7 @@ (li (text "Ability to create forges")) (li - (text "Ability to create more than 1 app")) + (text "Create more than 1 app")) (li (text "Create up to 10 stack blocks")) (li @@ -587,7 +587,9 @@ (li (text "Increased proxied image size")) (li - (text "Create infinite journals"))) + (text "Create infinite journals")) + (li + (text "Create infinite notes in each journal"))) (a ("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}") ("class" "button") @@ -1374,6 +1376,14 @@ \"{{ profile.settings.allow_anonymous_questions }}\", \"checkbox\", ], + [ + [ + \"enable_drawings\", + \"Allow users to create drawings and submit them with questions\", + ], + \"{{ profile.settings.enable_drawings }}\", + \"checkbox\", + ], [ [\"motivational_header\", \"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.\", \"text\", ], + [ + [\"paged_timelines\", \"Make timelines paged instead of infinitely scrolled\"], + \"{{ profile.settings.paged_timelines }}\", + \"checkbox\", + ], [[], \"Fun\", \"title\"], [ [\"disable_gpa_fun\", \"Disable GPA\"], diff --git a/crates/app/src/public/html/stacks/feed.lisp b/crates/app/src/public/html/stacks/feed.lisp index 5698065..5002856 100644 --- a/crates/app/src/public/html/stacks/feed.lisp +++ b/crates/app/src/public/html/stacks/feed.lisp @@ -83,9 +83,10 @@ ("ui_ident" "io_data_load") (div ("ui_ident" "io_data_marker"))) + (text "{% set paged = user and user.settings.paged_timelines %}") (script (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 %}")))) diff --git a/crates/app/src/public/html/timelines/all.lisp b/crates/app/src/public/html/timelines/all.lisp index c38dd88..7cced78 100644 --- a/crates/app/src/public/html/timelines/all.lisp +++ b/crates/app/src/public/html/timelines/all.lisp @@ -33,9 +33,10 @@ ("ui_ident" "io_data_load") (div ("ui_ident" "io_data_marker")))) +(text "{% set paged = user and user.settings.paged_timelines %}") (script (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 %}") diff --git a/crates/app/src/public/html/timelines/following.lisp b/crates/app/src/public/html/timelines/following.lisp index b1759e4..ef23a55 100644 --- a/crates/app/src/public/html/timelines/following.lisp +++ b/crates/app/src/public/html/timelines/following.lisp @@ -11,9 +11,10 @@ ("ui_ident" "io_data_load") (div ("ui_ident" "io_data_marker")))) +(text "{% set paged = user and user.settings.paged_timelines %}") (script (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 %}") diff --git a/crates/app/src/public/html/timelines/home.lisp b/crates/app/src/public/html/timelines/home.lisp index e398615..5a5658b 100644 --- a/crates/app/src/public/html/timelines/home.lisp +++ b/crates/app/src/public/html/timelines/home.lisp @@ -31,9 +31,10 @@ (div ("ui_ident" "io_data_marker"))) (text "{%- endif %}")) +(text "{% set paged = user and user.settings.paged_timelines %}") (script (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 %}") diff --git a/crates/app/src/public/html/timelines/popular.lisp b/crates/app/src/public/html/timelines/popular.lisp index 6d26f3d..d0223df 100644 --- a/crates/app/src/public/html/timelines/popular.lisp +++ b/crates/app/src/public/html/timelines/popular.lisp @@ -11,9 +11,10 @@ ("ui_ident" "io_data_load") (div ("ui_ident" "io_data_marker")))) +(text "{% set paged = user and user.settings.paged_timelines %}") (script (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 %}") diff --git a/crates/app/src/public/html/timelines/swiss_army.lisp b/crates/app/src/public/html/timelines/swiss_army.lisp index eb722c9..23243ce 100644 --- a/crates/app/src/public/html/timelines/swiss_army.lisp +++ b/crates/app/src/public/html/timelines/swiss_army.lisp @@ -30,3 +30,7 @@ (str (text "chats:label.go_back"))) (text "{%- endif %}")) (text "{%- endif %}") + +(text "{% if paginated -%}") +(text "{{ components::pagination(page=page, items=list|length) }}") +(text "{%- endif %}") diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 6c30428..a417a46 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -39,6 +39,7 @@ media_theme_pref(); // init use("me", () => {}); use("streams", () => {}); + use("carp", () => {}); // env 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( "[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_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 () => { diff --git a/crates/app/src/public/js/carp.js b/crates/app/src/public/js/carp.js new file mode 100644 index 0000000..dbef3ed --- /dev/null +++ b/crates/app/src/public/js/carp.js @@ -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); + } + } +})(); diff --git a/crates/app/src/public/js/loader.js b/crates/app/src/public/js/loader.js index 558a9a7..fd1d1df 100644 --- a/crates/app/src/public/js/loader.js +++ b/crates/app/src/public/js/loader.js @@ -24,7 +24,7 @@ globalThis.ns = (ns) => { if (!res) { 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), ); } diff --git a/crates/app/src/routes/api/v1/auth/images.rs b/crates/app/src/routes/api/v1/auth/images.rs index e062be1..e177db7 100644 --- a/crates/app/src/routes/api/v1/auth/images.rs +++ b/crates/app/src/routes/api/v1/auth/images.rs @@ -213,7 +213,7 @@ pub async fn upload_avatar_request( if mime == "image/gif" { // gif image, don't encode 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(); @@ -226,7 +226,7 @@ pub async fn upload_avatar_request( // check file size if img.0.len() > MAXIMUM_FILE_SIZE { - return Json(Error::DataTooLong("image".to_string()).into()); + return Json(Error::FileTooLarge.into()); } // upload image @@ -314,7 +314,7 @@ pub async fn upload_banner_request( if mime == "image/gif" { // gif image, don't encode 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(); @@ -327,7 +327,7 @@ pub async fn upload_banner_request( // check file size if img.0.len() > MAXIMUM_FILE_SIZE { - return Json(Error::DataTooLong("image".to_string()).into()); + return Json(Error::FileTooLarge.into()); } // upload image diff --git a/crates/app/src/routes/api/v1/communities/images.rs b/crates/app/src/routes/api/v1/communities/images.rs index 464dede..3ddee00 100644 --- a/crates/app/src/routes/api/v1/communities/images.rs +++ b/crates/app/src/routes/api/v1/communities/images.rs @@ -136,7 +136,7 @@ pub async fn upload_avatar_request( // check file size if img.0.len() > MAXIMUM_FILE_SIZE { - return Json(Error::DataTooLong("image".to_string()).into()); + return Json(Error::FileTooLarge.into()); } // upload image @@ -191,7 +191,7 @@ pub async fn upload_banner_request( // check file size if img.0.len() > MAXIMUM_FILE_SIZE { - return Json(Error::DataTooLong("image".to_string()).into()); + return Json(Error::FileTooLarge.into()); } // upload image diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index 81a1fae..7bf4bf3 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -133,7 +133,7 @@ pub async fn create_request( // check sizes for img in &images { if img.len() > MAXIMUM_FILE_SIZE { - return Json(Error::DataTooLong("image".to_string()).into()); + return Json(Error::FileTooLarge.into()); } } diff --git a/crates/app/src/routes/api/v1/communities/questions.rs b/crates/app/src/routes/api/v1/communities/questions.rs index 270197f..5e9de0d 100644 --- a/crates/app/src/routes/api/v1/communities/questions.rs +++ b/crates/app/src/routes/api/v1/communities/questions.rs @@ -15,6 +15,7 @@ use tetratto_core::model::{ }; use crate::{ get_user_from_token, + image::JsonMultipart, routes::{api::v1::CreateQuestion, pages::PaginatedQuery}, State, }; @@ -23,7 +24,7 @@ pub async fn create_request( jar: CookieJar, headers: HeaderMap, Extension(data): Extension<State>, - Json(req): Json<CreateQuestion>, + JsonMultipart(drawings, req): JsonMultipart<CreateQuestion>, ) -> impl IntoResponse { let data = &(data.read().await).0; 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: true, message: "Question created".to_string(), diff --git a/crates/app/src/routes/api/v1/journals.rs b/crates/app/src/routes/api/v1/journals.rs index 97f8c9b..19944c6 100644 --- a/crates/app/src/routes/api/v1/journals.rs +++ b/crates/app/src/routes/api/v1/journals.rs @@ -9,11 +9,14 @@ use crate::{ routes::api::v1::{UpdateJournalPrivacy, CreateJournal, UpdateJournalTitle}, State, }; -use tetratto_core::model::{ - journals::{Journal, JournalPrivacyPermission}, - oauth, - permissions::FinePermission, - ApiReturn, Error, +use tetratto_core::{ + database::NAME_REGEX, + model::{ + journals::{Journal, JournalPrivacyPermission}, + oauth, + permissions::FinePermission, + ApiReturn, Error, + }, }; 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 { let data = &(data.read().await).0; 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()), }; - 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 if data diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index f16b1ed..9217437 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -551,6 +551,7 @@ pub fn routes() -> Router { .route("/journals", post(journals::create_request)) .route("/journals/{id}", get(journals::get_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}/privacy", diff --git a/crates/app/src/routes/api/v1/notes.rs b/crates/app/src/routes/api/v1/notes.rs index 45c4a74..faf1bec 100644 --- a/crates/app/src/routes/api/v1/notes.rs +++ b/crates/app/src/routes/api/v1/notes.rs @@ -10,12 +10,15 @@ use crate::{ routes::api::v1::{CreateNote, RenderMarkdown, UpdateNoteContent, UpdateNoteTitle}, State, }; -use tetratto_core::model::{ - journals::{JournalPrivacyPermission, Note}, - oauth, - permissions::FinePermission, - uploads::CustomEmoji, - ApiReturn, Error, +use tetratto_core::{ + database::NAME_REGEX, + model::{ + journals::{JournalPrivacyPermission, Note}, + oauth, + permissions::FinePermission, + uploads::CustomEmoji, + ApiReturn, Error, + }, }; pub async fn get_request( @@ -135,7 +138,17 @@ pub async fn update_title_request( 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 if data diff --git a/crates/app/src/routes/api/v1/uploads.rs b/crates/app/src/routes/api/v1/uploads.rs index 8a6a8bb..0e7d6ab 100644 --- a/crates/app/src/routes/api/v1/uploads.rs +++ b/crates/app/src/routes/api/v1/uploads.rs @@ -4,7 +4,7 @@ use axum_extra::extract::CookieJar; use pathbufd::PathBufD; use crate::{get_user_from_token, State}; 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( Path(id): Path<usize>, @@ -39,10 +39,17 @@ pub async fn get_request( )); } - Ok(( - [("Content-Type", upload.what.mime())], - Body::from(read_image(path)), - )) + let bytes = 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( diff --git a/crates/app/src/routes/assets.rs b/crates/app/src/routes/assets.rs index f721eb3..4a450c5 100644 --- a/crates/app/src/routes/assets.rs +++ b/crates/app/src/routes/assets.rs @@ -18,3 +18,4 @@ serve_asset!(loader_js_request: LOADER_JS("text/javascript")); serve_asset!(atto_js_request: ATTO_JS("text/javascript")); serve_asset!(me_js_request: ME_JS("text/javascript")); serve_asset!(streams_js_request: STREAMS_JS("text/javascript")); +serve_asset!(carp_js_request: CARP_JS("text/javascript")); diff --git a/crates/app/src/routes/mod.rs b/crates/app/src/routes/mod.rs index 123f29a..de1b240 100644 --- a/crates/app/src/routes/mod.rs +++ b/crates/app/src/routes/mod.rs @@ -19,6 +19,7 @@ pub fn routes(config: &Config) -> Router { .route("/js/atto.js", get(assets::atto_js_request)) .route("/js/me.js", get(assets::me_js_request)) .route("/js/streams.js", get(assets::streams_js_request)) + .route("/js/carp.js", get(assets::carp_js_request)) .nest_service( "/public", get_service(tower_http::services::ServeDir::new(&config.dirs.assets)), diff --git a/crates/app/src/routes/pages/journals.rs b/crates/app/src/routes/pages/journals.rs index f631826..1f03dd7 100644 --- a/crates/app/src/routes/pages/journals.rs +++ b/crates/app/src/routes/pages/journals.rs @@ -116,7 +116,7 @@ pub async fn view_request( } // 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( render_error(Error::NotAllowed, &jar, &data, &user).await, )); @@ -207,3 +207,81 @@ pub async fn view_request( // return 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())) +} diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs index 3ff3f0d..8b76292 100644 --- a/crates/app/src/routes/pages/misc.rs +++ b/crates/app/src/routes/pages/misc.rs @@ -576,6 +576,8 @@ pub struct TimelineQuery { pub user_id: usize, #[serde(default)] pub tag: String, + #[serde(default)] + pub paginated: bool, } /// `/_swiss_army_timeline` @@ -697,6 +699,7 @@ pub async fn swiss_army_timeline_request( context.insert("list", &list); context.insert("page", &req.page); + context.insert("paginated", &req.paginated); Ok(Html( data.1 .render("timelines/swiss_army.html", &context) diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 2177d94..2eaeca2 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -134,7 +134,7 @@ pub fn routes() -> Router { // journals .route("/journals", get(journals::redirect_request)) .route("/journals/{journal}/{note}", get(journals::app_request)) - .route("/@{owner}/{journal}", get(journals::view_request)) + .route("/@{owner}/{journal}", get(journals::index_view_request)) .route("/@{owner}/{journal}/{note}", get(journals::view_request)) } diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index ba07543..1c1bda2 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -216,7 +216,7 @@ impl DataManager { &0_i32, &(data.last_seen as i64), &String::new(), - &"[]", + "[]", &0_i32, &0_i32, &serde_json::to_string(&data.connections).unwrap(), diff --git a/crates/core/src/database/journals.rs b/crates/core/src/database/journals.rs index 4602b6b..a4a0d00 100644 --- a/crates/core/src/database/journals.rs +++ b/crates/core/src/database/journals.rs @@ -1,9 +1,10 @@ use oiseau::{cache::Cache, query_row}; use crate::{ + database::common::NAME_REGEX, model::{ auth::User, - permissions::FinePermission, journals::{Journal, JournalPrivacyPermission}, + permissions::FinePermission, Error, Result, }, }; @@ -69,7 +70,7 @@ impl DataManager { Ok(res.unwrap()) } - const MAXIMUM_FREE_JOURNALS: usize = 15; + const MAXIMUM_FREE_JOURNALS: usize = 5; /// Create a new journal in the database. /// @@ -83,7 +84,19 @@ impl DataManager { 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 if self diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index e56bc93..a00fde9 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -30,3 +30,4 @@ mod userblocks; mod userfollows; pub use drivers::DataManager; +pub use common::NAME_REGEX; diff --git a/crates/core/src/database/notes.rs b/crates/core/src/database/notes.rs index d46394b..ea7da45 100644 --- a/crates/core/src/database/notes.rs +++ b/crates/core/src/database/notes.rs @@ -1,4 +1,5 @@ use oiseau::cache::Cache; +use crate::database::common::NAME_REGEX; use crate::model::{auth::User, journals::Note, permissions::FinePermission, Error, Result}; use crate::{auto_method, DataManager}; use oiseau::{execute, get, params, query_row, query_rows, PostgresRow}; @@ -64,6 +65,8 @@ impl DataManager { Ok(res.unwrap()) } + const MAXIMUM_FREE_NOTES_PER_JOURNAL: usize = 10; + /// Create a new note in the database. /// /// # Arguments @@ -82,7 +85,33 @@ impl DataManager { 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 if self diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index eeef882..72b4f5b 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -1952,6 +1952,15 @@ impl DataManager { 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 Ok(()) } @@ -2031,6 +2040,15 @@ impl DataManager { for upload in y.uploads { 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 { // incr parent comment count if let Some(replying_to) = y.replying_to { diff --git a/crates/core/src/database/questions.rs b/crates/core/src/database/questions.rs index d88fb44..0a6965c 100644 --- a/crates/core/src/database/questions.rs +++ b/crates/core/src/database/questions.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use oiseau::cache::Cache; use tetratto_shared::unix_epoch_timestamp; use crate::model::communities_permissions::CommunityPermission; +use crate::model::uploads::{MediaType, MediaUpload}; use crate::model::{ Error, Result, communities::Question, @@ -33,6 +34,7 @@ impl DataManager { // ... context: serde_json::from_str(&get!(x->10(String))).unwrap(), ip: get!(x->11(String)), + drawings: serde_json::from_str(&get!(x->12(String))).unwrap(), } } @@ -333,13 +335,20 @@ impl DataManager { Ok(res.unwrap()) } + const MAXIMUM_DRAWING_SIZE: usize = 32768; // 32 KiB + /// Create a new question in the database. /// /// # Arguments /// * `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 if data.is_global { + // global if data.community > 0 { // posting to community data.receiver = 0; @@ -370,6 +379,7 @@ impl DataManager { } } } else { + // single let receiver = self.get_user_by_id(data.receiver).await?; if !receiver.settings.enable_questions { @@ -380,6 +390,10 @@ impl DataManager { return Err(Error::NotAllowed); } + if !receiver.settings.enable_drawings && drawings.len() > 0 { + return Err(Error::DrawingsDisabled); + } + // check for ip block if self .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 { Ok(c) => c, @@ -398,7 +434,7 @@ impl DataManager { let res = execute!( &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![ &(data.id as i64), &(data.created as i64), @@ -411,7 +447,8 @@ impl DataManager { &0_i32, &0_i32, &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?; } + // 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 Ok(data.id) } @@ -495,6 +549,11 @@ impl DataManager { return Err(Error::DatabaseError(e.to_string())); } + // delete uploads + for upload in y.drawings { + self.delete_upload(upload).await?; + } + // return Ok(()) } diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 8c12f76..171c881 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -231,6 +231,12 @@ pub struct UserSettings { /// A list of strings the user has muted. #[serde(default)] 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 { @@ -332,7 +338,7 @@ impl User { // parse for char in input.chars() { - if (char == '\\') && !escape { + if ((char == '\\') | (char == '/')) && !escape { escape = true; continue; } diff --git a/crates/core/src/model/carp.rs b/crates/core/src/model/carp.rs new file mode 100644 index 0000000..40876fa --- /dev/null +++ b/crates/core/src/model/carp.rs @@ -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>") + } +} diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs index 41508ff..7b1957f 100644 --- a/crates/core/src/model/communities.rs +++ b/crates/core/src/model/communities.rs @@ -345,6 +345,9 @@ pub struct Question { /// The IP of the question creator for IP blocking and identifying anonymous users. #[serde(default)] pub ip: String, + /// The IDs of all uploads which hold this question's drawings. + #[serde(default)] + pub drawings: Vec<usize>, } impl Question { @@ -369,6 +372,7 @@ impl Question { dislikes: 0, context: QuestionContext::default(), ip, + drawings: Vec::new(), } } } diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index c50ea7c..ed2da9e 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -1,6 +1,7 @@ pub mod addr; pub mod apps; pub mod auth; +pub mod carp; pub mod channels; pub mod communities; pub mod communities_permissions; @@ -41,10 +42,12 @@ pub enum Error { AlreadyAuthenticated, DataTooLong(String), DataTooShort(String), + FileTooLarge, UsernameInUse, TitleInUse, QuestionsDisabled, RequiresSupporter, + DrawingsDisabled, Unknown, } @@ -62,10 +65,12 @@ impl Display for Error { Self::AlreadyAuthenticated => "Already authenticated".to_string(), Self::DataTooLong(name) => format!("Given {name} is too long!"), 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::TitleInUse => "Title in use".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::DrawingsDisabled => "You are not allowed to submit drawings there".to_string(), _ => format!("An unknown error as occurred: ({:?})", self), }) } diff --git a/crates/core/src/model/uploads.rs b/crates/core/src/model/uploads.rs index d502697..35165c6 100644 --- a/crates/core/src/model/uploads.rs +++ b/crates/core/src/model/uploads.rs @@ -5,7 +5,7 @@ use crate::config::Config; use std::fs::{write, exists, remove_file}; use super::{Error, Result}; -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, PartialEq, Eq)] pub enum MediaType { #[serde(alias = "image/webp")] Webp, @@ -17,6 +17,8 @@ pub enum MediaType { Jpg, #[serde(alias = "image/gif")] Gif, + #[serde(alias = "image/carpgraph")] + Carpgraph, } impl MediaType { @@ -27,6 +29,7 @@ impl MediaType { Self::Png => "png", Self::Jpg => "jpg", Self::Gif => "gif", + Self::Carpgraph => "carpgraph", } } diff --git a/sql_changes/questions_drawings.sql b/sql_changes/questions_drawings.sql new file mode 100644 index 0000000..f45e50b --- /dev/null +++ b/sql_changes/questions_drawings.sql @@ -0,0 +1,2 @@ +ALTER TABLE questions +ADD COLUMN drawings TEXT NOT NULL DEFAULT '[]';