refactor lit components to lustre and vanilla js

This commit is contained in:
Brent Schroeter 2025-07-25 15:01:31 -07:00
parent 1d95b6f917
commit 316a3d8414
40 changed files with 1018 additions and 1617 deletions

1
.gitignore vendored
View file

@ -3,7 +3,6 @@ target
.DS_Store .DS_Store
node_modules node_modules
css_dist css_dist
glm_dist
js_dist js_dist
pgdata pgdata
.vite .vite

View file

@ -1,16 +0,0 @@
{
"compilerOptions": {
"lib": ["deno.ns", "dom"],
"experimentalDecorators": true,
"useDefineForClassFields": false
},
"tasks": {
"dev": "deno run -A --node-modules-dir npm:vite",
"build": "deno run -A --node-modules-dir npm:vite build"
},
"imports": {
"@lit/context": "npm:@lit/context@^1.1.2",
"lit": "npm:lit@^3.2.0",
"vite": "npm:vite@^5.2.10"
}
}

353
components/deno.lock generated
View file

@ -1,353 +0,0 @@
{
"version": "5",
"specifiers": {
"jsr:@std/path@*": "1.0.8",
"npm:@lit/context@^1.1.2": "1.1.2",
"npm:lit@^3.2.0": "3.2.1",
"npm:vite@*": "5.4.5",
"npm:vite@^5.2.10": "5.4.5"
},
"jsr": {
"@std/path@1.0.8": {
"integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be"
}
},
"npm": {
"@esbuild/aix-ppc64@0.21.5": {
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
"os": ["aix"],
"cpu": ["ppc64"]
},
"@esbuild/android-arm64@0.21.5": {
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
"os": ["android"],
"cpu": ["arm64"]
},
"@esbuild/android-arm@0.21.5": {
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
"os": ["android"],
"cpu": ["arm"]
},
"@esbuild/android-x64@0.21.5": {
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
"os": ["android"],
"cpu": ["x64"]
},
"@esbuild/darwin-arm64@0.21.5": {
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
"os": ["darwin"],
"cpu": ["arm64"]
},
"@esbuild/darwin-x64@0.21.5": {
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
"os": ["darwin"],
"cpu": ["x64"]
},
"@esbuild/freebsd-arm64@0.21.5": {
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
"os": ["freebsd"],
"cpu": ["arm64"]
},
"@esbuild/freebsd-x64@0.21.5": {
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
"os": ["freebsd"],
"cpu": ["x64"]
},
"@esbuild/linux-arm64@0.21.5": {
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
"os": ["linux"],
"cpu": ["arm64"]
},
"@esbuild/linux-arm@0.21.5": {
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
"os": ["linux"],
"cpu": ["arm"]
},
"@esbuild/linux-ia32@0.21.5": {
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
"os": ["linux"],
"cpu": ["ia32"]
},
"@esbuild/linux-loong64@0.21.5": {
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
"os": ["linux"],
"cpu": ["loong64"]
},
"@esbuild/linux-mips64el@0.21.5": {
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
"os": ["linux"],
"cpu": ["mips64el"]
},
"@esbuild/linux-ppc64@0.21.5": {
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
"os": ["linux"],
"cpu": ["ppc64"]
},
"@esbuild/linux-riscv64@0.21.5": {
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
"os": ["linux"],
"cpu": ["riscv64"]
},
"@esbuild/linux-s390x@0.21.5": {
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
"os": ["linux"],
"cpu": ["s390x"]
},
"@esbuild/linux-x64@0.21.5": {
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
"os": ["linux"],
"cpu": ["x64"]
},
"@esbuild/netbsd-x64@0.21.5": {
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
"os": ["netbsd"],
"cpu": ["x64"]
},
"@esbuild/openbsd-x64@0.21.5": {
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
"os": ["openbsd"],
"cpu": ["x64"]
},
"@esbuild/sunos-x64@0.21.5": {
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
"os": ["sunos"],
"cpu": ["x64"]
},
"@esbuild/win32-arm64@0.21.5": {
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
"os": ["win32"],
"cpu": ["arm64"]
},
"@esbuild/win32-ia32@0.21.5": {
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
"os": ["win32"],
"cpu": ["ia32"]
},
"@esbuild/win32-x64@0.21.5": {
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
"os": ["win32"],
"cpu": ["x64"]
},
"@lit-labs/ssr-dom-shim@1.2.1": {
"integrity": "sha512-wx4aBmgeGvFmOKucFKY+8VFJSYZxs9poN3SDNQFF6lT6NrQUnHiPB2PWz2sc4ieEcAaYYzN+1uWahEeTq2aRIQ=="
},
"@lit/context@1.1.2": {
"integrity": "sha512-S0nw2C6Tkm7fVX5TGYqeROGD+Z9Coa2iFpW+ysYBDH3YvCqOY3wVQvSgwbaliLJkjTnSEYCBe9qFqKV8WUFpVw==",
"dependencies": [
"@lit/reactive-element"
]
},
"@lit/reactive-element@2.0.4": {
"integrity": "sha512-GFn91inaUa2oHLak8awSIigYz0cU0Payr1rcFsrkf5OJ5eSPxElyZfKh0f2p9FsTiZWXQdWGJeXZICEfXXYSXQ==",
"dependencies": [
"@lit-labs/ssr-dom-shim"
]
},
"@rollup/rollup-android-arm-eabi@4.21.3": {
"integrity": "sha512-MmKSfaB9GX+zXl6E8z4koOr/xU63AMVleLEa64v7R0QF/ZloMs5vcD1sHgM64GXXS1csaJutG+ddtzcueI/BLg==",
"os": ["android"],
"cpu": ["arm"]
},
"@rollup/rollup-android-arm64@4.21.3": {
"integrity": "sha512-zrt8ecH07PE3sB4jPOggweBjJMzI1JG5xI2DIsUbkA+7K+Gkjys6eV7i9pOenNSDJH3eOr/jLb/PzqtmdwDq5g==",
"os": ["android"],
"cpu": ["arm64"]
},
"@rollup/rollup-darwin-arm64@4.21.3": {
"integrity": "sha512-P0UxIOrKNBFTQaXTxOH4RxuEBVCgEA5UTNV6Yz7z9QHnUJ7eLX9reOd/NYMO3+XZO2cco19mXTxDMXxit4R/eQ==",
"os": ["darwin"],
"cpu": ["arm64"]
},
"@rollup/rollup-darwin-x64@4.21.3": {
"integrity": "sha512-L1M0vKGO5ASKntqtsFEjTq/fD91vAqnzeaF6sfNAy55aD+Hi2pBI5DKwCO+UNDQHWsDViJLqshxOahXyLSh3EA==",
"os": ["darwin"],
"cpu": ["x64"]
},
"@rollup/rollup-linux-arm-gnueabihf@4.21.3": {
"integrity": "sha512-btVgIsCjuYFKUjopPoWiDqmoUXQDiW2A4C3Mtmp5vACm7/GnyuprqIDPNczeyR5W8rTXEbkmrJux7cJmD99D2g==",
"os": ["linux"],
"cpu": ["arm"]
},
"@rollup/rollup-linux-arm-musleabihf@4.21.3": {
"integrity": "sha512-zmjbSphplZlau6ZTkxd3+NMtE4UKVy7U4aVFMmHcgO5CUbw17ZP6QCgyxhzGaU/wFFdTfiojjbLG3/0p9HhAqA==",
"os": ["linux"],
"cpu": ["arm"]
},
"@rollup/rollup-linux-arm64-gnu@4.21.3": {
"integrity": "sha512-nSZfcZtAnQPRZmUkUQwZq2OjQciR6tEoJaZVFvLHsj0MF6QhNMg0fQ6mUOsiCUpTqxTx0/O6gX0V/nYc7LrgPw==",
"os": ["linux"],
"cpu": ["arm64"]
},
"@rollup/rollup-linux-arm64-musl@4.21.3": {
"integrity": "sha512-MnvSPGO8KJXIMGlQDYfvYS3IosFN2rKsvxRpPO2l2cum+Z3exiExLwVU+GExL96pn8IP+GdH8Tz70EpBhO0sIQ==",
"os": ["linux"],
"cpu": ["arm64"]
},
"@rollup/rollup-linux-powerpc64le-gnu@4.21.3": {
"integrity": "sha512-+W+p/9QNDr2vE2AXU0qIy0qQE75E8RTwTwgqS2G5CRQ11vzq0tbnfBd6brWhS9bCRjAjepJe2fvvkvS3dno+iw==",
"os": ["linux"],
"cpu": ["ppc64"]
},
"@rollup/rollup-linux-riscv64-gnu@4.21.3": {
"integrity": "sha512-yXH6K6KfqGXaxHrtr+Uoy+JpNlUlI46BKVyonGiaD74ravdnF9BUNC+vV+SIuB96hUMGShhKV693rF9QDfO6nQ==",
"os": ["linux"],
"cpu": ["riscv64"]
},
"@rollup/rollup-linux-s390x-gnu@4.21.3": {
"integrity": "sha512-R8cwY9wcnApN/KDYWTH4gV/ypvy9yZUHlbJvfaiXSB48JO3KpwSpjOGqO4jnGkLDSk1hgjYkTbTt6Q7uvPf8eg==",
"os": ["linux"],
"cpu": ["s390x"]
},
"@rollup/rollup-linux-x64-gnu@4.21.3": {
"integrity": "sha512-kZPbX/NOPh0vhS5sI+dR8L1bU2cSO9FgxwM8r7wHzGydzfSjLRCFAT87GR5U9scj2rhzN3JPYVC7NoBbl4FZ0g==",
"os": ["linux"],
"cpu": ["x64"]
},
"@rollup/rollup-linux-x64-musl@4.21.3": {
"integrity": "sha512-S0Yq+xA1VEH66uiMNhijsWAafffydd2X5b77eLHfRmfLsRSpbiAWiRHV6DEpz6aOToPsgid7TI9rGd6zB1rhbg==",
"os": ["linux"],
"cpu": ["x64"]
},
"@rollup/rollup-win32-arm64-msvc@4.21.3": {
"integrity": "sha512-9isNzeL34yquCPyerog+IMCNxKR8XYmGd0tHSV+OVx0TmE0aJOo9uw4fZfUuk2qxobP5sug6vNdZR6u7Mw7Q+Q==",
"os": ["win32"],
"cpu": ["arm64"]
},
"@rollup/rollup-win32-ia32-msvc@4.21.3": {
"integrity": "sha512-nMIdKnfZfzn1Vsk+RuOvl43ONTZXoAPUUxgcU0tXooqg4YrAqzfKzVenqqk2g5efWh46/D28cKFrOzDSW28gTA==",
"os": ["win32"],
"cpu": ["ia32"]
},
"@rollup/rollup-win32-x64-msvc@4.21.3": {
"integrity": "sha512-fOvu7PCQjAj4eWDEuD8Xz5gpzFqXzGlxHZozHP4b9Jxv9APtdxL6STqztDzMLuRXEc4UpXGGhx029Xgm91QBeA==",
"os": ["win32"],
"cpu": ["x64"]
},
"@types/estree@1.0.5": {
"integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw=="
},
"@types/trusted-types@2.0.7": {
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="
},
"esbuild@0.21.5": {
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"optionalDependencies": [
"@esbuild/aix-ppc64",
"@esbuild/android-arm",
"@esbuild/android-arm64",
"@esbuild/android-x64",
"@esbuild/darwin-arm64",
"@esbuild/darwin-x64",
"@esbuild/freebsd-arm64",
"@esbuild/freebsd-x64",
"@esbuild/linux-arm",
"@esbuild/linux-arm64",
"@esbuild/linux-ia32",
"@esbuild/linux-loong64",
"@esbuild/linux-mips64el",
"@esbuild/linux-ppc64",
"@esbuild/linux-riscv64",
"@esbuild/linux-s390x",
"@esbuild/linux-x64",
"@esbuild/netbsd-x64",
"@esbuild/openbsd-x64",
"@esbuild/sunos-x64",
"@esbuild/win32-arm64",
"@esbuild/win32-ia32",
"@esbuild/win32-x64"
],
"scripts": true,
"bin": true
},
"fsevents@2.3.3": {
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"os": ["darwin"],
"scripts": true
},
"lit-element@4.1.1": {
"integrity": "sha512-HO9Tkkh34QkTeUmEdNYhMT8hzLid7YlMlATSi1q4q17HE5d9mrrEHJ/o8O2D0cMi182zK1F3v7x0PWFjrhXFew==",
"dependencies": [
"@lit-labs/ssr-dom-shim",
"@lit/reactive-element",
"lit-html"
]
},
"lit-html@3.2.1": {
"integrity": "sha512-qI/3lziaPMSKsrwlxH/xMgikhQ0EGOX2ICU73Bi/YHFvz2j/yMCIrw4+puF2IpQ4+upd3EWbvnHM9+PnJn48YA==",
"dependencies": [
"@types/trusted-types"
]
},
"lit@3.2.1": {
"integrity": "sha512-1BBa1E/z0O9ye5fZprPtdqnc0BFzxIxTTOO/tQFmyC/hj1O3jL4TfmLBw0WEwjAokdLwpclkvGgDJwTIh0/22w==",
"dependencies": [
"@lit/reactive-element",
"lit-element",
"lit-html"
]
},
"nanoid@3.3.7": {
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"bin": true
},
"picocolors@1.1.0": {
"integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw=="
},
"postcss@8.4.47": {
"integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==",
"dependencies": [
"nanoid",
"picocolors",
"source-map-js"
]
},
"rollup@4.21.3": {
"integrity": "sha512-7sqRtBNnEbcBtMeRVc6VRsJMmpI+JU1z9VTvW8D4gXIYQFz0aLcsE6rRkyghZkLfEgUZgVvOG7A5CVz/VW5GIA==",
"dependencies": [
"@types/estree"
],
"optionalDependencies": [
"@rollup/rollup-android-arm-eabi",
"@rollup/rollup-android-arm64",
"@rollup/rollup-darwin-arm64",
"@rollup/rollup-darwin-x64",
"@rollup/rollup-linux-arm-gnueabihf",
"@rollup/rollup-linux-arm-musleabihf",
"@rollup/rollup-linux-arm64-gnu",
"@rollup/rollup-linux-arm64-musl",
"@rollup/rollup-linux-powerpc64le-gnu",
"@rollup/rollup-linux-riscv64-gnu",
"@rollup/rollup-linux-s390x-gnu",
"@rollup/rollup-linux-x64-gnu",
"@rollup/rollup-linux-x64-musl",
"@rollup/rollup-win32-arm64-msvc",
"@rollup/rollup-win32-ia32-msvc",
"@rollup/rollup-win32-x64-msvc",
"fsevents"
],
"bin": true
},
"source-map-js@1.2.1": {
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
},
"vite@5.4.5": {
"integrity": "sha512-pXqR0qtb2bTwLkev4SE3r4abCNioP3GkjvIDLlzziPpXtHgiJIjuKl+1GN6ESOT3wMjG3JTeARopj2SwYaHTOA==",
"dependencies": [
"esbuild",
"postcss",
"rollup"
],
"optionalDependencies": [
"fsevents"
],
"bin": true
}
},
"workspace": {
"dependencies": [
"npm:@lit/context@^1.1.2",
"npm:lit@^3.2.0",
"npm:vite@^5.2.10"
]
}
}

