diff --git a/package-lock.json b/package-lock.json index 3b0cff8..4310ced 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,10 +14,11 @@ "@hotwired/turbo": "^8.0.2", "@koa/router": "^12.0.1", "@playwright/test": "^1.36.1", - "@sealcode/jdd": "^0.3.3", - "@sealcode/sealgen": "^0.14.4", + "@sealcode/jdd": "^0.3.4", + "@sealcode/sealgen": "^0.14.5", "@sealcode/ts-predicates": "^0.4.3", "@types/kill-port": "^2.0.0", + "@types/leaflet": "^1.9.8", "get-port": "^7.0.0", "js-convert-case": "^4.2.0", "koa-responsive-image-router": "^0.2.19", @@ -791,9 +792,9 @@ } }, "node_modules/@sealcode/jdd": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@sealcode/jdd/-/jdd-0.3.3.tgz", - "integrity": "sha512-mGOBlE/gQSSdXZfg5wP6mq8vw6q/qrxB6HIMGvctSZLAAFjiz/ExnOLN+5lIGmO5XpNRWcCHHlBmFKpBy4Vz3A==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@sealcode/jdd/-/jdd-0.3.4.tgz", + "integrity": "sha512-lwkVoUguOsOxuKYURw/hl3XqFARY85CuqnwzgdHG+gC6x23y/PgljbPREAT5GSKYCIVWkwGdNAfbh0HglV4Z4Q==", "dependencies": { "@sealcode/ts-predicates": "^0.5.3", "koa-responsive-image-router": "^0.2.19", @@ -820,8 +821,9 @@ } }, "node_modules/@sealcode/sealgen": { - "version": "0.14.4", - "license": "ISC", + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/@sealcode/sealgen/-/sealgen-0.14.5.tgz", + "integrity": "sha512-SE3dpS5KAsvpznKJla1VoqlnDNxeHJ2mTN2Kzdwg9Chleht/Mw/ojb7E58ZX/WxJNg+BRuCYy6knJkvkO7SfQg==", "dependencies": { "@koa/router": "^12.0.1", "@sealcode/ts-predicates": "^0.4.3", @@ -1031,6 +1033,11 @@ "@types/node": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.14", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz", + "integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==" + }, "node_modules/@types/http-assert": { "version": "1.5.5", "license": "MIT" @@ -1119,6 +1126,14 @@ "@types/koa-send": "*" } }, + "node_modules/@types/leaflet": { + "version": "1.9.8", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.8.tgz", + "integrity": "sha512-EXdsL4EhoUtGm2GC2ZYtXn+Fzc6pluVgagvo2VC1RHWToLGlTRwVYoDpqS/7QXa01rmDyBjJk3Catpf60VMkwg==", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/luxon": { "version": "3.3.8", "license": "MIT" diff --git a/package.json b/package.json index 64bf9e9..f1e3b4e 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,8 @@ "type": "module", "scripts": { "start": "docker-compose up -d db && node .", - "typecheck:back": "npx tsc --noEmit --target es6 --lib es2021,dom -p src/back", - "typecheck:front": "npx tsc --noEmit --target es6 --lib es2015,dom -p src/front", + "typecheck:back": "npx tsc --noEmit --target es6 --lib es2021,dom -p tsconfig-back.json", + "typecheck:front": "npx tsc --noEmit --target es6 --lib es2015,dom -p tsconfig-front.json", "build": "sealgen build", "watch": "multiple-scripts-tmux -p watch", "reset-db": "docker-compose down && docker-compose up -d", @@ -35,10 +35,11 @@ "@hotwired/turbo": "^8.0.2", "@koa/router": "^12.0.1", "@playwright/test": "^1.36.1", - "@sealcode/jdd": "^0.3.3", - "@sealcode/sealgen": "^0.14.4", + "@sealcode/jdd": "^0.3.4", + "@sealcode/sealgen": "^0.14.5", "@sealcode/ts-predicates": "^0.4.3", "@types/kill-port": "^2.0.0", + "@types/leaflet": "^1.9.8", "get-port": "^7.0.0", "js-convert-case": "^4.2.0", "koa-responsive-image-router": "^0.2.19", diff --git a/src/back/html.ts b/src/back/html.ts index c8ccd43..868d2be 100644 --- a/src/back/html.ts +++ b/src/back/html.ts @@ -46,6 +46,11 @@ export default function html( ) => Templatable = defaultHead ): Readable { ctx.set("content-type", "text/html;charset=utf-8"); + const controllers: string[] = []; + if (htmlOptions.autoRefreshCSS) { + controllers.push("refresh-styles"); + controllers.push("refresh-on-ts-changes"); + } return tempstream/* HTML */ ` ${makeHead(ctx, title, htmlOptions)} - + ${(htmlOptions.navbar || default_navbar)(ctx)} ${body} - ${htmlOptions.autoRefreshCSS - ? /* HTML */ `` - : ""} ${htmlOptions.disableCopyEvent ? /* HTML */ "" : ""} diff --git a/src/back/html/refresh-on-ts-changes.stimulus.ts b/src/back/html/refresh-on-ts-changes.stimulus.ts new file mode 100644 index 0000000..3b1117a --- /dev/null +++ b/src/back/html/refresh-on-ts-changes.stimulus.ts @@ -0,0 +1,96 @@ +import { Controller } from "stimulus"; + +const APP_DOWN_ERROR_MESSAGE = "App is currently down"; + +const sleep = (time: number) => + new Promise((resolve) => { + setTimeout(resolve, time); + }); + +async function get_status() { + const r = await fetch("/status.json"); + return await r.json(); +} + +async function wait_for_run_id_to_change() { + let first_timestamp: number; + try { + const { started_at } = await get_status(); + first_timestamp = started_at; + } catch (e) { + await wait_for_app_to_be_stable(); + return; + } + + if (!first_timestamp) { + throw new Error(APP_DOWN_ERROR_MESSAGE); + } + + while (true) { + const { started_at } = await get_status().catch(() => ({ + started_at: first_timestamp, + })); + if (started_at !== first_timestamp) { + return; + } + await sleep(100); + } +} + +async function wait_for_app_to_be_stable(n = 3) { + console.log("Waiting for app to be stable...."); + let counter = 0; + while (true) { + const { status } = await get_status().catch(() => ({ + status: "down", + })); + if (status == "running") { + console.log(counter); + counter++; + } else { + counter = 0; + } + if (counter == n) { + return; + } + await sleep(100); + } +} + +async function wait_for_app_restart() { + try { + await wait_for_run_id_to_change(); + } catch (e) { + if (e.message !== APP_DOWN_ERROR_MESSAGE) { + throw e; + } + } + await wait_for_app_to_be_stable(); +} + +export default class RefreshOnTSChanges extends Controller { + socket: WebSocket; + + async connect() { + const { port, watch } = await fetch("/dist/notifier.json").then((r) => r.json()); + if (!watch) { + console.warn( + "Not running auto refresh on watch because the build process is not running in watch mode" + ); + return; + } + const socket = new WebSocket(`ws://localhost:${port}`); + socket.onmessage = async (message) => { + if (message.data.endsWith("-ts")) { + document.documentElement.classList.add("restarting"); + await wait_for_app_restart(); + document.documentElement.dispatchEvent(new Event("ts-rebuilt")); + document.documentElement.classList.remove("restarting"); + } + }; + } + + async disconnect() { + this.socket.close(); + } +} diff --git a/src/back/html/refresh-styles.stimulus.ts b/src/back/html/refresh-styles.stimulus.ts new file mode 100644 index 0000000..5129997 --- /dev/null +++ b/src/back/html/refresh-styles.stimulus.ts @@ -0,0 +1,46 @@ +import { Controller } from "stimulus"; + +function make_new_link() { + const new_link = document.createElement("link"); + new_link.rel = "stylesheet"; + new_link.href = `/dist/main.css?${Math.random()}+${Math.random()}`; + new_link.type = "text/css"; + return new_link; +} + +function getStyles() { + return Array.from(document.querySelectorAll("head link")).filter( + (e: HTMLLinkElement) => new URL(e.href).pathname == "/dist/main.css" + ); +} + +function cleanup_css() { + console.log("clearing styles"); + getStyles() + .slice(0, -1) + .forEach((style) => { + style.parentElement.removeChild(style); + }); +} + +export default class RefreshStyles extends Controller { + socket: WebSocket; + + async connect() { + const { port } = await fetch("/dist/notifier.json").then((r) => r.json()); + this.socket = new WebSocket(`ws://localhost:${port}`); + this.socket.onmessage = async (message) => { + if (message.data === "css") { + const new_link = make_new_link(); + new_link.onload = cleanup_css; + document.querySelector("head").appendChild(new_link); + } + }; + document.documentElement.addEventListener("turbo:morph", cleanup_css); + } + + async disconnect() { + this.socket.close(); + document.documentElement.removeEventListener("turbo:morph", cleanup_css); + } +} diff --git a/src/back/jdd-components/map-with-pins/map-with-pins.css b/src/back/jdd-components/map-with-pins/map-with-pins.css index 4fb7421..dbd120a 100644 --- a/src/back/jdd-components/map-with-pins/map-with-pins.css +++ b/src/back/jdd-components/map-with-pins/map-with-pins.css @@ -1,7 +1,6 @@ .map-with-pins { - #map { - height: 534px; - } + height: 534px; + .popup { .leaflet-popup-content-wrapper, .leaflet-popup-tip { @@ -32,6 +31,7 @@ font-size: 12px; line-height: 21px; } + .button { display: inline-block; margin: 0 auto; diff --git a/src/back/jdd-components/map-with-pins/map-with-pins.jdd.tsx b/src/back/jdd-components/map-with-pins/map-with-pins.jdd.tsx index 83c0b48..524f255 100644 --- a/src/back/jdd-components/map-with-pins/map-with-pins.jdd.tsx +++ b/src/back/jdd-components/map-with-pins/map-with-pins.jdd.tsx @@ -32,95 +32,36 @@ export class MapWithPins extends Component { return component_arguments; } + async getEarlyAssets() { + return [ + { + type: "script" as const, + url: "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js", + identity: "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js", + integrity: "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=", + }, + { + type: "style" as const, + url: "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css", + integrity: "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=", + identity: "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css", + }, + ]; + } + toHTML({ pins, }: ExtractStructuredComponentArgumentsValues< typeof component_arguments >): FlatTemplatable { return ( -
- - - { - /* HTML */ `` - } - -
-
+
); } } diff --git a/src/back/jdd-components/map-with-pins/map-with-pins.stimulus.ts b/src/back/jdd-components/map-with-pins/map-with-pins.stimulus.ts new file mode 100644 index 0000000..97ee79c --- /dev/null +++ b/src/back/jdd-components/map-with-pins/map-with-pins.stimulus.ts @@ -0,0 +1,101 @@ +import { Controller } from "stimulus"; +declare const L: any; + +export default class MapWithPins extends Controller { + id: string; + map: any; + initiated: boolean = false; + resizeObserver: ResizeObserver; + + static values = { + pins: String, + }; + + connect() { + if (this.initiated) { + this.map.remove(); + } + if (window.L) { + this.initiateMap(); + } else { + document.addEventListener( + "loaded-https://unpkg.com/leaflet@1.9.4/dist/leaflet.js", + () => { + this.initiateMap(); + } + ); + } + } + + disconnect() { + this.map.remove(); + this.initiated = false; + this.resizeObserver.disconnect(); + } + + initiateMap() { + this.map = L.map(this.element); + this.resizeObserver = new ResizeObserver(() => { + this.map.invalidateSize(); + }); + + this.resizeObserver.observe(this.element); + + L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxZoom: 19, + attribution: + '© OpenStreetMap', + }).addTo(this.map); + + const pins = JSON.parse( + this.element.attributes["data-map-with-pins-pins-value"].value + ); + pins.forEach((pin) => this.addPin(pin)); + this.initiated = true; + } + + pinsValueChanged() { + if (this.initiated) { + this.connect(); + } + } + + addPin(pin) { + var pinIcon = L.icon({ + iconUrl: "/pin-icon.svg", + iconSize: [29, 41], + iconAnchor: [14, 40], + popupAnchor: [-3, 14], + }); + + var marker = L.marker( + pin.coordinates.split(", ").map((x) => parseFloat(x)), + { + icon: pinIcon, + } + ).addTo(this.map); + + var popup = L.popup({ + closeButton: false, + autoClose: false, + closeOnEscapeKey: false, + closeOnClick: false, + className: "popup", + offset: [0, -32], + maxWidth: "auto", + }) + .setLatLng(pin.coordinates.split(", ").map((x) => parseFloat(x))) + .setContent( + /* HTML */ ` ` + ) + .addTo(this.map); + this.map.setView( + pin.coordinates.split(", ").map((x) => parseFloat(x)), + 13 + ); + } +} diff --git a/src/back/jdd-components/nice-box/nice-box.css b/src/back/jdd-components/nice-box/nice-box.css new file mode 100644 index 0000000..5d7637e --- /dev/null +++ b/src/back/jdd-components/nice-box/nice-box.css @@ -0,0 +1,3 @@ +.nice-box { + background-color: white; +} diff --git a/src/back/routes/component-preview/component-debugger.stimulus.ts b/src/back/routes/component-preview/component-debugger.stimulus.ts new file mode 100644 index 0000000..595c2e2 --- /dev/null +++ b/src/back/routes/component-preview/component-debugger.stimulus.ts @@ -0,0 +1,60 @@ +import { Controller } from "stimulus"; + +export default class ComponentDebugger extends Controller { + id: string; + main_form: HTMLFormElement; + is_resizing: boolean = false; + origin_x: number; + origin_width: number; + + connect() { + this.main_form = document.querySelector("#component-debugger").closest("form"); + document.documentElement.addEventListener("ts-rebuilt", () => { + this.main_form.requestSubmit(); + }); + this.main_form.addEventListener("turbo:submit-end", () => { + // this clears the values of file inputs, so they don't get unecessarily + // re-uploaded on future submissions - the file is alreade there on the server + this.main_form + .querySelectorAll("input[type=file]") + .forEach((input: HTMLInputElement) => (input.value = "")); + }); + + window.addEventListener("load", () => { + this.update_width_display(); + }); + document.addEventListener("turbo:render", () => this.update_width_display()); + + const gutter = this.targets.find("gutter") as HTMLDivElement; + gutter.addEventListener("mousedown", (e) => { + this.is_resizing = true; + this.origin_x = e.clientX; + const resizable = this.targets.find("preview") as HTMLSpanElement; + this.origin_width = resizable.getBoundingClientRect().width; + const handler = this.resizeHandler.bind(this); + document.addEventListener("mousemove", handler); + document.addEventListener("mouseup", () => { + document.removeEventListener("mousemove", handler); + }); + e.preventDefault(); + }); + } + + update_width_display() { + const component_width = (this.targets.find("preview") as HTMLSpanElement) + .offsetWidth; + ( + this.targets.find("component-width") as HTMLSpanElement + ).innerHTML = `(width: ${component_width}px)`; + } + + resizeHandler(e: MouseEvent) { + const width_offset = this.origin_x - e.clientX; + + const new_width = Math.max(this.origin_width + width_offset, 1); + document + .getElementById("component-debugger") + .style.setProperty("--resizable-column-width", new_width + "px"); + this.update_width_display(); + } +} diff --git a/src/back/routes/component-preview/component-input-table.tsx b/src/back/routes/component-preview/component-input-table.tsx index 6b8b77f..7b3fc8d 100644 --- a/src/back/routes/component-preview/component-input-table.tsx +++ b/src/back/routes/component-preview/component-input-table.tsx @@ -40,11 +40,7 @@ export async function ComponentInputTable< return (
{arg_path.at(-1)} -
+
diff --git a/src/front/controllers/input-image-preview.ts b/src/back/routes/component-preview/input-image-preview.stimulus.ts similarity index 100% rename from src/front/controllers/input-image-preview.ts rename to src/back/routes/component-preview/input-image-preview.stimulus.ts diff --git a/src/back/routes/components.css b/src/back/routes/components.css index 847a27a..0f4a1dc 100644 --- a/src/back/routes/components.css +++ b/src/back/routes/components.css @@ -5,7 +5,7 @@ .two-column { display: grid; - grid-template-columns: min-content 15px 1fr; + grid-template-columns: 1fr 15px min-content; } .resize-gutter { @@ -15,7 +15,6 @@ } .resizable { - width: var(--resizable-column-width); overflow-x: auto; } @@ -24,6 +23,20 @@ transform: scale(0.99); opacity: 0.6; } + + .component-preview { + width: var(--resizable-column-width); + + & > fieldset { + min-width: 0; /* default is min-content and that causes overflow*/ + max-height: calc(100vh - 75px); + overflow-x: auto; + } + } + .component-arguments { + max-height: calc(100vh - 80px); + overflow-y: auto; + } } .component-preview-parameters { diff --git a/src/back/routes/components.sreact.tsx b/src/back/routes/components.sreact.tsx index 9be3d6b..8bb9d7f 100644 --- a/src/back/routes/components.sreact.tsx +++ b/src/back/routes/components.sreact.tsx @@ -1,9 +1,9 @@ -import { render } from "@sealcode/jdd"; -import { StateAndMetadata, StatefulPage, to_base64 } from "@sealcode/sealgen"; -import { hasFieldOfType, hasShape, predicates } from "@sealcode/ts-predicates"; +import { render, renderEarlyAssets } from "@sealcode/jdd"; +import { StatefulPage, to_base64 } from "@sealcode/sealgen"; +import { hasShape, predicates } from "@sealcode/ts-predicates"; import { BaseContext } from "koa"; -import { Templatable, TempstreamJSX } from "tempstream"; -import html from "../html.js"; +import { Templatable, tempstream, TempstreamJSX } from "tempstream"; +import html, { defaultHead } from "../html.js"; import { registry } from "../jdd-components/components.js"; import { ComponentInput } from "./component-preview/component-input.js"; import { ComponentPreviewActions } from "./component-preview/component-preview-actions.js"; @@ -31,13 +31,33 @@ export default new (class ComponentsPage extends StatefulPage< return initial_state; } - wrapInLayout(ctx: BaseContext, content: Templatable): Templatable { - return html(ctx, "Components", content, { - morphing: false, - preserveScroll: true, - autoRefreshCSS: true, - navbar: () => ``, - }); + wrapInLayout( + ctx: BaseContext, + content: Templatable, + state: ComponentPreviewState + ): Templatable { + return html( + ctx, + "Components", + content, + { + morphing: true, + preserveScroll: true, + autoRefreshCSS: true, + navbar: () => ``, + }, + (...args) => + tempstream`${defaultHead(...args)}${renderEarlyAssets( + registry, + [ + { + component_name: state.component, + args: state.component_args, + }, + ], + jdd_context + )}` + ); } wrapInForm(state: ComponentPreviewState, content: Templatable): Templatable { @@ -96,8 +116,9 @@ export default new (class ComponentsPage extends StatefulPage< class="two-column" id="component-debugger" style="--resizable-column-width: 50vw" + data-controller="component-debugger" > -
+
{/*The below button has to be here in order for it to be the default behavior */}