diff --git a/.gitignore b/.gitignore index ba5cf2d..4ad6c0f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ target .DS_Store node_modules css_dist -glm_dist js_dist pgdata .vite diff --git a/components/deno.json b/components/deno.json deleted file mode 100644 index d491685..0000000 --- a/components/deno.json +++ /dev/null @@ -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" - } -} diff --git a/components/deno.lock b/components/deno.lock deleted file mode 100644 index dfe8b55..0000000 --- a/components/deno.lock +++ /dev/null @@ -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" - ] - } -} diff --git a/components/src/add-selection-button.ts b/components/src/add-selection-button.ts deleted file mode 100644 index f3f542d..0000000 --- a/components/src/add-selection-button.ts +++ /dev/null @@ -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(); - - private _nameInputRef = createRef(); - - private _typePopoverRef = createRef(); - - 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` -
- - - -
-
- -
- `; - } - - return html` - - `; - } -} diff --git a/components/src/add-selection-modal-contents.tsx b/components/src/add-selection-modal-contents.tsx deleted file mode 100644 index e1c45d5..0000000 --- a/components/src/add-selection-modal-contents.tsx +++ /dev/null @@ -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` -
-
- - -
-
-
-
- - - -
-
-
-
- `; - } -} diff --git a/components/src/cell-text.ts b/components/src/cell-text.ts deleted file mode 100644 index 327ddd4..0000000 --- a/components/src/cell-text.ts +++ /dev/null @@ -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(); - - 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` -
- - -
- `; - } else if (this._contents === null) { - inner = html` - NULL - `; - } else { - inner = html` - ${this._contents} - `; - } - - return html` -
- ${inner} -
- `; - } -} diff --git a/components/src/cell-uuid.ts b/components/src/cell-uuid.ts deleted file mode 100644 index c8476b9..0000000 --- a/components/src/cell-uuid.ts +++ /dev/null @@ -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(); - - 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` -
- - -
- `; - } else if (this._contents === null) { - inner = html` - NULL - `; - } else { - inner = html` - ${this._contents} - `; - } - - return html` -
- ${inner} -
- `; - } -} diff --git a/components/src/entrypoints/cells.ts b/components/src/entrypoints/cells.ts deleted file mode 100644 index 6a75df5..0000000 --- a/components/src/entrypoints/cells.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { CellText } from "../cell-text.ts"; -export { CellUuid } from "../cell-uuid.ts"; diff --git a/components/src/entrypoints/custom-icon.ts b/components/src/entrypoints/custom-icon.ts deleted file mode 100644 index 8f4c9c5..0000000 --- a/components/src/entrypoints/custom-icon.ts +++ /dev/null @@ -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` - ${this.alt} - `; - } -} diff --git a/components/src/entrypoints/dev-reloader.ts b/components/src/entrypoints/dev-reloader.ts deleted file mode 100644 index 48abb0d..0000000 --- a/components/src/entrypoints/dev-reloader.ts +++ /dev/null @@ -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` - - `; - } -} diff --git a/components/src/entrypoints/lens-controls.ts b/components/src/entrypoints/lens-controls.ts deleted file mode 100644 index 959167b..0000000 --- a/components/src/entrypoints/lens-controls.ts +++ /dev/null @@ -1 +0,0 @@ -export { LensControls } from "../lens-controls.ts"; diff --git a/components/src/entrypoints/viewer-components.ts b/components/src/entrypoints/viewer-components.ts deleted file mode 100644 index 82acd3d..0000000 --- a/components/src/entrypoints/viewer-components.ts +++ /dev/null @@ -1 +0,0 @@ -export { AddSelectionButton } from "../add-selection-button.ts"; diff --git a/components/src/entrypoints/viewer-controller.ts b/components/src/entrypoints/viewer-controller.ts deleted file mode 100644 index 57f0ad4..0000000 --- a/components/src/entrypoints/viewer-controller.ts +++ /dev/null @@ -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; - }); - } - }); -}); diff --git a/components/src/lens-controls-shell.ts b/components/src/lens-controls-shell.ts deleted file mode 100644 index 435ba67..0000000 --- a/components/src/lens-controls-shell.ts +++ /dev/null @@ -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(); - - 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` -
-
-
    - ${this.tabs.map(({ icon, label, value }) => - html` -
  • - -
  • - ` - )} -
-
- -
-
- -
-
-
-
- -
-
-
-
-
- `; - } -} diff --git a/components/src/lens-controls.ts b/components/src/lens-controls.ts deleted file mode 100644 index 0c18579..0000000 --- a/components/src/lens-controls.ts +++ /dev/null @@ -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(); - - 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` -
- -
- -
-
- -
-
- ${this.selections.map((selection) => - html` -
-
- -
-
- -
-
Text
-
- -
-
- -
-
- ` - )} -
-
-
- `; - } -} diff --git a/components/src/selection-filters.ts b/components/src/selection-filters.ts deleted file mode 100644 index ff659a8..0000000 --- a/components/src/selection-filters.ts +++ /dev/null @@ -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` -
    - ${this.filters.map((filter) => - html` -
  1. - ${"NameEq" in filter - ? html` - ${filter.NameEq} - ` - : undefined} ${"NameMatches" in filter - ? html` - ${filter.NameMatches} - ` - : undefined} ${"TypeEq" in filter - ? html` - ${filter.TypeEq} - ` - : undefined} -
  2. - ` - )} -
- `; - } -} - -@customElement("name-eq-filter") -export class NameEqFilter extends LitElement { - protected override render() { - return html` -
- `; - } -} diff --git a/components/src/selections.ts b/components/src/selections.ts deleted file mode 100644 index 2c42d79..0000000 --- a/components/src/selections.ts +++ /dev/null @@ -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; - }; -}; diff --git a/components/tsconfig.json b/components/tsconfig.json deleted file mode 100644 index e8d5c89..0000000 --- a/components/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "compilerOptions": { - "lib": ["deno.ns", "dom"], - "experimentalDecorators": true, - "useDefineForClassFields": false - } -} diff --git a/components/vite.config.mjs b/components/vite.config.mjs deleted file mode 100644 index b7549a4..0000000 --- a/components/vite.config.mjs +++ /dev/null @@ -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/, - }, - }, -}); diff --git a/interim-models/src/field.rs b/interim-models/src/field.rs index 49d1e10..305ab4c 100644 --- a/interim-models/src/field.rs +++ b/interim-models/src/field.rs @@ -49,26 +49,26 @@ impl Field { vec![] } - pub fn render(&self, value: &Encodable) -> String { - match (self.field_type.0.clone(), value) { - (FieldType::Integer, Encodable::Integer(Some(value))) => value.to_string(), - (FieldType::Integer, Encodable::Integer(None)) => "".to_owned(), - (FieldType::Integer, _) => "###".to_owned(), - (FieldType::InterimUser, Encodable::Text(value)) => todo!(), - (FieldType::InterimUser, _) => "###".to_owned(), - (FieldType::Text, Encodable::Text(Some(value))) => value.clone(), - (FieldType::Text, Encodable::Text(None)) => "".to_owned(), - (FieldType::Text, _) => "###".to_owned(), - (FieldType::Timestamp { format }, Encodable::Timestamptz(value)) => value - .map(|value| value.format(&format).to_string()) - .unwrap_or("".to_owned()), - (FieldType::Timestamp { .. }, _) => "###".to_owned(), - (FieldType::Uuid, Encodable::Uuid(Some(value))) => value.hyphenated().to_string(), - (FieldType::Uuid, Encodable::Uuid(None)) => "".to_owned(), - (FieldType::Uuid, _) => "###".to_owned(), - (FieldType::Unknown, _) => "###".to_owned(), - } - } + // pub fn render(&self, value: &Encodable) -> String { + // match (self.field_type.0.clone(), value) { + // (FieldType::Integer, Encodable::Integer(Some(value))) => value.to_string(), + // (FieldType::Integer, Encodable::Integer(None)) => "".to_owned(), + // (FieldType::Integer, _) => "###".to_owned(), + // (FieldType::InterimUser, Encodable::Text(value)) => todo!(), + // (FieldType::InterimUser, _) => "###".to_owned(), + // (FieldType::Text, Encodable::Text(Some(value))) => value.clone(), + // (FieldType::Text, Encodable::Text(None)) => "".to_owned(), + // (FieldType::Text, _) => "###".to_owned(), + // (FieldType::Timestamp { format }, Encodable::Timestamptz(value)) => value + // .map(|value| value.format(&format).to_string()) + // .unwrap_or("".to_owned()), + // (FieldType::Timestamp { .. }, _) => "###".to_owned(), + // (FieldType::Uuid, Encodable::Uuid(Some(value))) => value.hyphenated().to_string(), + // (FieldType::Uuid, Encodable::Uuid(None)) => "".to_owned(), + // (FieldType::Uuid, _) => "###".to_owned(), + // (FieldType::Unknown, _) => "###".to_owned(), + // } + // } pub fn get_value_encodable(&self, row: &PgRow) -> Result { let value_ref = row @@ -221,4 +221,15 @@ impl Encodable { 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 + } } diff --git a/interim-server/templates/base.html b/interim-server/templates/base.html index 545c588..3a15223 100644 --- a/interim-server/templates/base.html +++ b/interim-server/templates/base.html @@ -9,9 +9,12 @@ {% block main %}{% endblock main %} - {% if settings.dev != 0 %} - + + {% endif %} diff --git a/interim-server/templates/lens.html b/interim-server/templates/lens.html index 8637aa7..d0947b0 100644 --- a/interim-server/templates/lens.html +++ b/interim-server/templates/lens.html @@ -1,11 +1,8 @@ {% extends "base.html" %} {% block main %} - - - - - + + @@ -20,21 +17,24 @@ - {% for row in rows %} + {% for (i, row) in rows.iter().enumerate() %} + {# TODO: store primary keys in a Vec separate from rows #} - {% for field in fields %} -
+ {% for (j, field) in fields.iter().enumerate() %} + {# Setting max-width is required for overflow to work properly. #} + {% match field.get_value_encodable(row.data) %} {% when Ok with (encodable) %} <{{ field.webc_tag() | safe }} {% for (k, v) in field.webc_custom_attrs() %} {{ k }}="{{ v }}" {% endfor %} - column="{{ field.name }}" - value="{{ encodable | json }}" + row="{{ i }}" + column="{{ j }}" + value="{{ encodable.inner_as_value() | json }}" class="cell" > - {{ field.render(encodable) | safe }} + {{ encodable.inner_as_value() | json }} {{ err }} @@ -45,5 +45,11 @@ {% endfor %}
+
+ + + + + {% endblock %} diff --git a/sass/_globals.scss b/sass/_globals.scss index 29b5f1d..10b8a1a 100644 --- a/sass/_globals.scss +++ b/sass/_globals.scss @@ -5,8 +5,11 @@ $button-primary-color: #fff; $default-border: solid 1px #ccc; $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-mono: Menlo, 'Courier New', Courier, mono; $popover-border: $default-border; $popover-shadow: 0 0.5rem 0.5rem #3333; +$border-radius-rounded-sm: 0.25rem; +$border-radius-rounded: 0.5rem; @mixin reset-button { 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 { - border-radius: 0.25rem; + border-radius: $border-radius-rounded-sm; } @mixin rounded { - border-radius: 0.5rem; + border-radius: $border-radius-rounded; } diff --git a/sass/_hoverbar.scss b/sass/_hoverbar.scss new file mode 100644 index 0000000..d7b3a43 --- /dev/null +++ b/sass/_hoverbar.scss @@ -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; +} diff --git a/sass/cells.scss b/sass/cells.scss new file mode 100644 index 0000000..4e81076 --- /dev/null +++ b/sass/cells.scss @@ -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; + } +} diff --git a/sass/viewer.scss b/sass/viewer.scss new file mode 100644 index 0000000..a9aaa80 --- /dev/null +++ b/sass/viewer.scss @@ -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; + } +} diff --git a/sass/viewer_hoverbar.scss b/sass/viewer_hoverbar.scss new file mode 100644 index 0000000..12578eb --- /dev/null +++ b/sass/viewer_hoverbar.scss @@ -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; +} diff --git a/static/dev_reloader.mjs b/static/dev_reloader.mjs new file mode 100644 index 0000000..4bbe5d2 --- /dev/null +++ b/static/dev_reloader.mjs @@ -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(); +} diff --git a/webc/gleam.toml b/webc/gleam.toml index b9673f9..01b5973 100644 --- a/webc/gleam.toml +++ b/webc/gleam.toml @@ -18,6 +18,8 @@ gleam_stdlib = ">= 0.44.0 and < 2.0.0" lustre = ">= 5.2.1 and < 6.0.0" gleam_json = ">= 3.0.2 and < 4.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] gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/webc/manifest.toml b/webc/manifest.toml index 415b6a2..c42a809 100644 --- a/webc/manifest.toml +++ b/webc/manifest.toml @@ -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_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_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_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" }, @@ -33,6 +34,7 @@ packages = [ { 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 = "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 = "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" }, @@ -43,9 +45,11 @@ packages = [ ] [requirements] +gleam_javascript = { version = ">= 1.0.0 and < 2.0.0" } gleam_json = { version = ">= 3.0.2 and < 4.0.0" } gleam_regexp = { version = ">= 1.1.1 and < 2.0.0" } gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } lustre = { version = ">= 5.2.1 and < 6.0.0" } lustre_dev_tools = { version = ">= 1.9.0 and < 2.0.0" } +plinth = { version = ">= 0.7.1 and < 1.0.0" } diff --git a/webc/src/cell_common.gleam b/webc/src/cell_common.gleam new file mode 100644 index 0000000..fae5074 --- /dev/null +++ b/webc/src/cell_common.gleam @@ -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()], + ), + ]) +} diff --git a/webc/src/cell_text_component.gleam b/webc/src/cell_text_component.gleam index d4b9b61..0a133ac 100644 --- a/webc/src/cell_text_component.gleam +++ b/webc/src/cell_text_component.gleam @@ -1,5 +1,7 @@ -import gleam/dynamic 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 @@ -7,7 +9,7 @@ import lustre/effect.{type Effect} import lustre/element.{type Element} import lustre/element/html -import context +import cell_common.{type CommonMsg, type ModelCommon} pub const name: String = "cell-text" @@ -16,73 +18,56 @@ pub fn component() -> App(Nil, Model, Msg) { component.on_attribute_change("value", fn(value) { ParentChangedValue(value) |> Ok }), - component.on_attribute_change("column", fn(value) { - 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) - }), + ..cell_common.options_common(Common) ]) } pub type Model { - Model( - root_path: String, - column: String, - pkeys: List(String), - selected: Bool, - editing: Bool, - ) + Model(common: ModelCommon, value: Option(String)) } fn init(_) -> #(Model, Effect(Msg)) { + let #(model_common, effect_common) = cell_common.init(Nil) #( - Model( - root_path: "", - column: "", - pkeys: [""], - selected: False, - editing: False, - ), - context.request_context( - context: dynamic.string("root_path"), - subscribe: False, - decoder: decode.string |> decode.map(AncestorChangedRootPath), - ), + Model(common: model_common, value: option.None), + effect_common + |> effect.map(fn(effect_common) { Common(effect_common) }), ) } pub type Msg { - AncestorChangedRootPath(String) - ParentChangedColumn(String) - ParentChangedEditing(Bool) + Common(CommonMsg) ParentChangedValue(String) - ParentChangedSelected(Bool) - ParentChangedPkeys(List(String)) } fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { case msg { - AncestorChangedRootPath(root_path) -> #( - Model(..model, root_path:), + 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(), ) - _ -> #(model, effect.none()) } } fn view(model: Model) -> Element(Msg) { - element.fragment([ - html.link([ - attr.rel("stylesheet"), - attr.href(model.root_path <> "/css_dist/cell_text/index.css"), - ]), - html.div([], [html.text(model.root_path)]), - ]) + 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.none() + }, + ], + [html.text(model.value |> option.unwrap("Null"))], + ) + }) } diff --git a/webc/src/cell_uuid_component.gleam b/webc/src/cell_uuid_component.gleam new file mode 100644 index 0000000..d7a9197 --- /dev/null +++ b/webc/src/cell_uuid_component.gleam @@ -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"))], + ) + }) +} diff --git a/webc/src/field_adder.ffi.mjs b/webc/src/field_adder_component.ffi.mjs similarity index 100% rename from webc/src/field_adder.ffi.mjs rename to webc/src/field_adder_component.ffi.mjs diff --git a/webc/src/field_adder_component.gleam b/webc/src/field_adder_component.gleam index 2b20482..67c1601 100644 --- a/webc/src/field_adder_component.gleam +++ b/webc/src/field_adder_component.gleam @@ -17,11 +17,10 @@ pub const name: String = "field-adder" pub fn component() -> App(Nil, Model, Msg) { lustre.component(init, update, view, [ component.on_attribute_change("columns", fn(value) { - Ok( - json.parse(from: value, using: decode.list(of: decode.string)) - |> result.unwrap([]) - |> ParentChangedColumns, - ) + json.parse(from: value, using: decode.list(of: decode.string)) + |> result.unwrap([]) + |> ParentChangedColumns + |> Ok }), component.on_attribute_change("root-path", fn(value) { ParentChangedRootPath(value) |> Ok @@ -118,7 +117,7 @@ fn focus_element(selector: String) -> Effect(Msg) { 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( selector _selector: String, in _root: Dynamic, diff --git a/webc/src/hoverbar.ffi.mjs b/webc/src/hoverbar.ffi.mjs new file mode 100644 index 0000000..46f6ece --- /dev/null +++ b/webc/src/hoverbar.ffi.mjs @@ -0,0 +1,6 @@ +export function showPopover(root) { + const controlPanel = root.querySelector(".control-panel__container"); + if (controlPanel) { + controlPanel.showPopover(); + } +} diff --git a/webc/src/hoverbar.gleam b/webc/src/hoverbar.gleam new file mode 100644 index 0000000..c0bc237 --- /dev/null +++ b/webc/src/hoverbar.gleam @@ -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() + } +} diff --git a/webc/src/viewer_controller_component.ffi.mjs b/webc/src/viewer_controller_component.ffi.mjs new file mode 100644 index 0000000..3c06064 --- /dev/null +++ b/webc/src/viewer_controller_component.ffi.mjs @@ -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"); + } + } +} diff --git a/webc/src/viewer_controller_component.gleam b/webc/src/viewer_controller_component.gleam index 6ef6df8..e57d983 100644 --- a/webc/src/viewer_controller_component.gleam +++ b/webc/src/viewer_controller_component.gleam @@ -1,9 +1,18 @@ 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/component import lustre/effect.{type Effect} import lustre/element.{type Element} import lustre/element/html +import lustre/event +import plinth/browser/document +import plinth/browser/event as plinth_event import context @@ -14,20 +23,61 @@ pub fn component() -> App(Nil, Model, Msg) { component.on_attribute_change("root-path", fn(value) { 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 { - 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)) { - #(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 { ParentChangedRootPath(String) + ParentChangedNRows(Int) + ParentChangedNColumns(Int) ChildRequestedRootPath(Dynamic, Bool) + UserClickedCell(Int, Int) + UserPressedDirectionalKey(String) } 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), ), ) + ParentChangedNRows(n_rows) -> #(Model(..model, n_rows:), effect.none()) + ParentChangedNColumns(n_columns) -> #( + Model(..model, n_columns:), + effect.none(), + ) ChildRequestedRootPath(callback, subscribe) -> #( case subscribe { True -> @@ -50,9 +105,87 @@ fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { }, 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) { html.div( [ @@ -60,6 +193,14 @@ fn view(_: Model) -> Element(Msg) { dynamic.string("root_path"), 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([], [])], ) diff --git a/webc/src/viewer_hoverbar_component.gleam b/webc/src/viewer_hoverbar_component.gleam new file mode 100644 index 0000000..0c31306 --- /dev/null +++ b/webc/src/viewer_hoverbar_component.gleam @@ -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")]) +}