View file

@ -1,132 +0,0 @@
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
import "./add-selection-modal-contents.tsx";
@customElement("add-selection-button")
export class AddSelectionButton extends LitElement {
@property({ attribute: true })
columns = "";
@state()
private _active = false;
private _labelInputRef = createRef<HTMLInputElement>();
private _nameInputRef = createRef<HTMLInputElement>();
private _typePopoverRef = createRef<HTMLDivElement>();
static override styles = css`
:host {
height: 100%;
--shadow: 0 0.5rem 0.5rem #3333;
}
button.main {
appearance: none;
border: none;
width: 100%;
height: 100%;
font-weight: inherit;
font-size: inherit;
font-family: inherit;
cursor: pointer;
background: none;
}
div.th {
height: 100%;
display: flex;
border: dashed 1px #ccc;
border-left: none;
border-top: none;
font-family: "Funnel Sans";
background: #0001;
box-sizing: border-box;
padding: 0 0.5rem;
}
input#name-input {
appearance: none;
background: none;
border: none;
outline: none;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
}
button#type-config-button {
anchor-name: --type-config-button;
}
.config-popover:popover-open {
box-sizing: border-box;
inset: unset;
margin: 0;
margin-top: 1rem;
position: fixed;
display: block;
width: 20rem;
border: solid 1px #ccc;
border-radius: 0.25rem;
filter: drop-shadow(var(--shadow));
}
#type-popover {
position-anchor: --type-config-button;
position-area: bottom;
}
`;
protected override updated() {
if (this._active && this._labelInputRef.value) {
this._labelInputRef.value.focus();
}
}
private _activate() {
this._active = true;
}
private _handleLabelChange(ev: InputEvent) {
}
private _showTypePopover() {
this._typePopoverRef.value?.showPopover();
}
protected override render() {
if (this._active) {
return html`
<div class="th">
<input
type="text"
${ref(
this._labelInputRef,
)}
id="label-input"
name="name-input"
@change="${this._handleLabelChange}"
>
<button type="button" id="type-config-button" @click="${this
._showTypePopover}">
abc
</button>
<button type="submit">Create</button>
</div>
<div ${ref(
this._typePopoverRef,
)} id="type-popover" class="config-popover" popover="auto">
<input type="text" ${ref(this._nameInputRef)} name="name">
</div>
`;
}
return html`
<button type="button" class="main" @click="${this._activate}">+</button>
`;
}
}

View file

@ -1,88 +0,0 @@
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { styleMap } from "lit/directives/style-map.js";
@customElement("add-selection-modal-content")
export class AddSelectionModalContents extends LitElement {
@property({ attribute: true, type: Array })
columns: {
attname: string;
atttypid: number;
}[] = [];
@state()
private _tab: "existing_col" | "new_col" = "existing_col";
static override styles = css`
.container {}
.tabs {
display: flex;
justify-content: flex-start;
}
button.tab {
appearance: none;
background: none;
border: none;
border-bottom: solid 2px #4474;
font: inherit;
padding: 0.25rem 0.5rem;
&.tab-active {
border-bottom-color: #447;
}
}
`;
setTab(key: typeof this._tab) {
this._tab = key;
}
protected override render() {
return html`
<div class="container">
<div class="tabs">
<button
class="${classMap({
tab: true,
"tab-active": this._tab === "existing_col",
})}"
type="button"
@click="${() => this.setTab("existing_col")}"
>
Select Existing
</button>
<button
class="${classMap({
tab: true,
"tab-active": this._tab === "new_col",
})}"
type="button"
@click="${() => this.setTab("new_col")}"
>
Create Column
</button>
</div>
<div class="tab-controlled">
<div style="${styleMap({
display: this._tab === "existing_col" ? "block" : "none",
})}">
<form method="post" action="add-selection">
<label for="column-select">Column:</label>
<select name="column" id="column-select">
${this.columns.map((column) =>
html`
<option value="${column.attname}">${column.attname}</option>
`
)}
</select>
<button type="submit">Add to Table</button>
</form>
</div>
</div>
</div>
`;
}
}

View file

