From ab63d4ba8d77659f7dc6058e1983cac56b060e72 Mon Sep 17 00:00:00 2001 From: Andy Burke Date: Sun, 21 Sep 2025 16:02:08 -0700 Subject: [PATCH] feature: first pass on replies refactor: move more smarts in the smartforms around --- models/event.ts | 82 ++++++++ public/api/topics/:topic_id/events/index.ts | 11 +- public/base.css | 27 +++ public/js/smartforms.js | 114 +++++++++-- public/tabs/forum/forum.html | 208 ++++---------------- public/tabs/forum/new_post.html | 82 ++++++++ 6 files changed, 343 insertions(+), 181 deletions(-) create mode 100644 public/tabs/forum/new_post.html diff --git a/models/event.ts b/models/event.ts index d318954..f60d2b4 100644 --- a/models/event.ts +++ b/models/event.ts @@ -37,6 +37,81 @@ type TOPIC_EVENT_CACHE_ENTRY = { eviction_timeout: number; }; +// TODO: separate out these different validators somewhere? +export function VALIDATE_EVENT(event: EVENT) { + const errors: any[] = []; + + const { + groups: { + type, + id + } + } = /^(?\w+)\:(?[A-Za-z-]+)$/.exec(event.id ?? '') ?? { groups: {} }; + + if (typeof type !== 'string' || type.length === 0) { + errors.push({ + cause: 'missing_event_type_in_id', + message: 'An event must have a type that is also encoded into its id, eg: chat:able-fish-wife...' + }); + } + + if (typeof id !== 'string' || id.length !== 49) { + errors.push({ + cause: 'invalid_event_id', + message: 'An event must have a type and a lurid id, eg: chat:able-fish-gold-wing-trip-form-seed-cost-rope-wife' + }); + } + + switch (event.type) { + case 'chat': + if (event.data?.message?.length <= 0) { + errors.push({ + cause: 'chat_message_missing', + message: 'A chat message event cannot be empty.' + }); + } + break; + case 'post': + if (event.data?.subject?.length <= 0) { + errors.push({ + cause: 'post_missing_subject', + message: 'A post cannot have an empty subject.' + }); + } + break; + case 'blurb': + if (event.data?.blurb?.length <= 0) { + errors.push({ + cause: 'blurb_missing', + message: 'A blurb cannot be empty.' + }); + } else if (event.data?.blurb?.length > 2 ** 8) { + errors.push({ + cause: 'blurb_length_limit_exceeded', + message: 'A blurb cannot be longer than 256 characters.' + }); + } + break; + case 'essay': + if (event.data?.essay?.length <= 0) { + errors.push({ + cause: 'essay_missing', + message: 'An essay cannot be empty.' + }); + } else if (event.data?.essay?.length > 2 ** 16) { + errors.push({ + cause: 'essay_length_limit_exceeded', + message: 'An essay cannot be longer than 65536 characters - that would be a screed, which we do not yet support.' + }); + } + break; + default: + break; + } + + return errors.length ? errors : undefined; +} + const TOPIC_EVENT_ID_MATCHER = /^(?.*):(?.*)$/; const TOPIC_EVENTS: Record = {}; @@ -73,6 +148,13 @@ export function get_events_collection_for_topic(topic_id: string): FSDB_COLLECTI organize: by_lurid }), + parent_id: new FSDB_INDEXER_SYMLINKS({ + name: 'parent_id', + field: 'parent_id', + to_many: true, + organize: by_lurid + }), + tags: new FSDB_INDEXER_SYMLINKS({ name: 'tags', get_values_to_index: (event: EVENT): string[] => { diff --git a/public/api/topics/:topic_id/events/index.ts b/public/api/topics/:topic_id/events/index.ts index e8fd120..22db3f1 100644 --- a/public/api/topics/:topic_id/events/index.ts +++ b/public/api/topics/:topic_id/events/index.ts @@ -2,7 +2,7 @@ import lurid from '@andyburke/lurid'; import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../../../utils/prechecks.ts'; import { TOPIC, TOPICS } from '../../../../../models/topic.ts'; import * as CANNED_RESPONSES from '../../../../../utils/canned_responses.ts'; -import { EVENT, get_events_collection_for_topic } from '../../../../../models/event.ts'; +import { EVENT, get_events_collection_for_topic, VALIDATE_EVENT } from '../../../../../models/event.ts'; import parse_body from '../../../../../utils/bodyparser.ts'; import { FSDB_COLLECTION, FSDB_SEARCH_OPTIONS, WALK_ENTRY } from '@andyburke/fsdb'; import * as path from '@std/path'; @@ -172,6 +172,15 @@ export async function POST(req: Request, meta: Record): Promise { - /* make all forms semi-smart */ - const forms = document.querySelectorAll("form[data-smart]"); +function smarten_forms() { + const forms = document.body.querySelectorAll("form[data-smart]:not([data-smartened])"); for (const form of forms) { async function on_submit(event) { + debugger; event.preventDefault(); form.disabled = true; + form.__submitted_at = new Date(); if (form.on_submit) { const result = await form.on_submit(event); @@ -25,8 +26,63 @@ document.addEventListener("DOMContentLoaded", () => { continue; } - if (typeof value === "string" && value.length === 0) { - const input = form.querySelector(`[name="${key}"]`); + const input = form.querySelector(`[name="${key}"]`); + + if (input.type === "file") { + if (input.dataset["smartformsSaveToHome"]) { + form.uploaded = []; + form.errors = []; + + const user = document.body.dataset.user + ? JSON.parse(document.body.dataset.user) + : undefined; + if (!user) { + throw new Error("You must be logged in to upload files here."); + } + + for await (const file of input.files) { + const body = new FormData(); + body.append("file", file, encodeURIComponent(file.name)); + + const file_path = + "/files/users/" + user.id + "/" + encodeURIComponent(file.name); + + const file_upload_response = await api.fetch(file_path, { + method: "PUT", + body, + }); + + if (!file_upload_response.ok) { + const error = await file_upload_response.json(); + form.errors.push(error?.error?.message ?? "Unknown error."); + continue; + } + + const file_url = + window.location.protocol + "//" + window.location.host + file_path; + form.uploaded.push(file_url); + } + + if (form.errors.length) { + const errors = form.errors.join("\n\n"); + alert(errors); + return false; + } + + continue; + } + } + + const generator = input.getAttribute("generator"); + const generated_value = + typeof generator === "string" && generator.length + ? eval(generator)(input, form) + : undefined; + + const resolved_value = + typeof value === "string" && value.length ? value : generated_value; + + if (typeof resolved_value === "undefined") { const should_submit_empty = input && input.dataset["smartformsSubmitEmpty"]; if (!should_submit_empty) { continue; @@ -40,11 +96,20 @@ document.addEventListener("DOMContentLoaded", () => { current = current[element]; } - current[elements.slice(elements.length - 1).shift()] = value; + current[elements.slice(elements.length - 1).shift()] = resolved_value; } - if (form.on_parsed) { - await form.on_parsed(json); + if (form.uploaded?.length > 0) { + json.data = json.data ?? {}; + json.data.media = [...form.uploaded]; + } + + const on_parsed = + form.on_parsed ?? + (form.getAttribute("on_parsed") ? eval(form.getAttribute("on_parsed")) : undefined); + + if (on_parsed) { + await on_parsed(json); } try { @@ -69,7 +134,7 @@ document.addEventListener("DOMContentLoaded", () => { return form.on_error(error); } - alert(error.message ?? "Unknown error!"); + alert(error.message ?? "Unknown error:\n\n" + error); return; } @@ -78,8 +143,20 @@ document.addEventListener("DOMContentLoaded", () => { } const response_body = await response.json(); - if (form.on_reply) { - return form.on_reply(response_body); + + const on_reply = + form.on_reply ?? + (form.getAttribute("on_reply") + ? eval(form.getAttribute("on_reply")) + : undefined); + if (on_reply) { + await on_reply(response_body); + } + + const inputs_for_reset = form.querySelectorAll("input[reset-on-submit]"); + for (const input of inputs_for_reset) { + const reset_value = input.getAttribute("reset-on-submit"); + input.value = reset_value ?? ""; } } catch (error) { console.dir({ @@ -97,6 +174,19 @@ document.addEventListener("DOMContentLoaded", () => { } form.addEventListener("submit", on_submit); - //form.onsubmit = on_submit; + form.dataset.smartened = true; } +} + +const smarten_observer = new MutationObserver(smarten_forms); +smarten_observer.observe(document, { + childList: true, + subtree: true, }); + +/* + +document.addEventListener("DOMContentLoaded", smarten_forms); +document.addEventListener("DOMSubtreeModified", smarten_forms); + +*/ diff --git a/public/tabs/forum/forum.html b/public/tabs/forum/forum.html index 05b9092..0b29fc5 100644 --- a/public/tabs/forum/forum.html +++ b/public/tabs/forum/forum.html @@ -6,12 +6,14 @@ .post-container { position: relative; display: grid; - grid-template-rows: auto auto 1fr; + grid-template-rows: auto auto auto auto 1fr; grid-template-columns: auto auto 1fr; grid-template-areas: - "expander preview info" - "expander preview subject" - "expander preview content"; + "expander preview info" + "expander preview subject" + ". . content" + ". . newpost" + ". . replies"; max-height: 6rem; padding: 1rem; border: 1px solid var(--border-subtle); @@ -20,9 +22,8 @@ margin-bottom: 2rem; } - .post-container:has(input[name="expanded"]:checked) { - max-height: 40rem; - overflow-y: scroll; + .post-container:has(> div > label > input[name="expanded"]:checked) { + max-height: unset; } .expand-toggle-container input[name="expanded"] { @@ -30,15 +31,16 @@ visibility: hidden; opacity: 0; } - .post-container:has(input[name="expanded"]) .icon.minus, - .post-container:has(input[name="expanded"]:checked) .icon.plus { + + .post-container:has(> div > label > input[name="expanded"]) .icon.minus, + .post-container:has(> div > label > input[name="expanded"]:checked) .icon.plus { display: block; opacity: 1; visibility: visible; } - .post-container:has(input[name="expanded"]) .icon.plus, - .post-container:has(input[name="expanded"]:checked) .icon.minus { + .post-container:has(> div > label > input[name="expanded"]) .icon.plus, + .post-container:has(> div > label > input[name="expanded"]:checked) .icon.minus { display: none; opacity: 0; visibility: hidden; @@ -121,6 +123,14 @@ padding-left: 5rem; margin-top: 2rem; } + + .post-container .new-post-container { + grid-area: newpost; + } + + .post-container .replies-container { + grid-area: replies; + }
@@ -145,10 +155,10 @@ const creator = await USERS.get(post.creator_id); const existing_element = document.getElementById(`post-${post.id.substring(0, 49)}`) ?? - document.getElementById(`post-${post.meta.temp_id}`); + document.getElementById(`post-${post.meta?.temp_id}`); const post_datetime = datetime_to_local(post.timestamps.created); - const html_content = `
+ const html_content = `
${post.data.subject}
${htmlify(post.data.content)}
+ +
`; if (existing_element) { const template = document.createElement("template"); template.innerHTML = html_content; existing_element.replaceWith(template.content.firstChild); + existing_element.classList.remove("sending"); } else { - forum_posts_list.insertAdjacentHTML("beforeend", html_content); + const target_container = + document.querySelector( + `.post-container[data-post_id='${post.parent_id}'] > .replies-container`, + ) ?? forum_posts_list; + target_container.insertAdjacentHTML("beforeend", html_content); } } @@ -202,6 +219,12 @@ await render_post(post); } + const new_post_forms = document.querySelectorAll(".post-creation-form"); + for (const new_post_form of new_post_forms) { + new_post_form.action = + "/api/topics/" + document.body.dataset?.topic + "/events"; + } + forum_posts_list.scrollTop = 0; } @@ -228,7 +251,7 @@ }) .then(async (new_events_response) => { const new_events = (await new_events_response.json()) ?? []; - await append_posts(new_events); + await append_posts(new_events.reverse()); poll_for_new_posts(topic_id); }) .catch((error) => { @@ -276,7 +299,7 @@ const events = await events_response.json(); - await append_posts(events); + await append_posts(events.reverse()); poll_for_new_posts(); } document.addEventListener("topic_changed", load_active_topic_for_forum); @@ -284,157 +307,6 @@
-
- - -
+
diff --git a/public/tabs/forum/new_post.html b/public/tabs/forum/new_post.html new file mode 100644 index 0000000..0443980 --- /dev/null +++ b/public/tabs/forum/new_post.html @@ -0,0 +1,82 @@ +
+ +
+ + + + + + + + + + + + + + +
+