selection creation
This commit is contained in:
parent
10959e6c2b
commit
e8d017314b
89 changed files with 2238 additions and 337 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,3 +4,4 @@ target
|
|||
node_modules
|
||||
js_dist
|
||||
pgdata
|
||||
.vite
|
||||
|
|
|
|||
123
Cargo.lock
generated
123
Cargo.lock
generated
|
|
@ -215,7 +215,7 @@ dependencies = [
|
|||
"chrono",
|
||||
"hmac 0.11.0",
|
||||
"log",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.9.9",
|
||||
|
|
@ -261,6 +261,7 @@ checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5"
|
|||
dependencies = [
|
||||
"axum-core",
|
||||
"axum-macros",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
|
|
@ -280,8 +281,10 @@ dependencies = [
|
|||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sha1",
|
||||
"sync_wrapper 1.0.2",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
|
|
@ -776,6 +779,12 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "575f75dfd25738df5b91b8e43e14d44bda14637a58fae779fd2b064f8bf3e010"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
|
|
@ -1624,7 +1633,30 @@ dependencies = [
|
|||
]
|
||||
|
||||
[[package]]
|
||||
name = "interim"
|
||||
name = "interim-models"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"derive_builder",
|
||||
"interim-pgtypes",
|
||||
"regex",
|
||||
"serde",
|
||||
"sqlx",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "interim-pgtypes"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"derive_builder",
|
||||
"regex",
|
||||
"serde",
|
||||
"sqlx",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "interim-server"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
|
|
@ -1638,10 +1670,13 @@ dependencies = [
|
|||
"derive_builder",
|
||||
"dotenvy",
|
||||
"futures",
|
||||
"interim-models",
|
||||
"interim-pgtypes",
|
||||
"nom 8.0.0",
|
||||
"oauth2",
|
||||
"percent-encoding",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
"regex",
|
||||
"reqwest 0.12.15",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -1885,7 +1920,7 @@ dependencies = [
|
|||
"num-integer",
|
||||
"num-iter",
|
||||
"num-traits",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
"smallvec",
|
||||
"zeroize",
|
||||
]
|
||||
|
|
@ -1936,7 +1971,7 @@ dependencies = [
|
|||
"chrono",
|
||||
"getrandom 0.2.16",
|
||||
"http 0.2.12",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
"reqwest 0.11.27",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -2238,8 +2273,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2249,7 +2294,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2261,6 +2316,15 @@ dependencies = [
|
|||
"getrandom 0.2.16",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
|
||||
dependencies = [
|
||||
"getrandom 0.3.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.12"
|
||||
|
|
@ -2438,7 +2502,7 @@ dependencies = [
|
|||
"num-traits",
|
||||
"pkcs1",
|
||||
"pkcs8",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
"signature",
|
||||
"spki",
|
||||
"subtle",
|
||||
|
|
@ -2781,7 +2845,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2950,7 +3014,7 @@ dependencies = [
|
|||
"memchr",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
"rsa",
|
||||
"serde",
|
||||
"sha1",
|
||||
|
|
@ -2990,7 +3054,7 @@ dependencies = [
|
|||
"md-5",
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
|
|
@ -3335,6 +3399,18 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.26.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.15"
|
||||
|
|
@ -3513,6 +3589,23 @@ version = "0.2.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.26.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http 1.3.1",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.1",
|
||||
"sha1",
|
||||
"thiserror 2.0.12",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.18.0"
|
||||
|
|
@ -3582,6 +3675,12 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
|
|
|
|||
50
Cargo.toml
50
Cargo.toml
|
|
@ -1,45 +1,27 @@
|
|||
[package]
|
||||
name = "interim"
|
||||
version = "0.0.1"
|
||||
edition = "2021"
|
||||
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
resolver = "3"
|
||||
members = ["interim-*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.0.1"
|
||||
edition = "2024"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = { version = "1.0.91", features = ["backtrace"] }
|
||||
chrono = { version = "0.4.41", features = ["serde"] }
|
||||
derive_builder = "0.20.2"
|
||||
futures = "0.3.31"
|
||||
interim-models = { path = "./interim-models" }
|
||||
interim-pgtypes = { path = "./interim-pgtypes" }
|
||||
interim-server = { path = "./interim-server" }
|
||||
rand = "0.8.5"
|
||||
regex = "1.11.1"
|
||||
reqwest = { version = "0.12.8", features = ["json"] }
|
||||
serde = { version = "1.0.213", features = ["derive"] }
|
||||
sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls-ring-native-roots", "postgres", "derive", "uuid", "chrono", "json", "macros"] }
|
||||
uuid = { version = "1.11.0", features = ["serde", "v4", "v7"] }
|
||||
tokio = { version = "1.42.0", features = ["full"] }
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
askama = { version = "0.14.0", features = ["urlencode"] }
|
||||
async-session = "3.0.0"
|
||||
axum = { version = "0.8.1", features = ["macros"] }
|
||||
axum-extra = { version = "0.10.0", features = ["cookie", "form", "typed-header"] }
|
||||
chrono = { workspace = true }
|
||||
clap = { version = "4.5.31", features = ["derive"] }
|
||||
config = "0.14.1"
|
||||
derive_builder = "0.20.2"
|
||||
dotenvy = "0.15.7"
|
||||
futures = "0.3.31"
|
||||
nom = "8.0.0"
|
||||
oauth2 = "4.4.2"
|
||||
percent-encoding = "2.3.1"
|
||||
rand = "0.8.5"
|
||||
reqwest = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = "1.0.132"
|
||||
sqlx = { workspace = true }
|
||||
sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls-ring-native-roots", "postgres", "derive", "uuid", "chrono", "json", "macros"] }
|
||||
thiserror = "2.0.12"
|
||||
tokio = { workspace = true }
|
||||
tower = "0.5.2"
|
||||
tower-http = { version = "0.6.2", features = ["compression-gzip", "fs", "normalize-path", "set-header", "trace"] }
|
||||
tokio = { version = "1.42.0", features = ["full"] }
|
||||
tracing = "0.1.40"
|
||||
tracing-subscriber = { version = "0.3.19", features = ["chrono", "env-filter"] }
|
||||
uuid = { workspace = true }
|
||||
uuid = { version = "1.11.0", features = ["serde", "v4", "v7"] }
|
||||
validator = { version = "0.20.0", features = ["derive"] }
|
||||
|
|
|
|||
90
bacon.toml
90
bacon.toml
|
|
@ -1,89 +1,24 @@
|
|||
# This is a configuration file for the bacon tool
|
||||
#
|
||||
# Complete help on configuration: https://dystroy.org/bacon/config/
|
||||
#
|
||||
# You may check the current default at
|
||||
# https://github.com/Canop/bacon/blob/main/defaults/default-bacon.toml
|
||||
|
||||
default_job = "check"
|
||||
env.CARGO_TERM_COLOR = "always"
|
||||
|
||||
[jobs.clippy]
|
||||
command = ["cargo", "clippy"]
|
||||
need_stdout = false
|
||||
watch = ["interim-*"]
|
||||
|
||||
[jobs.check]
|
||||
command = ["cargo", "check"]
|
||||
need_stdout = false
|
||||
|
||||
[jobs.check-all]
|
||||
command = ["cargo", "check", "--all-targets"]
|
||||
need_stdout = false
|
||||
|
||||
# Run clippy on the default target
|
||||
[jobs.clippy]
|
||||
command = ["cargo", "clippy"]
|
||||
need_stdout = false
|
||||
|
||||
# Run clippy on all targets
|
||||
# To disable some lints, you may change the job this way:
|
||||
# [jobs.clippy-all]
|
||||
# command = [
|
||||
# "cargo", "clippy",
|
||||
# "--all-targets",
|
||||
# "--",
|
||||
# "-A", "clippy::bool_to_int_with_if",
|
||||
# "-A", "clippy::collapsible_if",
|
||||
# "-A", "clippy::derive_partial_eq_without_eq",
|
||||
# ]
|
||||
# need_stdout = false
|
||||
[jobs.clippy-all]
|
||||
command = ["cargo", "clippy", "--all-targets"]
|
||||
need_stdout = false
|
||||
|
||||
# This job lets you run
|
||||
# - all tests: bacon test
|
||||
# - a specific test: bacon test -- config::test_default_files
|
||||
# - the tests of a package: bacon test -- -- -p config
|
||||
[jobs.test]
|
||||
command = ["cargo", "test"]
|
||||
need_stdout = true
|
||||
|
||||
[jobs.nextest]
|
||||
command = [
|
||||
"cargo", "nextest", "run",
|
||||
"--hide-progress-bar", "--failure-output", "final"
|
||||
]
|
||||
need_stdout = true
|
||||
analyzer = "nextest"
|
||||
|
||||
[jobs.doc]
|
||||
command = ["cargo", "doc", "--no-deps"]
|
||||
need_stdout = false
|
||||
|
||||
# If the doc compiles, then it opens in your browser and bacon switches
|
||||
# to the previous job
|
||||
[jobs.doc-open]
|
||||
command = ["cargo", "doc", "--no-deps", "--open"]
|
||||
need_stdout = false
|
||||
on_success = "back" # so that we don't open the browser at each change
|
||||
|
||||
# You can run your application and have the result displayed in bacon,
|
||||
# if it makes sense for this crate.
|
||||
[jobs.run-worker]
|
||||
command = [
|
||||
"cargo", "run", "worker",
|
||||
# put launch parameters for your program behind a `--` separator
|
||||
]
|
||||
need_stdout = true
|
||||
allow_warnings = true
|
||||
background = true
|
||||
default_watch = false
|
||||
|
||||
# Run your long-running application (eg server) and have the result displayed in bacon.
|
||||
# For programs that never stop (eg a server), `background` is set to false
|
||||
# to have the cargo run output immediately displayed instead of waiting for
|
||||
# program's end.
|
||||
# 'on_change_strategy' is set to `kill_then_restart` to have your program restart
|
||||
# on every change (an alternative would be to use the 'F5' key manually in bacon).
|
||||
# If you often use this job, it makes sense to override the 'r' key by adding
|
||||
# a binding `r = job:run-long` at the end of this file .
|
||||
[jobs.run-server]
|
||||
command = ["cargo", "run", "serve"]
|
||||
need_stdout = true
|
||||
|
|
@ -91,21 +26,22 @@ allow_warnings = true
|
|||
background = false
|
||||
on_change_strategy = "kill_then_restart"
|
||||
kill = ["kill", "-s", "INT"]
|
||||
watch = ["src", "templates"]
|
||||
watch = ["interim-*", "components/src"]
|
||||
|
||||
# This parameterized job runs the example of your choice, as soon
|
||||
# as the code compiles.
|
||||
# Call it as
|
||||
# bacon ex -- my-example
|
||||
[jobs.ex]
|
||||
command = ["cargo", "run", "--example"]
|
||||
[jobs.watch-components]
|
||||
command = ["deno", "task", "--cwd=components", "build"]
|
||||
default_watch = false
|
||||
watch = ["components/src"]
|
||||
need_stdout = true
|
||||
allow_warnings = true
|
||||
background = false
|
||||
|
||||
# You may define here keybindings that would be specific to
|
||||
# a project, for example a shortcut to launch a specific job.
|
||||
# Shortcuts to internal functions (scrolling, toggling, etc.)
|
||||
# should go in your personal global prefs.toml file instead.
|
||||
[keybindings]
|
||||
b = "job:watch-components"
|
||||
c = "job:clippy"
|
||||
r = "job:run-server"
|
||||
w = "job:run-worker"
|
||||
|
|
|
|||
6
components/deno.lock
generated
6
components/deno.lock
generated
|
|
@ -1,11 +1,17 @@
|
|||
{
|
||||
"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==",
|
||||
|
|
|
|||
58
components/src/add-selection-button.tsx
Normal file
58
components/src/add-selection-button.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { createRef, type Ref, 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 = "";
|
||||
|
||||
private _dialogRef: Ref<HTMLDialogElement> = createRef();
|
||||
|
||||
static override styles = css`
|
||||
button.th {
|
||||
appearance: none;
|
||||
border: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-weight: inherit;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
}
|
||||
|
||||
dialog {
|
||||
border: solid 1px #ccc;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 0.5rem 0.5rem #3333;
|
||||
font-family:
|
||||
"Averia Serif Libre",
|
||||
"Open Sans",
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
background: #0001;
|
||||
}
|
||||
`;
|
||||
|
||||
showModal() {
|
||||
this._dialogRef.value?.showModal();
|
||||
}
|
||||
|
||||
protected override render() {
|
||||
return html`
|
||||
<button type="button" class="th" @click="${this.showModal}">+</button>
|
||||
<dialog ${ref(this._dialogRef)} closedby="any">
|
||||
<add-selection-modal-content
|
||||
columns="${this.columns}"
|
||||
></add-selection-modal-content>
|
||||
</dialog>
|
||||
`;
|
||||
}
|
||||
}
|
||||
88
components/src/add-selection-modal-contents.tsx
Normal file
88
components/src/add-selection-modal-contents.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { CellText } from "./cell-text.ts";
|
||||
export { CellUuid } from "./cell-uuid.ts";
|
||||
2
components/src/entrypoints/cells.ts
Normal file
2
components/src/entrypoints/cells.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { CellText } from "../cell-text.ts";
|
||||
export { CellUuid } from "../cell-uuid.ts";
|
||||
32
components/src/entrypoints/custom-icon.ts
Normal file
32
components/src/entrypoints/custom-icon.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
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}">
|
||||
`;
|
||||
}
|
||||
}
|
||||
118
components/src/entrypoints/dev-reloader.ts
Normal file
118
components/src/entrypoints/dev-reloader.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
1
components/src/entrypoints/lens-controls.ts
Normal file
1
components/src/entrypoints/lens-controls.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { LensControls } from "../lens-controls.ts";
|
||||
1
components/src/entrypoints/viewer-components.tsx
Normal file
1
components/src/entrypoints/viewer-components.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { AddSelectionButton } from "../add-selection-button.tsx";
|
||||
214
components/src/lens-controls-shell.ts
Normal file
214
components/src/lens-controls-shell.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
191
components/src/lens-controls.ts
Normal file
191
components/src/lens-controls.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
259
components/src/lens-controls.ts.bak
Normal file
259
components/src/lens-controls.ts.bak
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
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 { type Selection } from "./selections.ts";
|
||||
|
||||
export { SelectionFilters } from "./selection-filters.ts";
|
||||
|
||||
@customElement("lens-controls")
|
||||
export class LensControls extends LitElement {
|
||||
@property({ attribute: true, type: Array })
|
||||
selections: Selection[] = [];
|
||||
|
||||
@state()
|
||||
private _isOpen = false;
|
||||
|
||||
private _controlPanelDialog = 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;
|
||||
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;
|
||||
}
|
||||
|
||||
& 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);
|
||||
display: flex;
|
||||
justify-content: stretch;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
width: 40rem;
|
||||
grid-row: 2;
|
||||
|
||||
& input {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: inherit;
|
||||
font-family: "Funnel Sans";
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
&.open {
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
#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: 1rem;
|
||||
overflow: auto;
|
||||
max-height: 8rem;
|
||||
}
|
||||
|
||||
.selections {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr 1fr;
|
||||
}
|
||||
|
||||
.selection {
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
justify-content: start;
|
||||
align-items: center;
|
||||
grid-gap: 1rem;
|
||||
padding: 0.25rem 0;
|
||||
|
||||
& .visibility {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
& .selection-filters {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
& .conversions {
|
||||
grid-column: 3;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
open(): void {
|
||||
this._controlPanelDialog.value?.showPopover();
|
||||
}
|
||||
|
||||
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`
|
||||
<div id="container-positioner">
|
||||
<div id="container">
|
||||
<ul id="tab-box">
|
||||
<li>
|
||||
<button type="button">Filters</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" class="active">Columns</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div id="control-bar" class="${classMap({ open: this._isOpen })}">
|
||||
<input
|
||||
type="text"
|
||||
id="control-bar-input"
|
||||
placeholder="2 of 2 shown"
|
||||
@click="${this.open}"
|
||||
@keydown="${this.open}"
|
||||
>
|
||||
</div>
|
||||
<div id="control-panel-positioner">
|
||||
<div
|
||||
id="control-panel-container"
|
||||
popover="auto"
|
||||
${ref(
|
||||
this._controlPanelDialog,
|
||||
)}
|
||||
@toggle="${(ev: ToggleEvent) => {
|
||||
this._isOpen = ev.newState === "open";
|
||||
}}"
|
||||
>
|
||||
<div id="control-panel">
|
||||
<div id="selections">
|
||||
${this.selections.map((selection) =>
|
||||
html`
|
||||
<div class="selection">
|
||||
<div class="visibility">
|
||||
<input
|
||||
type="checkbox"
|
||||
?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 class="conversions"></div>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
28
components/src/sel-item.ts
Normal file
28
components/src/sel-item.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
@customElement("sel-item")
|
||||
export class SelItem extends LitElement {
|
||||
@property({ attribute: "display-type", type: Object, reflect: true })
|
||||
displayType?: unknown;
|
||||
|
||||
@property({ attribute: true, type: Boolean, reflect: true })
|
||||
visible!: boolean;
|
||||
|
||||
@property({ attribute: true, type: String, reflect: true })
|
||||
label?: string;
|
||||
|
||||
private _handleDelete() {
|
||||
this.dispatchEvent(
|
||||
new Event("sel-item-deleted", { bubbles: true, composed: true }),
|
||||
);
|
||||
}
|
||||
|
||||
protected override render() {
|
||||
return html`
|
||||
<div class="sel-item">
|
||||
<button class="remove" @click="${this._handleDelete}">del</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
57
components/src/selection-filters.ts
Normal file
57
components/src/selection-filters.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
21
components/src/selections.ts
Normal file
21
components/src/selections.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
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;
|
||||
};
|
||||
};
|
||||
|
|
@ -1,12 +1,18 @@
|
|||
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: ["src/cells.ts"],
|
||||
entry,
|
||||
formats: ["es"],
|
||||
},
|
||||
outDir: "../js_dist",
|
||||
|
|
|
|||
12
interim-models/Cargo.toml
Normal file
12
interim-models/Cargo.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
[package]
|
||||
name = "interim-models"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
derive_builder = { workspace = true }
|
||||
interim-pgtypes = { path = "../interim-pgtypes" }
|
||||
regex = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
3
interim-models/migrations/20250528233517_lenses.down.sql
Normal file
3
interim-models/migrations/20250528233517_lenses.down.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
drop table if exists lens_selections;
|
||||
drop table if exists lenses;
|
||||
drop type if exists lens_display_type;
|
||||
21
interim-models/migrations/20250528233517_lenses.up.sql
Normal file
21
interim-models/migrations/20250528233517_lenses.up.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
create type lens_display_type as enum ('table');
|
||||
|
||||
create table if not exists lenses (
|
||||
id uuid not null primary key,
|
||||
name text not null,
|
||||
base_id uuid not null references bases(id) on delete cascade,
|
||||
class_oid oid not null,
|
||||
filter jsonb not null default '{}'::jsonb,
|
||||
order_by jsonb not null default '[]'::jsonb,
|
||||
display_type lens_display_type not null default 'table'
|
||||
);
|
||||
create index on lenses (base_id);
|
||||
|
||||
create table if not exists lens_selections (
|
||||
id uuid not null primary key,
|
||||
lens_id uuid not null references lenses(id) on delete cascade,
|
||||
attr_filters jsonb not null default '[]'::jsonb,
|
||||
label text,
|
||||
display_type text,
|
||||
visible boolean not null default true
|
||||
);
|
||||
129
interim-models/src/lens.rs
Normal file
129
interim-models/src/lens.rs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
use derive_builder::Builder;
|
||||
use serde::Serialize;
|
||||
use sqlx::{PgExecutor, postgres::types::Oid, query_as};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::selection::{AttrFilter, Selection, SelectionDisplayType};
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Lens {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub base_id: Uuid,
|
||||
pub class_oid: Oid,
|
||||
pub display_type: LensDisplayType,
|
||||
}
|
||||
|
||||
impl Lens {
|
||||
pub fn insertable_builder() -> InsertableLensBuilder {
|
||||
InsertableLensBuilder::default()
|
||||
}
|
||||
|
||||
pub async fn fetch_by_id<'a, E: PgExecutor<'a>>(
|
||||
id: Uuid,
|
||||
app_db: E,
|
||||
) -> Result<Option<Self>, sqlx::Error> {
|
||||
query_as!(
|
||||
Self,
|
||||
r#"
|
||||
select
|
||||
id,
|
||||
name,
|
||||
base_id,
|
||||
class_oid,
|
||||
display_type as "display_type: LensDisplayType"
|
||||
from lenses
|
||||
where id = $1
|
||||
"#,
|
||||
id
|
||||
)
|
||||
.fetch_optional(app_db)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn fetch_by_rel<'a, E: PgExecutor<'a>>(
|
||||
base_id: Uuid,
|
||||
rel_oid: Oid,
|
||||
app_db: E,
|
||||
) -> Result<Vec<Self>, sqlx::Error> {
|
||||
query_as!(
|
||||
Self,
|
||||
r#"
|
||||
select
|
||||
id,
|
||||
name,
|
||||
base_id,
|
||||
class_oid,
|
||||
display_type as "display_type: LensDisplayType"
|
||||
from lenses
|
||||
where base_id = $1 and class_oid = $2
|
||||
"#,
|
||||
base_id,
|
||||
rel_oid
|
||||
)
|
||||
.fetch_all(app_db)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn fetch_selections<'a, E: PgExecutor<'a>>(
|
||||
&self,
|
||||
app_db: E,
|
||||
) -> Result<Vec<Selection>, sqlx::Error> {
|
||||
query_as!(
|
||||
Selection,
|
||||
r#"
|
||||
select
|
||||
id,
|
||||
attr_filters as "attr_filters: sqlx::types::Json<Vec<AttrFilter>>",
|
||||
label,
|
||||
display_type as "display_type: SelectionDisplayType",
|
||||
visible
|
||||
from lens_selections
|
||||
where lens_id = $1
|
||||
"#,
|
||||
self.id
|
||||
)
|
||||
.fetch_all(app_db)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "lens_display_type", rename_all = "lowercase")]
|
||||
pub enum LensDisplayType {
|
||||
Table,
|
||||
}
|
||||
|
||||
#[derive(Builder, Clone, Debug)]
|
||||
pub struct InsertableLens {
|
||||
name: String,
|
||||
base_id: Uuid,
|
||||
class_oid: Oid,
|
||||
display_type: LensDisplayType,
|
||||
}
|
||||
|
||||
impl InsertableLens {
|
||||
pub async fn insert<'a, E: PgExecutor<'a>>(self, app_db: E) -> Result<Lens, sqlx::Error> {
|
||||
query_as!(
|
||||
Lens,
|
||||
r#"
|
||||
insert into lenses
|
||||
(id, base_id, class_oid, name, display_type)
|
||||
values ($1, $2, $3, $4, $5)
|
||||
returning
|
||||
id,
|
||||
name,
|
||||
base_id,
|
||||
class_oid,
|
||||
display_type as "display_type: LensDisplayType"
|
||||
"#,
|
||||
Uuid::now_v7(),
|
||||
self.base_id,
|
||||
self.class_oid,
|
||||
self.name,
|
||||
self.display_type as LensDisplayType
|
||||
)
|
||||
.fetch_one(app_db)
|
||||
.await
|
||||
}
|
||||
}
|
||||
4
interim-models/src/lib.rs
Normal file
4
interim-models/src/lib.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pub mod lens;
|
||||
pub mod selection;
|
||||
|
||||
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!();
|
||||
116
interim-models/src/selection.rs
Normal file
116
interim-models/src/selection.rs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
use derive_builder::Builder;
|
||||
use interim_pgtypes::pg_attribute::PgAttribute;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{PgExecutor, query_as};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Selection {
|
||||
pub id: Uuid,
|
||||
pub attr_filters: sqlx::types::Json<Vec<AttrFilter>>,
|
||||
pub label: Option<String>,
|
||||
pub display_type: Option<SelectionDisplayType>,
|
||||
pub visible: bool,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
pub fn insertable_builder() -> InsertableSelectionBuilder {
|
||||
InsertableSelectionBuilder::default()
|
||||
}
|
||||
|
||||
pub fn resolve_fields_from_attrs(&self, all_attrs: &[PgAttribute]) -> Vec<Field> {
|
||||
if self.visible {
|
||||
let mut filtered_attrs = all_attrs.to_owned();
|
||||
for attr_filter in self.attr_filters.0.clone() {
|
||||
filtered_attrs.retain(|attr| attr_filter.matches(attr));
|
||||
}
|
||||
filtered_attrs
|
||||
.into_iter()
|
||||
.map(|attr| Field {
|
||||
name: attr.attname.clone(),
|
||||
label: self.label.clone(),
|
||||
display_type: self.display_type.clone(),
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "TEXT")]
|
||||
#[sqlx(rename_all = "lowercase")]
|
||||
pub enum SelectionDisplayType {
|
||||
Text,
|
||||
InterimUser,
|
||||
Timestamp,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub enum AttrFilter {
|
||||
NameEq(String),
|
||||
NameMatches(String),
|
||||
TypeEq(String),
|
||||
}
|
||||
|
||||
impl AttrFilter {
|
||||
pub fn matches(&self, attr: &PgAttribute) -> bool {
|
||||
match self {
|
||||
Self::NameEq(name) => &attr.attname == name,
|
||||
Self::NameMatches(pattern) => Regex::new(pattern)
|
||||
.map(|re| re.is_match(&attr.attname))
|
||||
.unwrap_or(false),
|
||||
Self::TypeEq(_) => todo!("attr type filter is not yet implemented"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single column which can be passed to a front-end viewer. A Selection may
|
||||
/// resolve to zero or more Fields.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Field {
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub display_type: Option<SelectionDisplayType>,
|
||||
}
|
||||
|
||||
#[derive(Builder, Clone, Debug)]
|
||||
pub struct InsertableSelection {
|
||||
lens_id: Uuid,
|
||||
attr_filters: Vec<AttrFilter>,
|
||||
#[builder(default, setter(strip_option))]
|
||||
label: Option<String>,
|
||||
#[builder(default, setter(strip_option))]
|
||||
display_type: Option<SelectionDisplayType>,
|
||||
#[builder(default = true)]
|
||||
visible: bool,
|
||||
}
|
||||
|
||||
impl InsertableSelection {
|
||||
pub async fn insert<'a, E: PgExecutor<'a>>(self, app_db: E) -> Result<Selection, sqlx::Error> {
|
||||
query_as!(
|
||||
Selection,
|
||||
r#"
|
||||
insert into lens_selections
|
||||
(id, lens_id, attr_filters, label, display_type, visible)
|
||||
values ($1, $2, $3, $4, $5, $6)
|
||||
returning
|
||||
id,
|
||||
attr_filters as "attr_filters: sqlx::types::Json<Vec<AttrFilter>>",
|
||||
label,
|
||||
display_type as "display_type: SelectionDisplayType",
|
||||
visible
|
||||
"#,
|
||||
Uuid::now_v7(),
|
||||
self.lens_id,
|
||||
sqlx::types::Json::<_>(self.attr_filters) as sqlx::types::Json<Vec<AttrFilter>>,
|
||||
self.label,
|
||||
self.display_type as Option<SelectionDisplayType>,
|
||||
self.visible,
|
||||
)
|
||||
.fetch_one(app_db)
|
||||
.await
|
||||
}
|
||||
}
|
||||
11
interim-pgtypes/Cargo.toml
Normal file
11
interim-pgtypes/Cargo.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[package]
|
||||
name = "interim-pgtypes"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
derive_builder = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
1
interim-pgtypes/src/lib.rs
Normal file
1
interim-pgtypes/src/lib.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod pg_attribute;
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
use sqlx::{postgres::types::Oid, query_as, PgExecutor};
|
||||
use serde::Serialize;
|
||||
use sqlx::{PgExecutor, postgres::types::Oid, query_as};
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct PgAttribute {
|
||||
/// The table this column belongs to
|
||||
pub attrelid: Oid,
|
||||
36
interim-server/Cargo.toml
Normal file
36
interim-server/Cargo.toml
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
[package]
|
||||
name = "interim-server"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
askama = { version = "0.14.0", features = ["serde_json", "urlencode"] }
|
||||
async-session = "3.0.0"
|
||||
axum = { version = "0.8.1", features = ["macros", "ws"] }
|
||||
axum-extra = { version = "0.10.0", features = ["cookie", "form", "typed-header"] }
|
||||
chrono = { workspace = true }
|
||||
clap = { version = "4.5.31", features = ["derive"] }
|
||||
config = "0.14.1"
|
||||
derive_builder = { workspace = true }
|
||||
dotenvy = "0.15.7"
|
||||
futures = { workspace = true }
|
||||
interim-models = { workspace = true }
|
||||
interim-pgtypes = { workspace = true }
|
||||
nom = "8.0.0"
|
||||
oauth2 = "4.4.2"
|
||||
percent-encoding = "2.3.1"
|
||||
rand = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true}
|
||||
sqlx = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tower = "0.5.2"
|
||||
tower-http = { version = "0.6.2", features = ["compression-gzip", "fs", "normalize-path", "set-header", "trace"] }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { version = "0.3.19", features = ["chrono", "env-filter"] }
|
||||
uuid = { workspace = true }
|
||||
validator = { workspace = true }
|
||||
|
|
@ -40,7 +40,7 @@ pub fn new_oauth_client(settings: &Settings) -> Result<BasicClient> {
|
|||
.set_redirect_uri(
|
||||
RedirectUrl::new(format!(
|
||||
"{}{}/auth/callback",
|
||||
settings.frontend_host, settings.base_path
|
||||
settings.frontend_host, settings.root_path
|
||||
))
|
||||
.context("failed to create new redirection URL")?,
|
||||
))
|
||||
|
|
@ -59,7 +59,7 @@ async fn start_login(
|
|||
State(state): State<AppState>,
|
||||
State(Settings {
|
||||
auth: auth_settings,
|
||||
base_path,
|
||||
root_path,
|
||||
..
|
||||
}): State<Settings>,
|
||||
State(session_store): State<PgStore>,
|
||||
|
|
@ -74,7 +74,7 @@ async fn start_login(
|
|||
|
||||
if session.get::<AuthInfo>(SESSION_KEY_AUTH_INFO).is_some() {
|
||||
tracing::debug!("already logged in, redirecting...");
|
||||
return Ok(Redirect::to(&format!("{}/", base_path)).into_response());
|
||||
return Ok(Redirect::to(&format!("{}/", root_path)).into_response());
|
||||
}
|
||||
assert!(session.get_raw(SESSION_KEY_AUTH_REFRESH_TOKEN).is_none());
|
||||
|
||||
|
|
@ -99,7 +99,7 @@ async fn start_login(
|
|||
/// HTTP get handler for /logout
|
||||
async fn logout(
|
||||
State(Settings {
|
||||
base_path,
|
||||
root_path,
|
||||
auth: auth_settings,
|
||||
..
|
||||
}): State<Settings>,
|
||||
|
|
@ -134,7 +134,7 @@ async fn logout(
|
|||
}
|
||||
let jar = jar.remove(Cookie::from(auth_settings.cookie_name));
|
||||
tracing::debug!("Removed session cookie from jar.");
|
||||
Ok((jar, Redirect::to(&format!("{}/", base_path))))
|
||||
Ok((jar, Redirect::to(&format!("{}/", root_path))))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -150,7 +150,7 @@ async fn callback(
|
|||
State(state): State<AppState>,
|
||||
State(Settings {
|
||||
auth: auth_settings,
|
||||
base_path,
|
||||
root_path,
|
||||
..
|
||||
}): State<Settings>,
|
||||
State(ReqwestClient(reqwest_client)): State<ReqwestClient>,
|
||||
|
|
@ -205,7 +205,7 @@ async fn callback(
|
|||
}
|
||||
tracing::debug!("successfully authenticated");
|
||||
Ok(Redirect::to(
|
||||
&redirect_target.unwrap_or(format!("{}/", base_path)),
|
||||
&redirect_target.unwrap_or(format!("{}/", root_path)),
|
||||
))
|
||||
}
|
||||
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
use std::net::SocketAddr;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::{
|
||||
extract::Request,
|
||||
|
|
@ -10,8 +12,7 @@ use clap::{Parser, Subcommand};
|
|||
use tokio::time::sleep;
|
||||
use tower::ServiceBuilder;
|
||||
use tower_http::{
|
||||
compression::CompressionLayer, normalize_path::NormalizePathLayer,
|
||||
set_header::response::SetResponseHeaderLayer, trace::TraceLayer,
|
||||
compression::CompressionLayer, set_header::response::SetResponseHeaderLayer, trace::TraceLayer,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
|
|
@ -51,7 +52,6 @@ pub async fn serve_command(state: AppState) -> Result<()> {
|
|||
CONTENT_SECURITY_POLICY,
|
||||
HeaderValue::from_static("frame-ancestors 'none'"),
|
||||
))
|
||||
.layer(NormalizePathLayer::trim_trailing_slash())
|
||||
.service(new_router(state.clone()));
|
||||
|
||||
let listener =
|
||||
|
|
@ -62,10 +62,13 @@ pub async fn serve_command(state: AppState) -> Result<()> {
|
|||
"App running at http://{}:{}{}",
|
||||
state.settings.host,
|
||||
state.settings.port,
|
||||
state.settings.base_path
|
||||
state.settings.root_path
|
||||
);
|
||||
|
||||
axum::serve(listener, ServiceExt::<Request>::into_make_service(router))
|
||||
axum::serve(
|
||||
listener,
|
||||
ServiceExt::<Request>::into_make_service_with_connect_info::<SocketAddr>(router),
|
||||
)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
|
@ -2,14 +2,16 @@ use std::fmt::Display;
|
|||
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use derive_builder::Builder;
|
||||
use interim_models::selection::SelectionDisplayType;
|
||||
use sqlx::{
|
||||
ColumnIndex, Decode, Postgres, Row as _, TypeInfo as _, ValueRef as _,
|
||||
error::BoxDynError,
|
||||
postgres::{PgRow, PgTypeInfo, PgValueRef},
|
||||
ColumnIndex, Decode, Postgres, Row as _, TypeInfo as _, ValueRef as _,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
const DEFAULT_TIMESTAMP_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%.f%:z";
|
||||
|
||||
pub enum Value {
|
||||
Text(Option<String>),
|
||||
Integer(Option<i32>),
|
||||
|
|
@ -17,29 +19,11 @@ pub enum Value {
|
|||
Uuid(Option<Uuid>),
|
||||
}
|
||||
|
||||
#[derive(Builder)]
|
||||
#[builder(pattern = "owned", setter(prefix = "with"))]
|
||||
pub struct FieldOptions {
|
||||
/// Format with which to render timestamptz values
|
||||
#[builder(default = "\"%Y-%m-%dT%H:%M:%S%.f%:z\".to_owned()")]
|
||||
pub date_format: String,
|
||||
|
||||
/// If some, treat text column like an enum
|
||||
#[builder(default)]
|
||||
pub select_options: Option<Vec<String>>,
|
||||
|
||||
/// Text to display in place of actual column name
|
||||
#[builder(default)]
|
||||
pub label: Option<String>,
|
||||
|
||||
#[builder(default = "true")]
|
||||
pub editable: bool,
|
||||
}
|
||||
|
||||
pub trait ToHtmlString {
|
||||
fn to_html_string(&self, options: &FieldOptions) -> String;
|
||||
fn to_html_string(&self, display_type: &Option<SelectionDisplayType>) -> String;
|
||||
}
|
||||
|
||||
// TODO rewrite with thiserror
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FromSqlError {
|
||||
message: String,
|
||||
|
|
@ -72,7 +56,7 @@ impl Value {
|
|||
}
|
||||
|
||||
impl ToHtmlString for Value {
|
||||
fn to_html_string(&self, options: &FieldOptions) -> String {
|
||||
fn to_html_string(&self, display_type: &Option<SelectionDisplayType>) -> String {
|
||||
macro_rules! cell_html {
|
||||
($component:expr, $value:expr$(, $attr_name:expr => $attr_val:expr)*) => {
|
||||
{
|
||||
|
|
@ -97,7 +81,7 @@ impl ToHtmlString for Value {
|
|||
Self::Timestamptz(value) => cell_html!(
|
||||
"cell-timestamptz",
|
||||
value,
|
||||
"format" => options.date_format
|
||||
"format" => DEFAULT_TIMESTAMP_FORMAT
|
||||
),
|
||||
Self::Uuid(value) => cell_html!("cell-uuid", value),
|
||||
}
|
||||
|
|
@ -142,12 +126,3 @@ impl<'a> Decode<'a, Postgres> for Value {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Lens {
|
||||
pub fields: Vec<Field>,
|
||||
}
|
||||
|
||||
pub struct Field {
|
||||
pub options: FieldOptions,
|
||||
pub name: String,
|
||||
}
|
||||
72
interim-server/src/lenses.rs
Normal file
72
interim-server/src/lenses.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
use derive_builder::Builder;
|
||||
use interim_pgtypes::pg_attribute::PgAttribute;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{PgExecutor, postgres::types::Oid, query_as};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Selection {
|
||||
pub id: Uuid,
|
||||
pub attr_filters: sqlx::types::Json<Vec<AttrFilter>>,
|
||||
pub label: Option<String>,
|
||||
pub display_type: Option<SelectionDisplayType>,
|
||||
pub visible: bool,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
pub fn resolve_fields_from_attrs(&self, all_attrs: &[PgAttribute]) -> Vec<Field> {
|
||||
if self.visible {
|
||||
let mut filtered_attrs = all_attrs.to_owned();
|
||||
for attr_filter in self.attr_filters.0.clone() {
|
||||
filtered_attrs.retain(|attr| attr_filter.matches(attr));
|
||||
}
|
||||
filtered_attrs
|
||||
.into_iter()
|
||||
.map(|attr| Field {
|
||||
name: attr.attname.clone(),
|
||||
label: self.label.clone(),
|
||||
display_type: self.display_type.clone(),
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, sqlx::Type)]
|
||||
#[sqlx(rename_all = "lowercase")]
|
||||
pub enum SelectionDisplayType {
|
||||
Text,
|
||||
InterimUser,
|
||||
Timestamp,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub enum AttrFilter {
|
||||
NameEq(String),
|
||||
NameMatches(String),
|
||||
TypeEq(String),
|
||||
}
|
||||
|
||||
impl AttrFilter {
|
||||
pub fn matches(&self, attr: &PgAttribute) -> bool {
|
||||
match self {
|
||||
Self::NameEq(name) => &attr.attname == name,
|
||||
Self::NameMatches(pattern) => Regex::new(pattern)
|
||||
.map(|re| re.is_match(&attr.attname))
|
||||
.unwrap_or(false),
|
||||
Self::TypeEq(_) => todo!("attr type filter is not yet implemented"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single column which can be passed to a front-end viewer. A Selection may
|
||||
/// resolve to zero or more Fields.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Field {
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub display_type: Option<SelectionDisplayType>,
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
use clap::Parser as _;
|
||||
use dotenvy::dotenv;
|
||||
use interim_models::MIGRATOR;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use crate::{
|
||||
app_state::{App, AppState},
|
||||
cli::{serve_command, worker_command, Cli, Commands},
|
||||
cli::{Cli, Commands, serve_command, worker_command},
|
||||
settings::Settings,
|
||||
};
|
||||
|
||||
|
|
@ -17,9 +18,9 @@ mod bases;
|
|||
mod cli;
|
||||
mod data_layer;
|
||||
mod db_conns;
|
||||
mod lenses;
|
||||
mod middleware;
|
||||
mod pg_acls;
|
||||
mod pg_attributes;
|
||||
mod pg_classes;
|
||||
mod pg_databases;
|
||||
mod pg_roles;
|
||||
|
|
@ -44,8 +45,8 @@ async fn main() {
|
|||
|
||||
let state: AppState = App::from_settings(settings.clone()).await.unwrap().into();
|
||||
|
||||
if settings.run_database_migrations == Some(1) {
|
||||
sqlx::migrate!().run(&state.app_db).await.unwrap();
|
||||
if settings.run_database_migrations != 0 {
|
||||
MIGRATOR.run(&state.app_db).await.unwrap();
|
||||
}
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
|
@ -1,8 +1,13 @@
|
|||
use std::net::SocketAddr;
|
||||
|
||||
use axum::{
|
||||
extract::{ws::WebSocket, ConnectInfo, WebSocketUpgrade},
|
||||
http::{header::CACHE_CONTROL, HeaderValue},
|
||||
routing::{get, post},
|
||||
response::Response,
|
||||
routing::{any, get, post},
|
||||
Router,
|
||||
};
|
||||
use axum_extra::routing::RouterExt as _;
|
||||
use tower::ServiceBuilder;
|
||||
use tower_http::{
|
||||
services::{ServeDir, ServeFile},
|
||||
|
|
@ -12,38 +17,67 @@ use tower_http::{
|
|||
use crate::{app_state::AppState, auth, routes};
|
||||
|
||||
pub fn new_router(state: AppState) -> Router<()> {
|
||||
let base_path = state.settings.base_path.clone();
|
||||
let base_path = state.settings.root_path.clone();
|
||||
let app = Router::new()
|
||||
.route("/databases", get(routes::bases::list_bases_page))
|
||||
.route("/databases/add", post(routes::bases::add_base_page))
|
||||
.route(
|
||||
"/d/{base_id}/config",
|
||||
.route_with_tsr("/databases/", get(routes::bases::list_bases_page))
|
||||
.route_with_tsr("/databases/add/", post(routes::bases::add_base_page))
|
||||
.route_with_tsr(
|
||||
"/d/{base_id}/config/",
|
||||
get(routes::bases::base_config_page_get),
|
||||
)
|
||||
.route(
|
||||
"/d/{base_id}/config",
|
||||
"/d/{base_id}/config/",
|
||||
post(routes::bases::base_config_page_post),
|
||||
)
|
||||
.route(
|
||||
"/d/{base_id}/relations",
|
||||
.route_with_tsr(
|
||||
"/d/{base_id}/relations/",
|
||||
get(routes::relations::list_relations_page),
|
||||
)
|
||||
.route(
|
||||
"/d/{base_id}/r/{class_oid}/rbac",
|
||||
.route_with_tsr(
|
||||
"/d/{base_id}/r/{class_oid}/",
|
||||
get(routes::relations::rel_index_page),
|
||||
)
|
||||
.route_with_tsr(
|
||||
"/d/{base_id}/r/{class_oid}/rbac/",
|
||||
get(routes::relations::rel_rbac_page),
|
||||
)
|
||||
.route(
|
||||
"/d/{base_id}/r/{class_oid}/rbac/invite",
|
||||
.route_with_tsr(
|
||||
"/d/{base_id}/r/{class_oid}/rbac/invite/",
|
||||
get(routes::relations::rel_rbac_invite_page_get),
|
||||
)
|
||||
.route(
|
||||
"/d/{base_id}/r/{class_oid}/rbac/invite",
|
||||
post(routes::relations::rel_rbac_invite_page_post),
|
||||
)
|
||||
.route(
|
||||
"/d/{base_id}/r/{class_oid}/viewer",
|
||||
get(routes::relations::viewer_page),
|
||||
.route_with_tsr(
|
||||
"/d/{base_id}/r/{class_oid}/lenses/",
|
||||
get(routes::lenses::lenses_page),
|
||||
)
|
||||
.route_with_tsr(
|
||||
"/d/{base_id}/r/{class_oid}/lenses/add/",
|
||||
get(routes::lenses::add_lens_page_get),
|
||||
)
|
||||
.route(
|
||||
"/d/{base_id}/r/{class_oid}/lenses/add",
|
||||
post(routes::lenses::add_lens_page_post),
|
||||
)
|
||||
.route_with_tsr(
|
||||
"/d/{base_id}/r/{class_oid}/l/{lens_id}/",
|
||||
get(routes::lenses::lens_page),
|
||||
)
|
||||
.route(
|
||||
"/d/{base_id}/r/{class_oid}/l/{lens_id}/update-lens",
|
||||
post(routes::lenses::update_lens_page_post),
|
||||
)
|
||||
.route(
|
||||
"/d/{base_id}/r/{class_oid}/l/{lens_id}/add-selection",
|
||||
post(routes::lenses::add_selection_page_post),
|
||||
)
|
||||
.route_with_tsr(
|
||||
"/d/{base_id}/r/{class_oid}/l/{lens_id}/viewer/",
|
||||
get(routes::lenses::viewer_page),
|
||||
)
|
||||
.route("/__dev-healthz", any(dev_healthz_handler))
|
||||
.nest("/auth", auth::new_router())
|
||||
.layer(SetResponseHeaderLayer::if_not_present(
|
||||
CACHE_CONTROL,
|
||||
|
|
@ -54,7 +88,9 @@ pub fn new_router(state: AppState) -> Router<()> {
|
|||
ServiceBuilder::new()
|
||||
.layer(SetResponseHeaderLayer::if_not_present(
|
||||
CACHE_CONTROL,
|
||||
HeaderValue::from_static("max-age=21600, stale-while-revalidate=86400"),
|
||||
// FIXME: restore production value
|
||||
// HeaderValue::from_static("max-age=21600, stale-while-revalidate=86400"),
|
||||
HeaderValue::from_static("no-cache"),
|
||||
))
|
||||
.service(
|
||||
ServeDir::new("js_dist").not_found_service(
|
||||
|
|
@ -94,6 +130,19 @@ pub fn new_router(state: AppState) -> Router<()> {
|
|||
}
|
||||
}
|
||||
|
||||
async fn dev_healthz_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
) -> Response {
|
||||
tracing::info!("{addr} connected");
|
||||
ws.on_upgrade(move |socket| handle_dev_healthz_socket(socket, addr))
|
||||
}
|
||||
|
||||
async fn handle_dev_healthz_socket(mut socket: WebSocket, _: SocketAddr) {
|
||||
// Keep socket open indefinitely until the entire server exits
|
||||
while let Some(Ok(_)) = socket.recv().await {}
|
||||
}
|
||||
|
||||
// #[derive(Deserialize)]
|
||||
// struct RbacIndexPath {
|
||||
// oid: u32,
|
||||
|
|
@ -16,14 +16,12 @@ use crate::{
|
|||
base_user_perms::sync_perms_for_base,
|
||||
bases::Base,
|
||||
db_conns::{escape_identifier, init_role},
|
||||
pg_databases::PgDatabase,
|
||||
pg_roles::PgRole,
|
||||
settings::Settings,
|
||||
users::CurrentUser,
|
||||
};
|
||||
|
||||
pub async fn list_bases_page(
|
||||
State(Settings { base_path, .. }): State<Settings>,
|
||||
State(settings): State<Settings>,
|
||||
AppDbConn(mut app_db): AppDbConn,
|
||||
CurrentUser(current_user): CurrentUser,
|
||||
) -> Result<Response, AppError> {
|
||||
|
|
@ -33,14 +31,14 @@ pub async fn list_bases_page(
|
|||
#[derive(Template)]
|
||||
#[template(path = "list_bases.html")]
|
||||
struct ResponseTemplate {
|
||||
base_path: String,
|
||||
bases: Vec<Base>,
|
||||
settings: Settings,
|
||||
}
|
||||
Ok(Html(ResponseTemplate { base_path, bases }.render()?).into_response())
|
||||
Ok(Html(ResponseTemplate { bases, settings }.render()?).into_response())
|
||||
}
|
||||
|
||||
pub async fn add_base_page(
|
||||
State(Settings { base_path, .. }): State<Settings>,
|
||||
State(settings): State<Settings>,
|
||||
AppDbConn(mut app_db): AppDbConn,
|
||||
CurrentUser(current_user): CurrentUser,
|
||||
) -> Result<Response, AppError> {
|
||||
|
|
@ -62,7 +60,7 @@ values ($1, $2, $3, 'configure')",
|
|||
)
|
||||
.execute(&mut *app_db)
|
||||
.await?;
|
||||
Ok(Redirect::to(&format!("{}/d/{}/config", base_path, base.id)).into_response())
|
||||
Ok(Redirect::to(&format!("{}/d/{}/config", settings.root_path, base.id)).into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
@ -71,7 +69,7 @@ pub struct BaseConfigPagePath {
|
|||
}
|
||||
|
||||
pub async fn base_config_page_get(
|
||||
State(Settings { base_path, .. }): State<Settings>,
|
||||
State(settings): State<Settings>,
|
||||
AppDbConn(mut app_db): AppDbConn,
|
||||
CurrentUser(current_user): CurrentUser,
|
||||
Path(params): Path<BaseConfigPagePath>,
|
||||
|
|
@ -84,9 +82,9 @@ pub async fn base_config_page_get(
|
|||
#[template(path = "base_config.html")]
|
||||
struct ResponseTemplate {
|
||||
base: Base,
|
||||
base_path: String,
|
||||
settings: Settings,
|
||||
}
|
||||
Ok(Html(ResponseTemplate { base, base_path }.render()?).into_response())
|
||||
Ok(Html(ResponseTemplate { base, settings }.render()?).into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
@ -96,7 +94,7 @@ pub struct BaseConfigPageForm {
|
|||
}
|
||||
|
||||
pub async fn base_config_page_post(
|
||||
State(Settings { base_path, .. }): State<Settings>,
|
||||
State(settings): State<Settings>,
|
||||
State(mut base_pooler): State<BasePooler>,
|
||||
AppDbConn(mut app_db): AppDbConn,
|
||||
CurrentUser(current_user): CurrentUser,
|
||||
|
|
@ -139,5 +137,5 @@ pub async fn base_config_page_post(
|
|||
.await?;
|
||||
sync_perms_for_base(base.id, &mut app_db, &mut client).await?;
|
||||
}
|
||||
Ok(Redirect::to(&format!("{}/d/{}/config", base_path, base_id)).into_response())
|
||||
Ok(Redirect::to(&format!("{}/d/{}/config", settings.root_path, base_id)).into_response())
|
||||
}
|
||||
283
interim-server/src/routes/lenses.rs
Normal file
283
interim-server/src/routes/lenses.rs
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
response::{Html, IntoResponse as _, Redirect, Response},
|
||||
};
|
||||
use axum_extra::extract::Form;
|
||||
use interim_models::{
|
||||
lens::{Lens, LensDisplayType},
|
||||
selection::{AttrFilter, Field, Selection},
|
||||
};
|
||||
use interim_pgtypes::pg_attribute::{PgAttribute, fetch_attributes_for_rel};
|
||||
use serde::Deserialize;
|
||||
use sqlx::{
|
||||
postgres::{PgRow, types::Oid},
|
||||
query,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
app_error::{AppError, not_found},
|
||||
app_state::AppDbConn,
|
||||
base_pooler::BasePooler,
|
||||
bases::Base,
|
||||
data_layer::{ToHtmlString as _, Value},
|
||||
db_conns::{escape_identifier, init_role},
|
||||
settings::Settings,
|
||||
users::CurrentUser,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LensesPagePath {
|
||||
base_id: Uuid,
|
||||
class_oid: u32,
|
||||
}
|
||||
|
||||
pub async fn lenses_page(
|
||||
State(settings): State<Settings>,
|
||||
AppDbConn(mut app_db): AppDbConn,
|
||||
Path(LensesPagePath { base_id, class_oid }): Path<LensesPagePath>,
|
||||
) -> Result<Response, AppError> {
|
||||
// FIXME auth
|
||||
let lenses = Lens::fetch_by_rel(base_id, Oid(class_oid), &mut *app_db).await?;
|
||||
#[derive(Template)]
|
||||
#[template(path = "lenses.html")]
|
||||
struct ResponseTemplate {
|
||||
base_id: Uuid,
|
||||
class_oid: u32,
|
||||
lenses: Vec<Lens>,
|
||||
settings: Settings,
|
||||
}
|
||||
|
||||
Ok(Html(
|
||||
ResponseTemplate {
|
||||
base_id,
|
||||
class_oid,
|
||||
lenses,
|
||||
settings,
|
||||
}
|
||||
.render()?,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub async fn add_lens_page_get(
|
||||
State(settings): State<Settings>,
|
||||
AppDbConn(mut app_db): AppDbConn,
|
||||
Path(LensesPagePath { base_id, class_oid }): Path<LensesPagePath>,
|
||||
) -> Result<Response, AppError> {
|
||||
// FIXME auth
|
||||
#[derive(Template)]
|
||||
#[template(path = "add_lens.html")]
|
||||
struct ResponseTemplate {
|
||||
base_id: Uuid,
|
||||
class_oid: u32,
|
||||
settings: Settings,
|
||||
}
|
||||
|
||||
Ok(Html(
|
||||
ResponseTemplate {
|
||||
base_id,
|
||||
class_oid,
|
||||
settings,
|
||||
}
|
||||
.render()?,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AddLensPagePostForm {
|
||||
name: String,
|
||||
}
|
||||
|
||||
pub async fn add_lens_page_post(
|
||||
State(Settings { root_path, .. }): State<Settings>,
|
||||
AppDbConn(mut app_db): AppDbConn,
|
||||
Path(LensesPagePath { base_id, class_oid }): Path<LensesPagePath>,
|
||||
Form(AddLensPagePostForm { name }): Form<AddLensPagePostForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
// FIXME auth
|
||||
// FIXME csrf
|
||||
let lens = Lens::insertable_builder()
|
||||
.base_id(base_id)
|
||||
.class_oid(Oid(class_oid))
|
||||
.name(name)
|
||||
.display_type(LensDisplayType::Table)
|
||||
.build()?
|
||||
.insert(&mut *app_db)
|
||||
.await?;
|
||||
Ok(Redirect::to(&format!(
|
||||
"{root_path}/d/{0}/r/{class_oid}/l/{1}",
|
||||
base_id.simple(),
|
||||
lens.id.simple()
|
||||
))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LensPagePath {
|
||||
base_id: Uuid,
|
||||
class_oid: u32,
|
||||
lens_id: Uuid,
|
||||
}
|
||||
|
||||
pub async fn lens_page(
|
||||
State(settings): State<Settings>,
|
||||
State(mut base_pooler): State<BasePooler>,
|
||||
AppDbConn(mut app_db): AppDbConn,
|
||||
CurrentUser(current_user): CurrentUser,
|
||||
Path(LensPagePath {
|
||||
base_id,
|
||||
class_oid,
|
||||
lens_id,
|
||||
}): Path<LensPagePath>,
|
||||
) -> Result<Response, AppError> {
|
||||
// FIXME auth
|
||||
let base = Base::fetch_by_id(base_id, &mut *app_db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound("no base found with that id".to_owned()))?;
|
||||
let mut client = base_pooler.acquire_for(base_id).await?;
|
||||
|
||||
init_role(
|
||||
&format!("{}{}", &base.user_role_prefix, ¤t_user.id.simple()),
|
||||
&mut client,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// FIXME auth
|
||||
|
||||
let class = query!(
|
||||
"select relname from pg_class where oid = $1",
|
||||
Oid(class_oid)
|
||||
)
|
||||
.fetch_optional(&mut *client)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound(
|
||||
"no relation found with that oid".to_owned(),
|
||||
))?;
|
||||
|
||||
let lens = Lens::fetch_by_id(lens_id, &mut *app_db)
|
||||
.await?
|
||||
.ok_or(not_found!("no lens found with that id"))?;
|
||||
|
||||
let attrs = fetch_attributes_for_rel(Oid(class_oid), &mut *client).await?;
|
||||
|
||||
let selections = lens.fetch_selections(&mut *app_db).await?;
|
||||
let mut fields: Vec<Field> = Vec::with_capacity(selections.len());
|
||||
for selection in selections.clone() {
|
||||
fields.append(&mut selection.resolve_fields_from_attrs(&attrs));
|
||||
}
|
||||
|
||||
const FRONTEND_ROW_LIMIT: i64 = 1000;
|
||||
let rows = query(&format!(
|
||||
"select {} from {} limit $1",
|
||||
attrs
|
||||
.iter()
|
||||
.map(|attr| attr.attname.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
escape_identifier(&class.relname),
|
||||
))
|
||||
.bind(FRONTEND_ROW_LIMIT)
|
||||
.fetch_all(&mut *client)
|
||||
.await?;
|
||||
#[derive(Template)]
|
||||
#[template(path = "lens.html")]
|
||||
struct ResponseTemplate {
|
||||
fields: Vec<Field>,
|
||||
all_columns: Vec<PgAttribute>,
|
||||
rows: Vec<PgRow>,
|
||||
selections_json: String,
|
||||
settings: Settings,
|
||||
}
|
||||
Ok(Html(
|
||||
ResponseTemplate {
|
||||
all_columns: attrs,
|
||||
fields,
|
||||
rows,
|
||||
selections_json: serde_json::to_string(&selections)?,
|
||||
settings,
|
||||
}
|
||||
.render()?,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AddSelectionPageForm {
|
||||
column: String,
|
||||
}
|
||||
|
||||
pub async fn add_selection_page_post(
|
||||
State(settings): State<Settings>,
|
||||
AppDbConn(mut app_db): AppDbConn,
|
||||
CurrentUser(current_user): CurrentUser,
|
||||
Path(LensPagePath {
|
||||
base_id,
|
||||
class_oid,
|
||||
lens_id,
|
||||
}): Path<LensPagePath>,
|
||||
Form(form): Form<AddSelectionPageForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
dbg!(&form);
|
||||
// FIXME auth
|
||||
// FIXME csrf
|
||||
|
||||
let lens = Lens::fetch_by_id(lens_id, &mut *app_db)
|
||||
.await?
|
||||
.ok_or(not_found!("lens not found"))?;
|
||||
Selection::insertable_builder()
|
||||
.lens_id(lens.id)
|
||||
.attr_filters(vec![AttrFilter::NameEq(form.column)])
|
||||
.build()?
|
||||
.insert(&mut *app_db)
|
||||
.await?;
|
||||
|
||||
Ok(Redirect::to(&format!(
|
||||
"{0}/d/{base_id}/r/{class_oid}/l/{lens_id}/",
|
||||
settings.root_path
|
||||
))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub async fn update_lens_page_post(
|
||||
State(settings): State<Settings>,
|
||||
AppDbConn(mut app_db): AppDbConn,
|
||||
CurrentUser(current_user): CurrentUser,
|
||||
Path(LensPagePath {
|
||||
base_id,
|
||||
class_oid,
|
||||
lens_id,
|
||||
}): Path<LensPagePath>,
|
||||
Form(form): Form<HashMap<String, String>>,
|
||||
) -> Result<Response, AppError> {
|
||||
dbg!(&form);
|
||||
// FIXME auth
|
||||
// FIXME csrf
|
||||
|
||||
Ok(Redirect::to(&format!(
|
||||
"{0}/d/{base_id}/r/{class_oid}/l/{lens_id}/",
|
||||
settings.root_path
|
||||
))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ViewerPagePath {
|
||||
base_id: Uuid,
|
||||
class_oid: u32,
|
||||
lens_id: Uuid,
|
||||
}
|
||||
|
||||
pub async fn viewer_page(
|
||||
State(settings): State<Settings>,
|
||||
State(mut base_pooler): State<BasePooler>,
|
||||
AppDbConn(mut app_db): AppDbConn,
|
||||
CurrentUser(current_user): CurrentUser,
|
||||
Path(params): Path<ViewerPagePath>,
|
||||
) -> Result<Response, AppError> {
|
||||
todo!("not yet implemented");
|
||||
}
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
pub mod bases;
|
||||
pub mod lenses;
|
||||
pub mod relations;
|
||||
|
|
@ -8,10 +8,7 @@ use axum::{
|
|||
};
|
||||
use axum_extra::extract::Form;
|
||||
use serde::Deserialize;
|
||||
use sqlx::{
|
||||
postgres::{types::Oid, PgRow},
|
||||
query,
|
||||
};
|
||||
use sqlx::postgres::types::Oid;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
|
|
@ -19,10 +16,8 @@ use crate::{
|
|||
app_state::AppDbConn,
|
||||
base_pooler::BasePooler,
|
||||
bases::Base,
|
||||
data_layer::{Field, FieldOptionsBuilder, ToHtmlString as _, Value},
|
||||
db_conns::{escape_identifier, init_role},
|
||||
db_conns::init_role,
|
||||
pg_acls::PgPrivilegeType,
|
||||
pg_attributes::fetch_attributes_for_rel,
|
||||
pg_classes::{PgClass, PgRelKind},
|
||||
pg_roles::{user_id_from_rolname, PgRole, RoleTree},
|
||||
rel_invitations::RelInvitation,
|
||||
|
|
@ -36,7 +31,7 @@ pub struct ListRelationsPagePath {
|
|||
}
|
||||
|
||||
pub async fn list_relations_page(
|
||||
State(Settings { base_path, .. }): State<Settings>,
|
||||
State(settings): State<Settings>,
|
||||
State(mut base_pooler): State<BasePooler>,
|
||||
AppDbConn(mut app_db): AppDbConn,
|
||||
CurrentUser(current_user): CurrentUser,
|
||||
|
|
@ -81,16 +76,16 @@ pub async fn list_relations_page(
|
|||
#[derive(Template)]
|
||||
#[template(path = "list_rels.html")]
|
||||
struct ResponseTemplate {
|
||||
base_path: String,
|
||||
base: Base,
|
||||
rels: Vec<PgClass>,
|
||||
settings: Settings,
|
||||
}
|
||||
|
||||
Ok(Html(
|
||||
ResponseTemplate {
|
||||
base,
|
||||
base_path,
|
||||
rels: accessible_rels,
|
||||
settings,
|
||||
}
|
||||
.render()?,
|
||||
)
|
||||
|
|
@ -98,91 +93,26 @@ pub async fn list_relations_page(
|
|||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ViewerPagePath {
|
||||
pub struct RelPagePath {
|
||||
base_id: Uuid,
|
||||
class_oid: u32,
|
||||
}
|
||||
|
||||
pub async fn viewer_page(
|
||||
State(Settings { base_path, .. }): State<Settings>,
|
||||
State(mut base_pooler): State<BasePooler>,
|
||||
pub async fn rel_index_page(
|
||||
State(settings): State<Settings>,
|
||||
AppDbConn(mut app_db): AppDbConn,
|
||||
CurrentUser(current_user): CurrentUser,
|
||||
Path(params): Path<ViewerPagePath>,
|
||||
Path(RelPagePath { base_id, class_oid }): Path<RelPagePath>,
|
||||
) -> Result<Response, AppError> {
|
||||
let base = Base::fetch_by_id(params.base_id, &mut *app_db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound("no base found with that id".to_owned()))?;
|
||||
let mut client = base_pooler.acquire_for(params.base_id).await?;
|
||||
|
||||
init_role(
|
||||
&format!("{}{}", &base.user_role_prefix, ¤t_user.id.simple()),
|
||||
&mut client,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// FIXME: Ensure user has access to database and relation
|
||||
|
||||
let class = query!(
|
||||
"select relname from pg_class where oid = $1",
|
||||
Oid(params.class_oid)
|
||||
)
|
||||
.fetch_optional(&mut *client)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound(
|
||||
"no relation found with that oid".to_owned(),
|
||||
))?;
|
||||
let attrs = fetch_attributes_for_rel(Oid(params.class_oid), &mut *client).await?;
|
||||
|
||||
const FRONTEND_ROW_LIMIT: i64 = 1000;
|
||||
let rows = query(&format!(
|
||||
"select {} from {} limit $1",
|
||||
attrs
|
||||
.iter()
|
||||
.map(|attr| attr.attname.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
escape_identifier(&class.relname),
|
||||
))
|
||||
.bind(FRONTEND_ROW_LIMIT)
|
||||
.fetch_all(&mut *client)
|
||||
.await?;
|
||||
#[derive(Template)]
|
||||
#[template(path = "class-viewer.html")]
|
||||
struct ResponseTemplate {
|
||||
base_path: String,
|
||||
fields: Vec<Field>,
|
||||
rows: Vec<PgRow>,
|
||||
}
|
||||
Ok(Html(
|
||||
ResponseTemplate {
|
||||
base_path,
|
||||
fields: attrs
|
||||
.into_iter()
|
||||
.map(|attr| Field {
|
||||
options: FieldOptionsBuilder::default().build().unwrap(),
|
||||
name: attr.attname,
|
||||
})
|
||||
.collect(),
|
||||
rows,
|
||||
}
|
||||
.render()?,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RelRbacPagePath {
|
||||
base_id: Uuid,
|
||||
class_oid: u32,
|
||||
todo!();
|
||||
}
|
||||
|
||||
pub async fn rel_rbac_page(
|
||||
State(Settings { base_path, .. }): State<Settings>,
|
||||
State(settings): State<Settings>,
|
||||
State(mut base_pooler): State<BasePooler>,
|
||||
AppDbConn(mut app_db): AppDbConn,
|
||||
CurrentUser(current_user): CurrentUser,
|
||||
Path(RelRbacPagePath { base_id, class_oid }): Path<RelRbacPagePath>,
|
||||
Path(RelPagePath { base_id, class_oid }): Path<RelPagePath>,
|
||||
) -> Result<Response, AppError> {
|
||||
// FIXME: auth
|
||||
let base = Base::fetch_by_id(base_id, &mut *app_db)
|
||||
|
|
@ -215,29 +145,27 @@ pub async fn rel_rbac_page(
|
|||
let all_invites = RelInvitation::fetch_by_class_oid(Oid(class_oid), &mut *app_db).await?;
|
||||
let mut invites_by_email: HashMap<String, Vec<RelInvitation>> = HashMap::new();
|
||||
for invite in all_invites {
|
||||
let entry = invites_by_email
|
||||
.entry(invite.email.clone())
|
||||
.or_insert(vec![]);
|
||||
let entry = invites_by_email.entry(invite.email.clone()).or_default();
|
||||
entry.push(invite);
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "rel_rbac.html")]
|
||||
struct ResponseTemplate {
|
||||
base_path: String,
|
||||
base: Base,
|
||||
interim_users: HashMap<String, User>,
|
||||
invites_by_email: HashMap<String, Vec<RelInvitation>>,
|
||||
pg_class: PgClass,
|
||||
settings: Settings,
|
||||
}
|
||||
|
||||
Ok(Html(
|
||||
ResponseTemplate {
|
||||
base,
|
||||
base_path,
|
||||
interim_users,
|
||||
invites_by_email,
|
||||
pg_class: class,
|
||||
settings,
|
||||
}
|
||||
.render()?,
|
||||
)
|
||||
|
|
@ -245,14 +173,14 @@ pub async fn rel_rbac_page(
|
|||
}
|
||||
|
||||
pub async fn rel_rbac_invite_page_get(
|
||||
State(Settings { base_path, .. }): State<Settings>,
|
||||
State(settings): State<Settings>,
|
||||
) -> Result<Response, AppError> {
|
||||
#[derive(Template)]
|
||||
#[template(path = "rbac_invite.html")]
|
||||
struct ResponseTemplate {
|
||||
base_path: String,
|
||||
settings: Settings,
|
||||
}
|
||||
Ok(Html(ResponseTemplate { base_path }.render()?).into_response())
|
||||
Ok(Html(ResponseTemplate { settings }.render()?).into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
@ -261,10 +189,10 @@ pub struct RbacInvitePagePostForm {
|
|||
}
|
||||
|
||||
pub async fn rel_rbac_invite_page_post(
|
||||
State(Settings { base_path, .. }): State<Settings>,
|
||||
State(settings): State<Settings>,
|
||||
AppDbConn(mut app_db): AppDbConn,
|
||||
CurrentUser(current_user): CurrentUser,
|
||||
Path(RelRbacPagePath { base_id, class_oid }): Path<RelRbacPagePath>,
|
||||
Path(RelPagePath { base_id, class_oid }): Path<RelPagePath>,
|
||||
Form(form): Form<RbacInvitePagePostForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
// FIXME auth
|
||||
|
|
@ -288,5 +216,9 @@ pub async fn rel_rbac_invite_page_post(
|
|||
.upsert(&mut *app_db)
|
||||
.await?;
|
||||
}
|
||||
Ok(Redirect::to(&format!("{base_path}/d/{base_id}/r/{class_oid}/rbac")).into_response())
|
||||
Ok(Redirect::to(&format!(
|
||||
"{0}/d/{base_id}/r/{class_oid}/rbac",
|
||||
settings.root_path
|
||||
))
|
||||
.into_response())
|
||||
}
|
||||
|
|
@ -12,7 +12,12 @@ pub struct Settings {
|
|||
/// slash but no trailing slash, for example "/app". For default behavior,
|
||||
/// leave as empty string.
|
||||
#[serde(default)]
|
||||
pub base_path: String,
|
||||
pub root_path: String,
|
||||
|
||||
/// When set to 1, dev features such as the frontend reloader will be
|
||||
/// enabled.
|
||||
#[serde(default)]
|
||||
pub dev: u8,
|
||||
|
||||
/// postgresql:// URL for Interim's application database.
|
||||
pub database_url: String,
|
||||
|
|
@ -21,7 +26,8 @@ pub struct Settings {
|
|||
pub app_db_max_connections: u32,
|
||||
|
||||
/// When set to 1, embedded SQLx migrations will be run on startup.
|
||||
pub run_database_migrations: Option<u8>,
|
||||
#[serde(default)]
|
||||
pub run_database_migrations: u8,
|
||||
|
||||
/// Address for server to bind to
|
||||
#[serde(default = "default_host")]
|
||||
|
|
@ -74,7 +74,7 @@ where
|
|||
SESSION_KEY_AUTH_REDIRECT,
|
||||
uri.path_and_query()
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or(format!("{}/", app_state.settings.base_path)),
|
||||
.unwrap_or(format!("{}/", app_state.settings.root_path)),
|
||||
)?;
|
||||
if let Some(cookie_value) = app_state.session_store.store_session(session).await? {
|
||||
tracing::debug!("adding session cookie to jar");
|
||||
|
|
@ -96,7 +96,7 @@ where
|
|||
};
|
||||
return Err(Self::Rejection::SetCookiesAndRedirect(
|
||||
jar,
|
||||
format!("{}/auth/login", app_state.settings.base_path),
|
||||
format!("{}/auth/login", app_state.settings.root_path),
|
||||
));
|
||||
};
|
||||
let current_user = if let Some(value) =
|
||||
11
interim-server/templates/add_lens.html
Normal file
11
interim-server/templates/add_lens.html
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block main %}
|
||||
<form method="post" action="">
|
||||
<div>
|
||||
<label for="lens-name-input">Lens name:</label>
|
||||
<input type="text" name="name" id="lens-name-input">
|
||||
</div>
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
16
interim-server/templates/base.html
Normal file
16
interim-server/templates/base.html
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<!doctype html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<title>{% block title %}Interim{% endblock %}</title>
|
||||
{% include "meta_tags.html" %}
|
||||
<link rel="stylesheet" href="{{ settings.root_path }}/modern-normalize.min.css">
|
||||
<link rel="stylesheet" href="{{ settings.root_path }}/main.css">
|
||||
</head>
|
||||
<body>
|
||||
{% block main %}{% endblock main %}
|
||||
<script type="module" src="{{ settings.root_path }}/js_dist/dev-reloader.mjs"></script>
|
||||
{% if settings.dev != 0 %}
|
||||
<dev-reloader ws="ws://127.0.0.1:8080{{ settings.root_path }}/__dev-healthz" auto=""></dev-reloader>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,13 +1,14 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block main %}
|
||||
<script type="module" src="{{ base_path }}/js_dist/cells.mjs"></script>
|
||||
<script type="module" src="{{ settings.root_path }}/js_dist/cells.mjs"></script>
|
||||
<script type="module" src="{{ settings.root_path }}/js_dist/lens-controls.mjs"></script>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{% for field in fields %}
|
||||
<th>
|
||||
<div>{{ field.options.label.clone().unwrap_or(field.name.clone()) }}</div>
|
||||
<div>{{ field.label.clone().unwrap_or(field.name.clone()) }}</div>
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
|
|
@ -19,7 +20,7 @@
|
|||
<td>
|
||||
{% match Value::get_from_row(row, field.name.as_str()) %}
|
||||
{% when Ok with (value) %}
|
||||
{{ value.to_html_string(&field.options) | safe }}
|
||||
{{ value.to_html_string(&field.display_type) | safe }}
|
||||
{% when Err with (err) %}
|
||||
<span class="pg-value-error">{{ err }}</span>
|
||||
{% endmatch %}
|
||||
|
|
@ -29,4 +30,5 @@
|
|||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<lens-controls selections="{{ selections_json }}"></lens-controls>
|
||||
{% endblock %}
|
||||
39
interim-server/templates/lens.html
Normal file
39
interim-server/templates/lens.html
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block main %}
|
||||
<script type="module" src="{{ settings.root_path }}/js_dist/cells.mjs"></script>
|
||||
<script type="module" src="{{ settings.root_path }}/js_dist/lens-controls.mjs"></script>
|
||||
<script type="module" src="{{ settings.root_path }}/js_dist/viewer-components.mjs"></script>
|
||||
<link rel="stylesheet" href="{{ settings.root_path }}/viewer.css">
|
||||
<table class="viewer">
|
||||
<thead>
|
||||
<tr>
|
||||
{% for field in fields %}
|
||||
<th>
|
||||
<div class="padded-cell">{{ field.label.clone().unwrap_or(field.name.clone()) }}</div>
|
||||
</th>
|
||||
{% endfor %}
|
||||
<th>
|
||||
<add-selection-button columns="{{ all_columns | json }}"></add-selection-button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
{% for field in fields %}
|
||||
<td>
|
||||
{% match Value::get_from_row(row, field.name.as_str()) %}
|
||||
{% when Ok with (value) %}
|
||||
{{ value.to_html_string(field.display_type) | safe }}
|
||||
{% when Err with (err) %}
|
||||
<span class="pg-value-error">{{ err }}</span>
|
||||
{% endmatch %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<lens-controls selections="{{ selections_json }}"></lens-controls>
|
||||
{% endblock %}
|
||||
19
interim-server/templates/lenses.html
Normal file
19
interim-server/templates/lenses.html
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block main %}
|
||||
<table>
|
||||
<tbody>
|
||||
{% for lens in lenses %}
|
||||
<tr>
|
||||
<td>
|
||||
<a
|
||||
href="{{ settings.root_path }}/d/{{ base_id.simple() }}/r/{{ class_oid }}/l/{{ lens.id.simple() }}"
|
||||
>
|
||||
{{ lens.name }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block main %}
|
||||
<form method="post" action="{{ base_path }}/databases/add">
|
||||
<form method="post" action="{{ settings.root_path }}/databases/add">
|
||||
<button type="submit">Add Database</button>
|
||||
</form>
|
||||
<table>
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
{% for base in bases %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ base_path }}/d/{{ base.id.simple() }}/config">
|
||||
<a href="{{ settings.root_path }}/d/{{ base.id.simple() }}/config">
|
||||
{{ base.name }}
|
||||
</a>
|
||||
</td>
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
{% for rel in rels %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ base_path }}/d/{{ base.id.simple() }}/r/{{ rel.oid.0 }}">
|
||||
<a href="{{ settings.root_path }}/d/{{ base.id.simple() }}/r/{{ rel.oid.0 }}">
|
||||
{{ rel.relname }}
|
||||
</a>
|
||||
</td>
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" href="{{ base_path }}/favicon.ico">
|
||||
<link rel="icon" href="{{ settings.root_path }}/favicon.ico">
|
||||
7
interim-server/templates/nav.html
Normal file
7
interim-server/templates/nav.html
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<nav>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ settings.root_path }}/auth/login">Login</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
23
interim-server/templates/rbac.html
Normal file
23
interim-server/templates/rbac.html
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block main %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>User ID</th>
|
||||
<th>Roles</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>{{ user.user.email }}</td>
|
||||
<td>{{ role_prefix }}{{ user.user.id.simple() }}</td>
|
||||
<td></td>
|
||||
<td>...</td>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
{% block main %}
|
||||
<h2>Invitations</h2>
|
||||
<a href="{{ base_path }}/d/{{ base.id.simple() }}/r/{{ pg_class.oid.0 }}/rbac/invite">
|
||||
<a href="{{ settings.root_path }}/d/{{ base.id.simple() }}/r/{{ pg_class.oid.0 }}/rbac/invite">
|
||||
Invite Collaborators
|
||||
</a>
|
||||
<table>
|
||||
|
|
@ -1,13 +1,17 @@
|
|||
:root {
|
||||
--bs-font-sans-serif: Geist, "Noto Sans", Roboto, "Segoe UI", system-ui, -apple-system, "Helvetica Neue", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji";
|
||||
html {
|
||||
font-family: "Averia Serif Libre", "Open Sans", "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] {
|
||||
--bs-body-bg: rgb(27, 28, 30);
|
||||
--bs-tertiary-bg-rgb: 36, 38, 40;
|
||||
button, input[type="submit"] {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Geist;
|
||||
src: url("./geist/geist_variable.ttf");
|
||||
font-family: "Averia Serif Libre";
|
||||
src: url("./averia_serif_libre/averia_serif_libre_regular.ttf");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Funnel Sans";
|
||||
src: url("./funnel_sans/funnel_sans_variable.ttf");
|
||||
}
|
||||
|
|
|
|||
9
static/modern-normalize.min.css
vendored
Normal file
9
static/modern-normalize.min.css
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* Minified by jsDelivr using clean-css v5.3.2.
|
||||
* Original file: /npm/modern-normalize@3.0.1/modern-normalize.css
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
/*! modern-normalize v3.0.1 | MIT License | https://github.com/sindresorhus/modern-normalize */
|
||||
*,::after,::before{box-sizing:border-box}html{font-family:system-ui,'Segoe UI',Roboto,Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji';line-height:1.15;-webkit-text-size-adjust:100%;tab-size:4}body{margin:0}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-color:currentcolor}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}legend{padding:0}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}
|
||||
/*# sourceMappingURL=/sm/d2d8cd206fb9f42f071e97460f3ad9c875edb5e7a4b10f900a83cdf8401c53a9.map */
|
||||
36
static/viewer.css
Normal file
36
static/viewer.css
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
table.viewer {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
table.viewer > thead > tr > th {
|
||||
border: solid 1px #ccc;
|
||||
border-top: none;
|
||||
font-family: "Funnel Sans";
|
||||
background: #0001;
|
||||
|
||||
&:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
|
||||
table.viewer .padded-cell {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
table.viewer .clickable-header-cell {
|
||||
appearance: none;
|
||||
border: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-weight: inherit;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
table.viewer > tbody > tr > td {
|
||||
border: solid 1px #ccc;
|
||||
|
||||
&:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<title>{% block title %}Interim{% endblock %}</title>
|
||||
{% include "meta_tags.html" %}
|
||||
<link rel="stylesheet" href="{{ base_path }}/main.css">
|
||||
</head>
|
||||
<body>
|
||||
{% block main %}{% endblock main %}
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
<nav>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ base_path }}/auth/login">Login</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
Loading…
Add table
Reference in a new issue