@ -1,129 +0,0 @@
import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
@customElement("cell-text")
export class CellText extends LitElement {
/**
* When present, a JSON object with key "c" mapped to cell contents.
*/
@property({ attribute: true, reflect: true })
value = "null";
@property({ attribute: true, type: Boolean })
editable = false;
@property({ attribute: true })
column = "";
@property()
pkeyJson = "";
@state()
private _editing = false;
@state()
private _updating = false;
private _inputRef = createRef<HTMLTextAreaElement>();
static override styles = css`
.outer {
width: 100%;
height: 100%;
font-family: "Funnel Sans";
}
`;
protected override updated() {
if (this._editing && this._inputRef.value) {
this._inputRef.value.focus();
}
}
private get _contents(): string | undefined {
return JSON.parse(this.value)?.c ?? undefined;
}
startEdit() {
if (!this._updating) {
this._editing = true;
}
}
cancelEdit() {
this._editing = false;
}
confirmEdit(value: string) {
// TODO how to handle null vs empty string
(async () => {
this._editing = false;
this._updating = true;
const response = await fetch("update-value", {
method: "post",
headers: { "content-type": "application/json" },
body: JSON.stringify({
pkeys: JSON.parse(this.pkeyJson),
column: this.column,
value: { t: "Text", c: value },
}),
});
// TODO retry logic
if (response.ok) {
this.value = JSON.stringify({ t: "Text", c: value });
this._updating = false;
}
})()
.catch(console.error);
}
private _handleClick() {
if (!this._editing && !this._updating) {
this.startEdit();
}
}
private _handleBlur(ev: Event) {
this.confirmEdit((ev.target as HTMLTextAreaElement).value);
}
protected override render() {
let inner: TemplateResult = html`
`;
if (this._updating) {
inner = html`
Loading...
`;
}
if (this._editing) {
inner = html`
<div>
<textarea ${ref(this._inputRef)} @blur="${this
._handleBlur}">${this._contents ??
""}</textarea>
<button type="button" @click="${this.cancelEdit}">
<custom-icon name="x-mark" alt="cancel"></custom-icon>
</button>
</div>
`;
} else if (this._contents === null) {
inner = html`
<code>NULL</code>
`;
} else {
inner = html`
${this._contents}
`;
}
return html`
<div class="outer" @click="${this._handleClick}">
${inner}
</div>
`;
}
}

View file

@ -1,128 +0,0 @@
import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
@customElement("cell-uuid")
export class CellUuid extends LitElement {
/**
* When present, a JSON object with key "c" mapped to cell contents.
*/
@property({ attribute: true, reflect: true })
value = "null";
@property({ attribute: true })
column = "";
@property()
pkeyJson = "";
@state()
private _editing = false;
@state()
private _updating = false;
private _inputRef = createRef<HTMLTextAreaElement>();
static override styles = css`
.outer {
width: 100%;
height: 100%;
font-family: "Funnel Sans";
}
`;
protected override updated() {
if (this._editing && this._inputRef.value) {
this._inputRef.value.focus();
}
}
private get _contents(): string | undefined {
return JSON.parse(this.value)?.c ?? undefined;
}
startEdit() {
if (!this._updating) {
this._editing = true;
}
}
cancelEdit() {
this._editing = false;
}
confirmEdit(value: string) {
// TODO how to handle null vs empty string
(async () => {
this._editing = false;
this._updating = true;
const response = await fetch("update-value", {
method: "post",
headers: { "content-type": "application/json" },
body: JSON.stringify({
pkeys: JSON.parse(this.pkeyJson),
column: this.column,
value: { t: "Uuid", c: value },
}),
});
// TODO retry logic
if (response.ok) {
// FIXME If this is a primary key, need to freeze the whole row until
// edit is accepted by the server, and then update the pkeys value
// locally.
this.value = JSON.stringify({ t: "Uuid", c: value });
this._updating = false;
}
})()
.catch(console.error);
}
private _handleClick() {
if (!this._editing && !this._updating) {
this.startEdit();
}
}
private _handleBlur(ev: Event) {
this.confirmEdit((ev.target as HTMLTextAreaElement).value);
}
protected override render() {
let inner: TemplateResult = html`
`;
if (this._updating) {
inner = html`
Loading...
`;
}
if (this._editing) {
inner = html`
<div>
<textarea ${ref(this._inputRef)} @blur="${this
._handleBlur}">${this._contents ?? ""}</textarea>
<button type="button" @click="${this.cancelEdit}">
<custom-icon name="x-mark" alt="cancel"></custom-icon>
</button>
</div>
`;
} else if (this._contents === null) {
inner = html`
<code>NULL</code>
`;
} else {
inner = html`
<code>${this._contents}</code>
`;
}
return html`
<div class="outer" @click="${this._handleClick}">
${inner}
</div>
`;
}
}

View file

@ -1,2 +0,0 @@
export { CellText } from "../cell-text.ts";
export { CellUuid } from "../cell-uuid.ts";

View file

@ -1,32 +0,0 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("custom-icon")
export class CustomIcon extends LitElement {
@property({ attribute: true })
name = "";
@property({ attribute: true })
alt?: string;
static override styles = css`
:host {
display: flex;
align-items: center;
}
img {
display: block;
}
`;
protected override render() {
const path = `../heroicons/16/solid/${this.name}.svg`;
return html`
<img src="${new URL(
path,
import.meta.url,
).href}" alt="${this.alt}">
`;
}
}

View file

@ -1,118 +0,0 @@
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
@customElement("dev-reloader")
export class DevReloader extends LitElement {
@property({ attribute: true })
ws = "";
@property({ attribute: true, type: Boolean, reflect: true })
auto = false;
@state()
private _connected = false;
private _armed = false;
private _socket?: WebSocket;
static override styles = css`
button {
appearance: none;
background: none;
border: none;
padding: none;
}
.widget {
position: fixed;
z-index: 999;
bottom: 1rem;
left: 1rem;
border: solid 1px #0002;
padding: 1rem;
border-radius: 9999px;
cursor: pointer;
display: flex;
align-items: center;
box-shadow: 0 0.5rem 1rem #0002;
opacity: 0.5;
&:hover {
opacity: 1;
}
}
.indicator {
width: 8px;
height: 8px;
border-radius: 9999px;
background: #f60;
&.connected {
background: #06f;
}
}
.label {
margin-left: 1rem;
}
`;
override connectedCallback() {
super.connectedCallback();
this._connected = true;
this._handleDisconnect();
}
private _handleDisconnect() {
if (this._connected) {
console.log("dev-reloader: disconnected");
this._connected = false;
this._socket = undefined;
const intvl = setInterval(() => {
if (!this._socket || this._socket.readyState === WebSocket.CLOSED) {
try {
this._socket = new WebSocket(this.ws);
this._socket.addEventListener("open", () => {
if (this.auto && this._armed) {
globalThis.location.reload();
}
console.log("dev-reloader: connected");
this._connected = true;
this._armed = true;
clearInterval(intvl);
});
this._socket.addEventListener(
"close",
this._handleDisconnect.bind(this),
);
this._socket.addEventListener(
"error",
this._handleDisconnect.bind(this),
);
} catch { /* no-op */ }
}
}, 500);
}
}
private _toggleAuto() {
this.auto = !this.auto;
}
protected override render() {
return html`
<button type="button" class="widget" @click="${this._toggleAuto}">
<div class="${classMap({
indicator: true,
connected: this._connected,
})}"></div>
<div class="label">
${this.auto ? "Disable" : "Enable"} auto-reload
</div>
</button>
`;
}
}

View file

@ -1 +0,0 @@
export { LensControls } from "../lens-controls.ts";

View file

@ -1 +0,0 @@
export { AddSelectionButton } from "../add-selection-button.ts";

View file

@ -1,11 +0,0 @@
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll("table.viewer > tbody > tr").forEach((tr) => {
const pkeyJson = tr.getAttribute("data-pkey");
if (pkeyJson !== null) {
tr.querySelectorAll(".cell").forEach((node) => {
console.log(node);
(node as unknown as { pkeyJson: string }).pkeyJson = pkeyJson;
});
}
});
});

View file

@ -1,214 +0,0 @@
import { css, html, LitElement } from "lit";
import { classMap } from "lit/directives/class-map.js";
import { createRef, ref } from "lit/directives/ref.js";
import { customElement, property, state } from "lit/decorators.js";
import "./entrypoints/custom-icon.ts";
export type TabItem = {
icon: string;
label: string;
value: string;
};
export class SelectTabEvent extends Event {
value: string;
constructor(value: string) {
super("select-tab");
this.value = value;
}
}
@customElement("lens-controls-shell")
export class LensControlsShell extends LitElement {
@property({ attribute: true, type: Array })
tabs: TabItem[] = [];
@property({ attribute: "active-tab" })
activeTab?: string;
@state()
private _isOpen = false;
private _controlPanelRef = createRef<HTMLDivElement>();
static override styles = css`
:host {
--shadow: 0 0.5rem 0.5rem #3333;
--background: #fff;
--border-color: #ccc;
--border-radius: 0.5rem;
}
#container-positioner {
position: fixed;
bottom: 2rem;
display: flex;
justify-content: center;
align-items: flex-end;
overflow: visible;
width: 100%;
height: 0;
}
#container {
display: grid;
grid-template-columns: max-content max-content 1rem max-content;
filter: drop-shadow(var(--shadow));
}
#tab-box {
height: 2rem;
display: flex;
align-items: center;
list-style-type: none;
padding: 0.5rem;
margin: 0;
border: solid 1px var(--border-color);
border-right: none;
border-top-left-radius: var(--border-radius);
border-bottom-left-radius: var(--border-radius);
background: var(--background);
grid-row: 2;
& button {
appearance: none;
background: none;
border: none;
font-size: inherit;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-family: inherit;
cursor: pointer;
height: 2rem;
}
& button.active {
background: #39f3;
}
}
#control-bar {
height: 3rem;
flex-shrink: 0;
border: solid 1px var(--border-color);
border-top-right-radius: var(--border-radius);
border-bottom-right-radius: var(--border-radius);
overflow: hidden;
width: 40rem;
grid-row: 2;
background: var(--background);
&.open {
border-top-right-radius: 0;
}
}
#control-buttons {
height: 3rem;
grid-row: 2;
grid-column: 4;
height: 100%;
}
#control-panel-positioner {
grid-template-columns: subgrid;
grid-column: 2;
grid-row: 1;
position: relative;
overflow: visible;
/* Flexbox positioning is required for Safari */
display: flex;
align-items: flex-end;
anchor-name: --control-bar;
}
#control-panel-container:popover-open {
inset: unset;
border: solid 1px var(--border-color);
border-bottom: none;
border-top-left-radius: var(--border-radius);
border-top-right-radius: var(--border-radius);
margin: 0;
position: fixed;
display: block;
width: 40rem;
/* Anchor positioning is required for Chromium */
position-anchor: --control-bar;
position-area: top;
padding: 0;
background: var(--background);
box-shadow: var(--shadow);
/* Clip drop shadow */
clip-path: polygon(
-100% -100%,
200% -100%,
200% 200%,
100% 200%,
100% 100%,
-100% 100%
);
}
#control-panel {
padding: 0.5rem;
overflow: auto;
max-height: 8rem;
}
`;
open(): void {
this._controlPanelRef.value?.showPopover();
}
protected override render() {
return html`
<div id="container-positioner">
<div id="container">
<ul id="tab-box" @click="${this.open}">
${this.tabs.map(({ icon, label, value }) =>
html`
<li>
<button type="button" class="${classMap({
active: this.activeTab === value,
})}" @click="${() => {
this.dispatchEvent(new SelectTabEvent(value));
}}">
${icon
? html`<custom-icon name=${icon} alt=${label}></custom-label>`
: label}
</button>
</li>
`
)}
</ul>
<div id="control-bar" class="${classMap({
open: this._isOpen,
})}" @click="${this.open}">
<slot name="control-bar"></slot>
</div>
<div id="control-buttons">
<slot name="control-buttons"></slot>
</div>
<div id="control-panel-positioner">
<div
id="control-panel-container"
popover="auto"
${ref(
this._controlPanelRef,
)}
@toggle="${(ev: ToggleEvent) => {
this._isOpen = ev.newState === "open";
}}"
>
<div id="control-panel">
<slot name="control-panel"></slot>
</div>
</div>
</div>
</div>
</div>
`;
}
}

