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 (