View file

@ -1,191 +0,0 @@
import { css, html, LitElement } from "lit";
import { createRef, ref } from "lit/directives/ref.js";
import { customElement, property } from "lit/decorators.js";
import "./lens-controls-shell.ts";
import "./entrypoints/custom-icon.ts";
import { type LensControlsShell } from "./lens-controls-shell.ts";
import { type Selection } from "./selections.ts";
export { SelectionFilters } from "./selection-filters.ts";
const TABS = [
{ icon: "funnel", label: "Filters", value: "filters" },
{ icon: "view-columns", label: "Columns", value: "columns" },
];
@customElement("lens-controls")
export class LensControls extends LitElement {
@property({ attribute: true, type: Array })
selections: Selection[] = [];
private _shellRef = createRef<LensControlsShell>();
static override styles = css`
#control-bar {
width: 100%;
height: 100%;
display: flex;
align-items: stretch;
& input {
appearance: none;
flex: 1;
border: none;
outline: none;
font-size: inherit;
font-family: "Funnel Sans";
padding: 0.5rem;
}
& .actions {
flex: 0;
display: flex;
align-items: stretch;
padding: 0.5rem;
}
}
#control-buttons {
height: 100%;
display: flex;
align-items: stretch;
& button {
appearance: none;
background: #447;
border: none;
color: #fff;
font-size: inherit;
font-family: inherit;
border-radius: 0.5rem;
padding: 0.5rem 1rem;
cursor: pointer;
}
}
#selections {
display: grid;
grid-template-columns: max-content max-content max-content max-content 1fr;
grid-gap: 0 1rem;
}
.selection {
display: grid;
grid-column: 1 / 7;
grid-template-columns: subgrid;
justify-content: start;
align-items: center;
padding: 0.5rem;
border-radius: 0.25rem;
transition: background 0.2s ease;
&:hover {
background: #9991;
}
& .selection-filters {
font-family: "Funnel Sans";
}
}
.label input {
font-family: "Funnel Sans";
font-size: inherit;
outline: none;
background: transparent;
border: none;
height: 100%;
width: 100%;
}
`;
private _handleVisibilityInput(ev: InputEvent, selectionId: string): void {
this.selections = this.selections.map((
selection,
) => (selection.id === selectionId
? {
...selection,
visible: (ev.target as HTMLInputElement).checked,
}
: selection)
);
}
private _handleSelectionFiltersChange(ev: Event, selectionId: string): void {
// TODO
}
protected override render() {
return html`
<form method="post" action="update-lens">
<lens-controls-shell tabs="${JSON.stringify(
TABS,
)}" active-tab="columns" ${ref(this._shellRef)}>
<div slot="control-bar" id="control-bar">
<input
type="text"
placeholder="Configure columns..."
@keydown="${() => {
this._shellRef.value?.open();
}}"
>
</div>
<div slot="control-buttons" id="control-buttons">
<button type="submit">Apply</button>
</div>
<div slot="control-panel" id="selections">
${this.selections.map((selection) =>
html`
<div class="selection">
<div class="visibility">
<input
type="checkbox"
name="selection_visibility_${selection.id}"
?checked="${selection.visible}"
@input="${(ev: InputEvent) =>
this._handleVisibilityInput(ev, selection.id)}"
>
</div>
<div class="selection-filters">
<selection-filters
selection-id="${selection.id}"
filters="${JSON.stringify(selection.attr_filters)}"
@change="${(ev: Event) =>
this._handleSelectionFiltersChange(ev, selection.id)}"
></selection-filters>
</div>
<div>Text</div>
<div class="cast">
<custom-icon name="arrow-right"></custom-icon>
</div>
<div class="label">
<input
type="text"
name="selection_label_${selection.id}"
@change="${(
ev: Event,
) => {
this.selections = this.selections.map((
iterSelection,
) => (iterSelection.id === selection.id
? {
...iterSelection,
label: (ev.target as HTMLInputElement).value.trim() ??
undefined,
}
: iterSelection)
);
}}"
value="${selection.label ?? ""}"
placeholder="Customize name"
>
</div>
</div>
`
)}
</div>
</lens-controls-shell>
</form>
`;
}
}

View file

@ -1,57 +0,0 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { type AttrFilter } from "./selections.ts";
@customElement("selection-filters")
export class SelectionFilters extends LitElement {
@property({ attribute: "selection-id" })
selectionId = "";
@property({ attribute: true, type: Array })
filters: AttrFilter[] = [];
static override styles = css`
#selection-filters {
display: flex;
list-style-type: none;
margin: 0;
padding: 0;
}
`;
protected override render() {
return html`
<ol id="selection-filters">
${this.filters.map((filter) =>
html`
<li class="selection-filter">
${"NameEq" in filter
? html`
<name-eq-filter>${filter.NameEq}</name-eq-filter>
`
: undefined} ${"NameMatches" in filter
? html`
<name-matches-filter>${filter.NameMatches}</name-matches-filter>
`
: undefined} ${"TypeEq" in filter
? html`
<type-eq-filter>${filter.TypeEq}</type-eq-filter>
`
: undefined}
</li>
`
)}
</ol>
`;
}
}
@customElement("name-eq-filter")
export class NameEqFilter extends LitElement {
protected override render() {
return html`
<div><slot></slot></div>
`;
}
}

View file

@ -1,21 +0,0 @@
export type Selection = {
id: string;
attr_filters: AttrFilter[];
display_type?: SelectionDisplayType;
label?: string;
visible: boolean;
};
export type AttrFilter = {
NameEq: string;
} | {
NameMatches: string;
} | {
TypeEq: string;
};
export type SelectionDisplayType = "Text" | "InterimUser" | {
Timestamp: {
format: string;
};
};

View file

@ -1,7 +0,0 @@
{
"compilerOptions": {
"lib": ["deno.ns", "dom"],
"experimentalDecorators": true,
"useDefineForClassFields": false
}
}

View file

@ -1,24 +0,0 @@
import { defineConfig } from "vite";
import * as path from "jsr:@std/path";
import "lit";
const entrypointsDir = path.join(import.meta.dirname, "src/entrypoints");
const entry = [...Deno.readDirSync(entrypointsDir)].map(({ name }) =>
path.join(entrypointsDir, name)
);
// https://vitejs.dev/config/
export default defineConfig({
build: {
lib: {
entry,
formats: ["es"],
},
outDir: "../js_dist",
emptyOutDir: true,
rollupOptions: {
// external: /^lit/,
},
},
});

View file

@ -49,26 +49,26 @@ impl Field {
vec![] vec![]
} }
pub fn render(&self, value: &Encodable) -> String { // pub fn render(&self, value: &Encodable) -> String {
match (self.field_type.0.clone(), value) { // match (self.field_type.0.clone(), value) {
(FieldType::Integer, Encodable::Integer(Some(value))) => value.to_string(), // (FieldType::Integer, Encodable::Integer(Some(value))) => value.to_string(),
(FieldType::Integer, Encodable::Integer(None)) => "".to_owned(), // (FieldType::Integer, Encodable::Integer(None)) => "".to_owned(),
(FieldType::Integer, _) => "###".to_owned(), // (FieldType::Integer, _) => "###".to_owned(),
(FieldType::InterimUser, Encodable::Text(value)) => todo!(), // (FieldType::InterimUser, Encodable::Text(value)) => todo!(),
(FieldType::InterimUser, _) => "###".to_owned(), // (FieldType::InterimUser, _) => "###".to_owned(),
(FieldType::Text, Encodable::Text(Some(value))) => value.clone(), // (FieldType::Text, Encodable::Text(Some(value))) => value.clone(),
(FieldType::Text, Encodable::Text(None)) => "".to_owned(), // (FieldType::Text, Encodable::Text(None)) => "".to_owned(),
(FieldType::Text, _) => "###".to_owned(), // (FieldType::Text, _) => "###".to_owned(),
(FieldType::Timestamp { format }, Encodable::Timestamptz(value)) => value // (FieldType::Timestamp { format }, Encodable::Timestamptz(value)) => value
.map(|value| value.format(&format).to_string()) // .map(|value| value.format(&format).to_string())
.unwrap_or("".to_owned()), // .unwrap_or("".to_owned()),
(FieldType::Timestamp { .. }, _) => "###".to_owned(), // (FieldType::Timestamp { .. }, _) => "###".to_owned(),
(FieldType::Uuid, Encodable::Uuid(Some(value))) => value.hyphenated().to_string(), // (FieldType::Uuid, Encodable::Uuid(Some(value))) => value.hyphenated().to_string(),
(FieldType::Uuid, Encodable::Uuid(None)) => "".to_owned(), // (FieldType::Uuid, Encodable::Uuid(None)) => "".to_owned(),
(FieldType::Uuid, _) => "###".to_owned(), // (FieldType::Uuid, _) => "###".to_owned(),
(FieldType::Unknown, _) => "###".to_owned(), // (FieldType::Unknown, _) => "###".to_owned(),
} // }
} // }
pub fn get_value_encodable(&self, row: &PgRow) -> Result<Encodable, ParseError> { pub fn get_value_encodable(&self, row: &PgRow) -> Result<Encodable, ParseError> {
let value_ref = row let value_ref = row
@ -221,4 +221,15 @@ impl Encodable {
Self::Uuid(value) => query.bind(value), Self::Uuid(value) => query.bind(value),
} }
} }
/// Transform the contained value into a serde_json::Value.
pub fn inner_as_value(&self) -> serde_json::Value {
let serialized = serde_json::to_value(self).unwrap();
#[derive(Deserialize)]
struct Tagged {
c: serde_json::Value,
}
let deserialized: Tagged = serde_json::from_value(serialized).unwrap();
deserialized.c
}
} }

View file

@ -9,9 +9,12 @@
</head> </head>
<body> <body>
{% block main %}{% endblock main %} {% block main %}{% endblock main %}
<script type="module" src="{{ settings.root_path }}/js_dist/dev-reloader.mjs"></script>
{% if settings.dev != 0 %} {% if settings.dev != 0 %}
<dev-reloader ws="ws://127.0.0.1:8080{{ settings.root_path }}/__dev-healthz" auto=""></dev-reloader> <script type="module" src="{{ settings.root_path }}/dev_reloader.mjs"></script>
<script type="module">
import { initDevReloader } from "{{ settings.root_path }}/dev_reloader.mjs";
initDevReloader("ws://127.0.0.1:8080{{ settings.root_path }}/__dev-healthz");
</script>
{% endif %} {% endif %}
</body> </body>
</html> </html>

View file

@ -1,11 +1,8 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block main %} {% block main %}
<script type="module" src="{{ settings.root_path }}/js_dist/field_adder_component.mjs"></script> <link rel="stylesheet" href="{{ settings.root_path }}/css_dist/viewer.css">
<script type="module" src="{{ settings.root_path }}/js_dist/viewer_controller_component.mjs"></script> <viewer-controller root-path="{{ settings.root_path }}" n-rows="{{ rows.len() }}" n-columns="{{ fields.len() }}">
<script type="module" src="{{ settings.root_path }}/js_dist/cell_text_component.mjs"></script>
<link rel="stylesheet" href="{{ settings.root_path }}/viewer.css">
<viewer-controller root-path="{{ settings.root_path }}">
<table class="viewer"> <table class="viewer">
<thead> <thead>
<tr> <tr>
@ -20,21 +17,24 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for row in rows %} {% for (i, row) in rows.iter().enumerate() %}
{# TODO: store primary keys in a Vec separate from rows #}
<tr data-pkey="{{ row.pkeys | json }}"> <tr data-pkey="{{ row.pkeys | json }}">
{% for field in fields %} {% for (j, field) in fields.iter().enumerate() %}
<td> {# Setting max-width is required for overflow to work properly. #}
<td style="width: {{ field.width_px }}px; max-width: {{ field.width_px }}px;">
{% match field.get_value_encodable(row.data) %} {% match field.get_value_encodable(row.data) %}
{% when Ok with (encodable) %} {% when Ok with (encodable) %}
<{{ field.webc_tag() | safe }} <{{ field.webc_tag() | safe }}
{% for (k, v) in field.webc_custom_attrs() %} {% for (k, v) in field.webc_custom_attrs() %}
{{ k }}="{{ v }}" {{ k }}="{{ v }}"
{% endfor %} {% endfor %}
column="{{ field.name }}" row="{{ i }}"
value="{{ encodable | json }}" column="{{ j }}"
value="{{ encodable.inner_as_value() | json }}"
class="cell" class="cell"
> >
{{ field.render(encodable) | safe }} {{ encodable.inner_as_value() | json }}
</{{ field.webc_tag() | safe }} </{{ field.webc_tag() | safe }}
{% when Err with (err) %} {% when Err with (err) %}
<span class="pg-value-error">{{ err }}</span> <span class="pg-value-error">{{ err }}</span>
@ -45,5 +45,11 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<viewer-hoverbar root-path="{{ settings.root_path }}"></viewer-hoverbar>
</viewer-controller> </viewer-controller>
<script type="module" src="{{ settings.root_path }}/js_dist/field_adder_component.mjs"></script>
<script type="module" src="{{ settings.root_path }}/js_dist/viewer_controller_component.mjs"></script>
<script type="module" src="{{ settings.root_path }}/js_dist/cell_text_component.mjs"></script>
<script type="module" src="{{ settings.root_path }}/js_dist/cell_uuid_component.mjs"></script>
<script type="module" src="{{ settings.root_path }}/js_dist/viewer_hoverbar_component.mjs"></script>
{% endblock %} {% endblock %}

View file

@ -5,8 +5,11 @@ $button-primary-color: #fff;
$default-border: solid 1px #ccc; $default-border: solid 1px #ccc;
$font-family-default: 'Averia Serif Libre', 'Open Sans', 'Helvetica Neue', Arial, sans-serif; $font-family-default: 'Averia Serif Libre', 'Open Sans', 'Helvetica Neue', Arial, sans-serif;
$font-family-data: 'Funnel Sans', 'Open Sans', 'Helvetica Neue', Arial, sans-serif; $font-family-data: 'Funnel Sans', 'Open Sans', 'Helvetica Neue', Arial, sans-serif;
$font-family-mono: Menlo, 'Courier New', Courier, mono;
$popover-border: $default-border; $popover-border: $default-border;
$popover-shadow: 0 0.5rem 0.5rem #3333; $popover-shadow: 0 0.5rem 0.5rem #3333;
$border-radius-rounded-sm: 0.25rem;
$border-radius-rounded: 0.5rem;
@mixin reset-button { @mixin reset-button {
appearance: none; appearance: none;
@ -45,10 +48,20 @@ $popover-shadow: 0 0.5rem 0.5rem #3333;
} }
} }
@mixin reset-input {
appearance: none;
background: none;
border: none;
box-sizing: border-box;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
}
@mixin rounded-sm { @mixin rounded-sm {
border-radius: 0.25rem; border-radius: $border-radius-rounded-sm;
} }
@mixin rounded { @mixin rounded {
border-radius: 0.5rem; border-radius: $border-radius-rounded;
} }

121
sass/_hoverbar.scss Normal file
View file

@ -0,0 +1,121 @@
@use 'globals';
$background-color: #fff;
$tab-button-color-active: #0001;
.container-positioner {
position: fixed;
bottom: 2rem;
display: flex;
justify-content: center;
align-items: flex-end;
overflow: visible;
width: 100%;
height: 0;
}
.container {
display: grid;
grid-template-columns: max-content max-content 1rem max-content;
filter: drop-shadow(globals.$popover-shadow);
}
.tab-box {
@include globals.rounded;
height: 3rem;
display: flex;
align-items: center;
list-style-type: none;
padding: 0.5rem;
margin: 0;
border: globals.$popover-border;
border-right: none;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
background: $background-color;
grid-row: 2;
&__button {
@include globals.reset-button;
@include globals.rounded-sm;
display: flex;
align-items: center;
padding: 0.25rem 0.5rem;
cursor: pointer;
height: 2rem;
&--active {
background: $tab-button-color-active;
}
}
}
.control-bar {
@include globals.rounded;
height: 3rem;
flex-shrink: 0;
border: globals.$popover-border;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
overflow: hidden;
width: 40rem;
grid-row: 2;
background: $background-color;
&--open {
border-top-right-radius: 0;
}
}
.control-buttons {
height: 3rem;
gird-row: 2;
grid-column: 4;
height: 100%;
}
.control-panel-positioner {
grid-template-columns: subgrid;
grid-column: 2;
grid-row: 1;
position: relative;
overflow: visible;
/* Flexbox positioning is required for Safari */
display: flex;
align-items: flex-end;
anchor-name: --control-bar;
}
.control-panel__container:popover-open {
@include globals.rounded;
inset: unset;
border: globals.$popover-border;
border-bottom: none;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
margin: 0;
position: fixed;
display: block;
width: 40rem;
/* Anchor positioning is required for Chromium */
position-anchor: --control-bar;
position-area: top;
padding: 0;
background: $background-color;
box-shadow: globals.$popover-shadow;
/* Clip drop shadow */
clip-path: polygon(
-100% -100%,
200% -100%,
200% 200%,
100% 200%,
100% 100%,
-100% 100%
);
}
.control-panel {
padding: 0.5rem;
overflow: auto;
max-height: 8rem;
}

34
sass/cells.scss Normal file
View file

@ -0,0 +1,34 @@
@use 'globals';
.cell__container {
display: block;
width: 100%;
height: 100%;
border: solid 2px transparent;
&--selected {
border-color: #07f;
}
}
.cell__content {
font-family: globals.$font-family-data;
max-width: 100%;
&--padded {
padding: 0.25rem;
}
&--null {
opacity: 0.5;
font-style: oblique;
text-align: center;
}
&--uuid {
font-family: globals.$font-family-mono;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}

38
sass/viewer.scss Normal file
View file

@ -0,0 +1,38 @@
table.viewer {
border-collapse: collapse;
height: 1px; /* css hack to make percentage based cell heights work */
}
table.viewer > thead > tr > th {
border: solid 1px #ccc;
border-top: none;
font-family: "Funnel Sans";
background: #0001;
height: 100%; /* css hack to make percentage based cell heights work */
padding: 0 0.5rem;
text-align: left;
&:first-child {
border-left: none;
}
&.column-adder {
border: none;
background: none;
padding: 0;
}
}
table.viewer .padded-cell {
padding: 0.5rem;
}
table.viewer > tbody > tr > td {
border: solid 1px #ccc;
height: 100%; /* css hack to make percentage based cell heights work */
padding: 0;
&:first-child {
border-left: none;
}
}

10
sass/viewer_hoverbar.scss Normal file
View file

@ -0,0 +1,10 @@
@use 'globals';
@use 'hoverbar';
.control-bar__input {
@include globals.reset-input;
width: 100%;
height: 100%;
padding: 0.5rem;
font-family: globals.$font-family-data;
}

81
static/dev_reloader.mjs Normal file
View file

@ -0,0 +1,81 @@
export function initDevReloader(wsAddr, pollIntervalMs = 500) {
// State model is implemented with variables and closures.
let auto = true;
let connected = false;
let socket = undefined;
let initialized = false;
const button = document.createElement("button");
const indicator = document.createElement("div");
const label = document.createElement("div");
// Rolling our own reactivity: call this after state change to initialize or
// update DOM view.
function render() {
button.style.appearance = "none";
button.style.background = "#fff";
button.style.border = "solid 1px #0002";
button.style.borderRadius = "999px";
button.style.padding = "1rem";
button.style.position = "fixed";
button.style.zIndex = "999";
button.style.bottom = "1rem";
button.style.left = "1rem";
button.style.opacity = "0.5";
button.style.boxShadow = "0 0.5rem 1rem #0002";
button.style.display = "flex";
button.style.cursor = "pointer";
button.style.alignItems = "center";
button.style.fontFamily = "sans-serif";
indicator.style.width = "8px";
indicator.style.height = "8px";
indicator.style.borderRadius = "999px";
indicator.style.background = connected ? "#06f" : "#f60";
label.style.marginLeft = "1rem";
label.innerText = auto ? "Disable auto-reload" : "Enable auto-reload";
}
function toggleAuto() {
auto = !auto;
render();
}
function handleDisconnect() {
if (connected || !initialized) {
console.log("dev-reloader: disconnected");
connected = false;
socket = undefined;
render();
const intvl = setInterval(function () {
try {
socket = new WebSocket(wsAddr);
socket.addEventListener("open", function () {
console.log("dev-reloader: connected");
clearInterval(intvl);
if (auto && initialized) {
globalThis.location.reload();
}
connected = true;
initialized = true;
render();
});
socket.addEventListener("close", handleDisconnect);
socket.addEventListener("error", handleDisconnect);
} catch { /* no-op */ }
}, pollIntervalMs);
}
}
render();
button.setAttribute("type", "button");
button.addEventListener("click", toggleAuto);
button.appendChild(indicator);
button.appendChild(label);
document.body.appendChild(button);
// Simulate disconnect event to initialize.
handleDisconnect();
}

View file

@ -18,6 +18,8 @@ gleam_stdlib = ">= 0.44.0 and < 2.0.0"
lustre = ">= 5.2.1 and < 6.0.0" lustre = ">= 5.2.1 and < 6.0.0"
gleam_json = ">= 3.0.2 and < 4.0.0" gleam_json = ">= 3.0.2 and < 4.0.0"
gleam_regexp = ">= 1.1.1 and < 2.0.0" gleam_regexp = ">= 1.1.1 and < 2.0.0"
plinth = ">= 0.7.1 and < 1.0.0"
gleam_javascript = ">= 1.0.0 and < 2.0.0"
[dev-dependencies] [dev-dependencies]
gleeunit = ">= 1.0.0 and < 2.0.0" gleeunit = ">= 1.0.0 and < 2.0.0"

View file

@ -15,6 +15,7 @@ packages = [
{ name = "gleam_erlang", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "F91CE62A2D011FA13341F3723DB7DB118541AAA5FE7311BD2716D018F01EF9E3" }, { name = "gleam_erlang", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "F91CE62A2D011FA13341F3723DB7DB118541AAA5FE7311BD2716D018F01EF9E3" },
{ name = "gleam_http", version = "4.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "DB25DFC8530B64B77105405B80686541A0D96F7E2D83D807D6B2155FB9A8B1B8" }, { name = "gleam_http", version = "4.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "DB25DFC8530B64B77105405B80686541A0D96F7E2D83D807D6B2155FB9A8B1B8" },
{ name = "gleam_httpc", version = "4.1.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C670EBD46FC1472AD5F1F74F1D3938D1D0AC1C7531895ED1D4DDCB6F07279F43" }, { name = "gleam_httpc", version = "4.1.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C670EBD46FC1472AD5F1F74F1D3938D1D0AC1C7531895ED1D4DDCB6F07279F43" },
{ name = "gleam_javascript", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "EF6C77A506F026C6FB37941889477CD5E4234FCD4337FF0E9384E297CB8F97EB" },
{ name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" },
{ name = "gleam_otp", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "7020E652D18F9ABAC9C877270B14160519FA0856EE80126231C505D719AD68DA" }, { name = "gleam_otp", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "7020E652D18F9ABAC9C877270B14160519FA0856EE80126231C505D719AD68DA" },
{ name = "gleam_package_interface", version = "3.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_package_interface", source = "hex", outer_checksum = "8F2D19DE9876D9401BB0626260958A6B1580BB233489C32831FE74CE0ACAE8B4" }, { name = "gleam_package_interface", version = "3.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_package_interface", source = "hex", outer_checksum = "8F2D19DE9876D9401BB0626260958A6B1580BB233489C32831FE74CE0ACAE8B4" },
@ -33,6 +34,7 @@ packages = [
{ name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" },
{ name = "mist", version = "5.0.2", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "0716CE491EA13E1AA1EFEC4B427593F8EB2B953B6EBDEBE41F15BE3D06A22918" }, { name = "mist", version = "5.0.2", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "0716CE491EA13E1AA1EFEC4B427593F8EB2B953B6EBDEBE41F15BE3D06A22918" },
{ name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" },
{ name = "plinth", version = "0.7.1", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_json", "gleam_stdlib"], otp_app = "plinth", source = "hex", outer_checksum = "63BB36AACCCCB82FBE46A862CF85CB88EBE4EF280ECDBAC4B6CB042340B9E1D8" },
{ name = "repeatedly", version = "2.1.2", build_tools = ["gleam"], requirements = [], otp_app = "repeatedly", source = "hex", outer_checksum = "93AE1938DDE0DC0F7034F32C1BF0D4E89ACEBA82198A1FE21F604E849DA5F589" }, { name = "repeatedly", version = "2.1.2", build_tools = ["gleam"], requirements = [], otp_app = "repeatedly", source = "hex", outer_checksum = "93AE1938DDE0DC0F7034F32C1BF0D4E89ACEBA82198A1FE21F604E849DA5F589" },
{ name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" }, { name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" },
{ name = "snag", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "7E9F06390040EB5FAB392CE642771484136F2EC103A92AE11BA898C8167E6E17" }, { name = "snag", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "7E9F06390040EB5FAB392CE642771484136F2EC103A92AE11BA898C8167E6E17" },
@ -43,9 +45,11 @@ packages = [
] ]
[requirements] [requirements]
gleam_javascript = { version = ">= 1.0.0 and < 2.0.0" }
gleam_json = { version = ">= 3.0.2 and < 4.0.0" } gleam_json = { version = ">= 3.0.2 and < 4.0.0" }
gleam_regexp = { version = ">= 1.1.1 and < 2.0.0" } gleam_regexp = { version = ">= 1.1.1 and < 2.0.0" }
gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" }
gleeunit = { version = ">= 1.0.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
lustre = { version = ">= 5.2.1 and < 6.0.0" } lustre = { version = ">= 5.2.1 and < 6.0.0" }
lustre_dev_tools = { version = ">= 1.9.0 and < 2.0.0" } lustre_dev_tools = { version = ">= 1.9.0 and < 2.0.0" }
plinth = { version = ">= 0.7.1 and < 1.0.0" }

136
webc/src/cell_common.gleam Normal file
View file

@ -0,0 +1,136 @@
import gleam/dynamic
import gleam/dynamic/decode
import gleam/int
import gleam/json
import gleam/result
import lustre/attribute as attr
import lustre/component
import lustre/effect.{type Effect}
import lustre/element.{type Element}
import lustre/element/html
import lustre/event
import context
pub fn options_common(
map_msg: fn(CommonMsg) -> msg,
) -> List(component.Option(msg)) {
[
component.on_attribute_change("row", fn(value) {
use value <- result.map(int.parse(value))
ParentChangedRow(value) |> CommonMsg |> map_msg
}),
component.on_attribute_change("column", fn(value) {
use value <- result.map(int.parse(value))
ParentChangedColumn(value) |> CommonMsg |> map_msg
}),
component.on_attribute_change("selected", fn(value) {
ParentChangedSelected(value != "") |> CommonMsg |> map_msg |> Ok
}),
component.on_attribute_change("editing", fn(value) {
ParentChangedEditing(value != "") |> CommonMsg |> map_msg |> Ok
}),
]
}
pub type ModelCommon {
ModelCommon(
root_path: String,
row: Int,
column: Int,
selected: Bool,
editing: Bool,
)
}
pub type Msg {
AncestorChangedRootPath(String)
ParentChangedEditing(Bool)
ParentChangedRow(Int)
ParentChangedColumn(Int)
ParentChangedSelected(Bool)
UserClickedCell
}
pub fn init(_) -> #(ModelCommon, Effect(CommonMsg)) {
#(
ModelCommon(
root_path: "",
selected: False,
editing: False,
row: -1,
column: -1,
),
context.request_context(
context: dynamic.string("root_path"),
subscribe: False,
decoder: decode.string
|> decode.map(fn(value) {
value |> AncestorChangedRootPath |> CommonMsg
}),
),
)
}
pub type CommonMsg {
CommonMsg(msg: Msg)
}
pub fn update(
model: ModelCommon,
msg_common: CommonMsg,
) -> #(ModelCommon, Effect(msg)) {
case msg_common.msg {
AncestorChangedRootPath(root_path) -> #(
ModelCommon(..model, root_path:),
effect.none(),
)
ParentChangedRow(row) -> #(ModelCommon(..model, row:), effect.none())
ParentChangedColumn(column) -> #(
ModelCommon(..model, column:),
effect.none(),
)
ParentChangedEditing(editing) -> #(
ModelCommon(..model, editing:),
effect.none(),
)
ParentChangedSelected(selected) -> #(
ModelCommon(..model, selected:),
effect.none(),
)
UserClickedCell -> #(
model,
event.emit(
"cell-click",
json.object([
#("row", json.int(model.row)),
#("column", json.int(model.column)),
]),
),
)
}
}
pub fn view(
model model: ModelCommon,
map_msg map_msg: fn(CommonMsg) -> msg,
inner inner: fn() -> Element(msg),
) -> Element(msg) {
element.fragment([
html.link([
attr.rel("stylesheet"),
attr.href(model.root_path <> "/css_dist/cells.css"),
]),
html.div(
[
attr.class("cell__container"),
case model.selected {
True -> attr.class("cell__container--selected")
False -> attr.none()
},
event.on_click(CommonMsg(UserClickedCell) |> map_msg),
],
[inner()],
),
])
}

View file

@ -1,5 +1,7 @@
import gleam/dynamic
import gleam/dynamic/decode import gleam/dynamic/decode
import gleam/json
import gleam/option.{type Option}
import gleam/result
import lustre.{type App} import lustre.{type App}
import lustre/attribute as attr import lustre/attribute as attr
import lustre/component import lustre/component
@ -7,7 +9,7 @@ import lustre/effect.{type Effect}
import lustre/element.{type Element} import lustre/element.{type Element}
import lustre/element/html import lustre/element/html
import context import cell_common.{type CommonMsg, type ModelCommon}
pub const name: String = "cell-text" pub const name: String = "cell-text"
@ -16,73 +18,56 @@ pub fn component() -> App(Nil, Model, Msg) {
component.on_attribute_change("value", fn(value) { component.on_attribute_change("value", fn(value) {
ParentChangedValue(value) |> Ok ParentChangedValue(value) |> Ok
}), }),
component.on_attribute_change("column", fn(value) { ..cell_common.options_common(Common)
ParentChangedColumn(value) |> Ok
}),
component.on_attribute_change("selected", fn(value) {
ParentChangedSelected(value != "") |> Ok
}),
component.on_attribute_change("editing", fn(value) {
ParentChangedEditing(value != "") |> Ok
}),
component.on_property_change("pkeys", {
decode.list(of: decode.string) |> decode.map(ParentChangedPkeys)
}),
]) ])
} }
pub type Model { pub type Model {
Model( Model(common: ModelCommon, value: Option(String))
root_path: String,
column: String,
pkeys: List(String),
selected: Bool,
editing: Bool,
)
} }
fn init(_) -> #(Model, Effect(Msg)) { fn init(_) -> #(Model, Effect(Msg)) {
let #(model_common, effect_common) = cell_common.init(Nil)
#( #(
Model( Model(common: model_common, value: option.None),
root_path: "", effect_common
column: "", |> effect.map(fn(effect_common) { Common(effect_common) }),
pkeys: [""],
selected: False,
editing: False,
),
context.request_context(
context: dynamic.string("root_path"),
subscribe: False,
decoder: decode.string |> decode.map(AncestorChangedRootPath),
),
) )
} }
pub type Msg { pub type Msg {
AncestorChangedRootPath(String) Common(CommonMsg)
ParentChangedColumn(String)
ParentChangedEditing(Bool)
ParentChangedValue(String) ParentChangedValue(String)
ParentChangedSelected(Bool)
ParentChangedPkeys(List(String))
} }
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg { case msg {
AncestorChangedRootPath(root_path) -> #( Common(sub_msg) -> {
Model(..model, root_path:), let #(common, effect) = cell_common.update(model.common, sub_msg)
#(Model(..model, common:), effect)
}
ParentChangedValue(value) -> #(
Model(
..model,
value: json.parse(value, decode.optional(decode.string))
|> result.unwrap(option.None),
),
effect.none(), effect.none(),
) )
_ -> #(model, effect.none())
} }
} }
fn view(model: Model) -> Element(Msg) { fn view(model: Model) -> Element(Msg) {
element.fragment([ cell_common.view(model: model.common, map_msg: Common, inner: fn() {
html.link([ html.div(
attr.rel("stylesheet"), [
attr.href(model.root_path <> "/css_dist/cell_text/index.css"), attr.class("cell__content cell__content--padded"),
]), case option.is_none(model.value) {
html.div([], [html.text(model.root_path)]), True -> attr.class("cell__content--null")
]) False -> attr.none()
},
],
[html.text(model.value |> option.unwrap("Null"))],
)
})
} }

View file

@ -0,0 +1,73 @@
import gleam/dynamic/decode
import gleam/json
import gleam/option.{type Option}
import gleam/result
import lustre.{type App}
import lustre/attribute as attr
import lustre/component
import lustre/effect.{type Effect}
import lustre/element.{type Element}
import lustre/element/html
import cell_common.{type CommonMsg, type ModelCommon}
pub const name: String = "cell-uuid"
pub fn component() -> App(Nil, Model, Msg) {
lustre.component(init, update, view, [
component.on_attribute_change("value", fn(value) {
ParentChangedValue(value) |> Ok
}),
..cell_common.options_common(Common)
])
}
pub type Model {
Model(common: ModelCommon, value: Option(String))
}
fn init(_) -> #(Model, Effect(Msg)) {
let #(model_common, effect_common) = cell_common.init(Nil)
#(
Model(common: model_common, value: option.None),
effect_common
|> effect.map(fn(effect_common) { Common(effect_common) }),
)
}
pub type Msg {
Common(CommonMsg)
ParentChangedValue(String)
}
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg {
Common(sub_msg) -> {
let #(common, effect) = cell_common.update(model.common, sub_msg)
#(Model(..model, common:), effect)
}
ParentChangedValue(value) -> #(
Model(
..model,
value: json.parse(value, decode.optional(decode.string))
|> result.unwrap(option.None),
),
effect.none(),
)
}
}
fn view(model: Model) -> Element(Msg) {
cell_common.view(model: model.common, map_msg: Common, inner: fn() {
html.div(
[
attr.class("cell__content cell__content--padded"),
case option.is_none(model.value) {
True -> attr.class("cell__content--null")
False -> attr.class("cell__content--uuid")
},
],
[html.text(model.value |> option.unwrap("Null"))],
)
})
}

View file

@ -17,11 +17,10 @@ pub const name: String = "field-adder"
pub fn component() -> App(Nil, Model, Msg) { pub fn component() -> App(Nil, Model, Msg) {
lustre.component(init, update, view, [ lustre.component(init, update, view, [
component.on_attribute_change("columns", fn(value) { component.on_attribute_change("columns", fn(value) {
Ok( json.parse(from: value, using: decode.list(of: decode.string))
json.parse(from: value, using: decode.list(of: decode.string)) |> result.unwrap([])
|> result.unwrap([]) |> ParentChangedColumns
|> ParentChangedColumns, |> Ok
)
}), }),
component.on_attribute_change("root-path", fn(value) { component.on_attribute_change("root-path", fn(value) {
ParentChangedRootPath(value) |> Ok ParentChangedRootPath(value) |> Ok
@ -118,7 +117,7 @@ fn focus_element(selector: String) -> Effect(Msg) {
do_focus_element(selector:, in: shadow_root) |> result.unwrap(Nil) do_focus_element(selector:, in: shadow_root) |> result.unwrap(Nil)
} }
@external(javascript, "./field_adder.ffi.mjs", "focusElement") @external(javascript, "./field_adder_component.ffi.mjs", "focusElement")
fn do_focus_element( fn do_focus_element(
selector _selector: String, selector _selector: String,
in _root: Dynamic, in _root: Dynamic,

View file

@ -0,0 +1,6 @@
export function showPopover(root) {
const controlPanel = root.querySelector(".control-panel__container");
if (controlPanel) {
controlPanel.showPopover();
}
}

136
webc/src/hoverbar.gleam Normal file
View file

@ -0,0 +1,136 @@
import gleam/dynamic.{type Dynamic}
import gleam/dynamic/decode
import gleam/list
import gleam/option.{type Option, None, Some}
import lustre/attribute as attr
import lustre/effect.{type Effect}
import lustre/element.{type Element}
import lustre/element/html
import lustre/event
pub type Model {
Model(tab_key: String, control_panel_open: Bool)
}
pub type TabItem {
TabItem(icon: String, label: String, key: String)
}
pub fn init(_) -> #(Model, Effect(Msg)) {
#(Model(tab_key: "", control_panel_open: False), effect.none())
}
pub type Msg {
UserClickedControlBar
UserClickedTabBox
UserClickedTab(String)
SomeoneToggledControlPanel(Bool)
}
pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg {
UserClickedControlBar | UserClickedTabBox -> #(model, show_popover())
UserClickedTab(tab_key) -> #(Model(..model, tab_key:), show_popover())
SomeoneToggledControlPanel(control_panel_open) -> #(
Model(..model, control_panel_open:),
effect.none(),
)
}
}
fn show_popover() -> Effect(Msg) {
use _, root <- effect.before_paint
do_show_popover(root)
}
@external(javascript, "./hoverbar.ffi.mjs", "showPopover")
fn do_show_popover(in _root: Dynamic) -> Nil {
Nil
}
pub fn view(
model model: Model,
map_msg map_msg: fn(Msg) -> msg,
tabs tabs: List(TabItem),
control_bar control_bar: fn(String) -> Element(msg),
control_panel control_panel: fn(String) -> Option(Element(msg)),
) -> Element(msg) {
html.div([attr.class("container-positioner")], [
html.div([attr.class("container")], [
tab_box(model, map_msg, tabs),
view_control_bar(model, map_msg, control_bar(model.tab_key)),
view_control_panel(model, map_msg, control_panel(model.tab_key)),
]),
])
}
fn tab_box(
model: Model,
map_msg: fn(Msg) -> msg,
tabs: List(TabItem),
) -> Element(msg) {
html.ul(
[attr.class("tab-box"), event.on_click(UserClickedTabBox |> map_msg)],
tabs
|> list.map(fn(tab) {
html.li([], [
html.button(
[
attr.type_("button"),
attr.class("tab-box__button"),
case model.tab_key == tab.key {
True -> attr.class("tab-box__button--active")
False -> attr.none()
},
event.on_click(UserClickedTab(tab.key) |> map_msg),
],
[html.img([attr.src(tab.icon), attr.alt(tab.label)])],
),
])
}),
)
}
fn view_control_bar(
model: Model,
map_msg: fn(Msg) -> msg,
inner: Element(msg),
) -> Element(msg) {
html.div(
[
attr.class("control-bar"),
case model.control_panel_open {
True -> attr.class("control-bar--open")
False -> attr.none()
},
event.on_click(UserClickedControlBar |> map_msg),
],
[inner],
)
}
fn view_control_panel(
_: Model,
map_msg: fn(Msg) -> msg,
inner: Option(Element(msg)),
) -> Element(msg) {
case inner {
Some(inner_element) ->
html.div([attr.class("control-panel-positioner")], [
html.div(
[
attr.class("control-panel__container"),
attr.popover("auto"),
event.on("toggle", {
use new_state <- decode.field("newState", decode.string)
decode.success(
SomeoneToggledControlPanel(new_state == "open") |> map_msg,
)
}),
],
[html.div([attr.class("control-panel")], [inner_element])],
),
])
None -> element.none()
}
}

View file

@ -0,0 +1,16 @@
export function clearSelectedAttrs() {
document.querySelectorAll(
"table.viewer > tbody > tr > td > [selected='true']",
)
.forEach((element) => element.setAttribute("selected", ""));
}
export function setSelectedAttr(row, column) {
const tr = document.querySelectorAll("table.viewer > tbody > tr")[row];
if (tr) {
const cell = [...tr.querySelectorAll(":scope > td > *")][column];
if (cell) {
cell.setAttribute("selected", "true");
}
}
}

View file

@ -1,9 +1,18 @@
import gleam/dynamic.{type Dynamic} import gleam/dynamic.{type Dynamic}
import gleam/dynamic/decode
import gleam/int
import gleam/io
import gleam/list
import gleam/option.{type Option, Some}
import gleam/result
import lustre.{type App} import lustre.{type App}
import lustre/component import lustre/component
import lustre/effect.{type Effect} import lustre/effect.{type Effect}
import lustre/element.{type Element} import lustre/element.{type Element}
import lustre/element/html import lustre/element/html
import lustre/event
import plinth/browser/document
import plinth/browser/event as plinth_event
import context import context
@ -14,20 +23,61 @@ pub fn component() -> App(Nil, Model, Msg) {
component.on_attribute_change("root-path", fn(value) { component.on_attribute_change("root-path", fn(value) {
ParentChangedRootPath(value) |> Ok ParentChangedRootPath(value) |> Ok
}), }),
component.on_attribute_change("n-rows", fn(value) {
int.parse(value) |> result.map(ParentChangedNRows)
}),
component.on_attribute_change("n-columns", fn(value) {
int.parse(value) |> result.map(ParentChangedNColumns)
}),
component.on_attribute_change("root-path", fn(value) {
ParentChangedRootPath(value) |> Ok
}),
]) ])
} }
pub type Model { pub type Model {
Model(root_path: String, root_path_consumers: List(Dynamic)) Model(
root_path: String,
root_path_consumers: List(Dynamic),
selected_row: Option(Int),
selected_column: Option(Int),
editing: Bool,
n_rows: Int,
n_columns: Int,
)
} }
fn init(_) -> #(Model, Effect(Msg)) { fn init(_) -> #(Model, Effect(Msg)) {
#(Model(root_path: "", root_path_consumers: []), effect.none()) #(
Model(
root_path: "",
root_path_consumers: [],
selected_row: option.None,
selected_column: option.None,
editing: False,
n_rows: -1,
n_columns: -1,
),
effect.before_paint(fn(dispatch, _) -> Nil {
document.add_event_listener("keydown", fn(ev) -> Nil {
let key = plinth_event.key(ev)
case key {
"ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" ->
dispatch(UserPressedDirectionalKey(key))
_ -> Nil
}
})
}),
)
} }
pub type Msg { pub type Msg {
ParentChangedRootPath(String) ParentChangedRootPath(String)
ParentChangedNRows(Int)
ParentChangedNColumns(Int)
ChildRequestedRootPath(Dynamic, Bool) ChildRequestedRootPath(Dynamic, Bool)
UserClickedCell(Int, Int)
UserPressedDirectionalKey(String)
} }
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
@ -39,6 +89,11 @@ fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
dynamic.string(root_path), dynamic.string(root_path),
), ),
) )
ParentChangedNRows(n_rows) -> #(Model(..model, n_rows:), effect.none())
ParentChangedNColumns(n_columns) -> #(
Model(..model, n_columns:),
effect.none(),
)
ChildRequestedRootPath(callback, subscribe) -> #( ChildRequestedRootPath(callback, subscribe) -> #(
case subscribe { case subscribe {
True -> True ->
@ -50,9 +105,87 @@ fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
}, },
context.update_consumers([callback], dynamic.string(model.root_path)), context.update_consumers([callback], dynamic.string(model.root_path)),
) )
UserClickedCell(row, column) -> #(
Model(
..model,
selected_row: option.Some(row),
selected_column: option.Some(column),
),
move_selection(to_row: option.Some(row), to_column: Some(column)),
)
UserPressedDirectionalKey(key) -> {
case model.editing, model.selected_row, model.selected_column {
False, Some(selected_row), Some(selected_column) -> {
let first_row_selected = selected_row == 0
let last_row_selected = selected_row == model.n_rows - 1
let first_col_selected = selected_column == 0
let last_col_selected = selected_column == model.n_columns - 1
case
key,
first_row_selected,
last_row_selected,
first_col_selected,
last_col_selected
{
"ArrowLeft", _, _, False, _ -> #(
Model(..model, selected_column: option.Some(selected_column - 1)),
move_selection(
to_row: model.selected_row,
to_column: Some(selected_column - 1),
),
)
"ArrowRight", _, _, _, False -> #(
Model(..model, selected_column: option.Some(selected_column + 1)),
move_selection(
to_row: model.selected_row,
to_column: Some(selected_column + 1),
),
)
"ArrowUp", False, _, _, _ -> #(
Model(..model, selected_row: option.Some(selected_row - 1)),
move_selection(
to_row: Some(selected_row - 1),
to_column: model.selected_column,
),
)
"ArrowDown", _, False, _, _ -> #(
Model(..model, selected_row: option.Some(selected_row + 1)),
move_selection(
to_row: Some(selected_row + 1),
to_column: model.selected_column,
),
)
_, _, _, _, _ -> #(model, effect.none())
}
}
_, _, _ -> #(model, effect.none())
}
}
} }
} }
fn move_selection(
to_row to_row: Option(Int),
to_column to_column: Option(Int),
) -> Effect(msg) {
use _dispatch, _root <- effect.before_paint()
do_clear_selected_attrs()
case to_row, to_column {
option.Some(row), option.Some(column) -> do_set_selected_attr(row:, column:)
_, _ -> Nil
}
}
@external(javascript, "./viewer_controller_component.ffi.mjs", "clearSelectedAttrs")
fn do_clear_selected_attrs() -> Nil {
Nil
}
@external(javascript, "./viewer_controller_component.ffi.mjs", "setSelectedAttr")
fn do_set_selected_attr(row _row: Int, column _column: Int) -> Nil {
Nil
}
fn view(_: Model) -> Element(Msg) { fn view(_: Model) -> Element(Msg) {
html.div( html.div(
[ [
@ -60,6 +193,14 @@ fn view(_: Model) -> Element(Msg) {
dynamic.string("root_path"), dynamic.string("root_path"),
ChildRequestedRootPath, ChildRequestedRootPath,
), ),
event.on("cell-click", {
use msg <- decode.field("detail", {
use row <- decode.field("row", decode.int)
use column <- decode.field("column", decode.int)
decode.success(UserClickedCell(row, column))
})
decode.success(msg)
}),
], ],
[component.default_slot([], [])], [component.default_slot([], [])],
) )

View file

@ -0,0 +1,112 @@
import gleam/option.{None, Some}
import lustre.{type App}
import lustre/attribute as attr
import lustre/component
import lustre/effect.{type Effect}
import lustre/element.{type Element}
import lustre/element/html
import hoverbar
pub const name: String = "viewer-hoverbar"
pub fn component() -> App(Nil, Model, Msg) {
lustre.component(init, update, view, [
component.on_attribute_change("root-path", fn(value) {
ParentChangedRootPath(value) |> Ok
}),
component.on_attribute_change("field-type", fn(value) {
ParentChangedFieldType(value) |> Ok
}),
component.on_attribute_change("value", fn(value) {
ParentChangedValue(value) |> Ok
}),
])
}
pub type Model {
Model(
hoverbar_model: hoverbar.Model,
root_path: String,
field_type: String,
value: String,
)
}
fn init(_) -> #(Model, Effect(Msg)) {
let #(hoverbar_model, hoverbar_effect) = hoverbar.init(Nil)
#(
Model(
hoverbar_model: hoverbar.Model(..hoverbar_model, tab_key: "editor"),
root_path: "",
field_type: "",
value: "",
),
hoverbar_effect |> effect.map(HoverbarMsg),
)
}
pub type Msg {
HoverbarMsg(hoverbar.Msg)
ParentChangedRootPath(String)
ParentChangedFieldType(String)
ParentChangedValue(String)
}
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg {
HoverbarMsg(hoverbar_msg) -> {
let #(hoverbar_model, hoverbar_effect) =
hoverbar.update(model.hoverbar_model, hoverbar_msg)
#(
Model(..model, hoverbar_model:),
hoverbar_effect |> effect.map(HoverbarMsg),
)
}
ParentChangedRootPath(root_path) -> #(
Model(..model, root_path:),
effect.none(),
)
ParentChangedFieldType(field_type) -> #(
Model(..model, field_type:),
effect.none(),
)
ParentChangedValue(value) -> #(Model(..model, value:), effect.none())
}
}
fn view(model: Model) -> Element(Msg) {
element.fragment([
html.link([
attr.rel("stylesheet"),
attr.href(model.root_path <> "/css_dist/viewer_hoverbar.css"),
]),
hoverbar.view(
model: model.hoverbar_model,
map_msg: HoverbarMsg,
tabs: [
hoverbar.TabItem(
icon: model.root_path <> "/heroicons/16/solid/chart-bar.svg",
label: "Editor",
key: "editor",
),
],
control_bar: fn(tab_key) {
case tab_key {
"editor" -> editor_control_bar(model)
_ -> element.none()
}
},
control_panel: fn(tab_key) {
case tab_key {
"editor" -> None
_ -> None
}
},
),
])
}
fn editor_control_bar(_model: Model) -> Element(Msg) {
html.input([attr.type_("text"), attr.class("control-bar__input")])
}