SvelteKit-app, SpacetimeDB-modul og chat med sanntid
SvelteKit (web/): - Komplett app-skjelett med Authentik SSO (dev-bypass lokalt) - Workspace-modell med cookie-basert switching og RLS-kontekst - Komponerbare sider (PageGrid + BlockShell + block registry) - Chat med adapter-mønster: PG-polling og SpacetimeDB hybrid-adapter - Brukeridentitet fra Authentik/dev-login flyter til chat-meldinger - API-ruter for channels, messages og health SpacetimeDB (spacetimedb/): - Rust WASM-modul med ChatMessage og SyncOutbox-tabeller - send_message reducer med sync outbox for fremtidig PG-persistering - Genererte TypeScript-bindings for klient-integrasjon Infra: - SpacetimeDB lagt til i docker-compose.dev.yml Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a5985ef3f8
commit
ca27a8077b
53 changed files with 5914 additions and 0 deletions
|
|
@ -41,6 +41,24 @@ services:
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
# === Lag B: Sanntid ===
|
||||||
|
|
||||||
|
spacetimedb:
|
||||||
|
image: clockworklabs/spacetime:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
command: start
|
||||||
|
volumes:
|
||||||
|
- ./.docker-data/spacetimedb:/stdb
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3000:3000"
|
||||||
|
networks:
|
||||||
|
- sidelinja-dev
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -f http://localhost:3000/database/ping || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
# === AI Gateway ===
|
# === AI Gateway ===
|
||||||
|
|
||||||
ai-gateway:
|
ai-gateway:
|
||||||
|
|
|
||||||
947
spacetimedb/Cargo.lock
generated
Normal file
947
spacetimedb/Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,947 @@
|
||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyhow"
|
||||||
|
version = "1.0.102"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "approx"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0e60b75072ecd4168020818c0107f2857bb6c4e64252d8d3983f6263b40a5c3"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arrayref"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arrayvec"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "autocfg"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake3"
|
||||||
|
version = "1.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d"
|
||||||
|
dependencies = [
|
||||||
|
"arrayref",
|
||||||
|
"arrayvec",
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"constant_time_eq",
|
||||||
|
"cpufeatures",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytemuck"
|
||||||
|
version = "1.25.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytes"
|
||||||
|
version = "1.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cc"
|
||||||
|
version = "1.2.57"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
|
||||||
|
dependencies = [
|
||||||
|
"find-msvc-tools",
|
||||||
|
"shlex",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chrono"
|
||||||
|
version = "0.4.44"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "constant_time_eq"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "convert_case"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "decorum"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "281759d3c8a14f5c3f0c49363be56810fcd7f910422f97f2db850c2920fde5cf"
|
||||||
|
dependencies = [
|
||||||
|
"approx",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_more"
|
||||||
|
version = "0.99.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
|
||||||
|
dependencies = [
|
||||||
|
"convert_case",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustc_version",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"crypto-common",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "either"
|
||||||
|
version = "1.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "enum-as-inner"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc"
|
||||||
|
dependencies = [
|
||||||
|
"heck 0.5.0",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ethnum"
|
||||||
|
version = "1.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "find-msvc-tools"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foldhash"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generic-array"
|
||||||
|
version = "0.14.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi 5.3.0",
|
||||||
|
"wasip2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi 6.0.0",
|
||||||
|
"wasip2",
|
||||||
|
"wasip3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.15.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||||
|
dependencies = [
|
||||||
|
"foldhash",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.16.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hex"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http"
|
||||||
|
version = "1.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"itoa",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "humantime"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "id-arena"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "2.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown 0.16.1",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itertools"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "keccak"
|
||||||
|
version = "0.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
|
||||||
|
dependencies = [
|
||||||
|
"cpufeatures",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "leb128fmt"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.183"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nohash-hasher"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-traits"
|
||||||
|
version = "0.2.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ppv-lite86"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prettyplease"
|
||||||
|
version = "0.2.37"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.106"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "5.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.8.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rand_chacha 0.3.1",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||||
|
dependencies = [
|
||||||
|
"rand_chacha 0.9.0",
|
||||||
|
"rand_core 0.9.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"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.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc_version"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||||
|
dependencies = [
|
||||||
|
"semver",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scoped-tls"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "second-stack"
|
||||||
|
version = "0.3.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4904c83c6e51f1b9b08bfa5a86f35a51798e8307186e6f5513852210a219c0bb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "semver"
|
||||||
|
version = "1.0.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.149"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha3"
|
||||||
|
version = "0.10.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
"keccak",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sidelinja-realtime"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"spacetimedb",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smallvec"
|
||||||
|
version = "1.15.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spacetimedb"
|
||||||
|
version = "1.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "503ac8c991a76998d4ba699ef9b7f0085d3d7c363d1fcce4219314f909746bca"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"bytemuck",
|
||||||
|
"bytes",
|
||||||
|
"derive_more",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"http",
|
||||||
|
"log",
|
||||||
|
"rand 0.8.5",
|
||||||
|
"scoped-tls",
|
||||||
|
"serde_json",
|
||||||
|
"spacetimedb-bindings-macro",
|
||||||
|
"spacetimedb-bindings-sys",
|
||||||
|
"spacetimedb-lib",
|
||||||
|
"spacetimedb-primitives",
|
||||||
|
"spacetimedb-query-builder",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spacetimedb-bindings-macro"
|
||||||
|
version = "1.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1214628a7c29ee58255d511b7c4dbaaa463bc5022dba9401f264c1c85b5a891c"
|
||||||
|
dependencies = [
|
||||||
|
"heck 0.4.1",
|
||||||
|
"humantime",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"spacetimedb-primitives",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spacetimedb-bindings-sys"
|
||||||
|
version = "1.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d4777d90692bade6601887a21a074b71c157b34a92a5cfc8d5ecb46a0c571094"
|
||||||
|
dependencies = [
|
||||||
|
"spacetimedb-primitives",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spacetimedb-lib"
|
||||||
|
version = "1.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c5e91c66b10dc38cce01928d3f77313276e34c635504e54afdec6f186a2fc9c"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"bitflags",
|
||||||
|
"blake3",
|
||||||
|
"chrono",
|
||||||
|
"derive_more",
|
||||||
|
"enum-as-inner",
|
||||||
|
"hex",
|
||||||
|
"itertools",
|
||||||
|
"log",
|
||||||
|
"spacetimedb-bindings-macro",
|
||||||
|
"spacetimedb-primitives",
|
||||||
|
"spacetimedb-sats",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spacetimedb-primitives"
|
||||||
|
version = "1.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f0321a161fa39f0937aceb436b47115cd811212799ddaf7996a8ecac3476d8d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"either",
|
||||||
|
"enum-as-inner",
|
||||||
|
"itertools",
|
||||||
|
"nohash-hasher",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spacetimedb-query-builder"
|
||||||
|
version = "1.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9df8137b6dc2739d4efbc6218c5fb106f1e105a1345819a74053b677bd38c429"
|
||||||
|
dependencies = [
|
||||||
|
"spacetimedb-lib",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spacetimedb-sats"
|
||||||
|
version = "1.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f4e07b1bc933156b0cbe6b6c759e57381ce95df52c4522d9c3d71df59c01cf20"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"arrayvec",
|
||||||
|
"bitflags",
|
||||||
|
"bytemuck",
|
||||||
|
"bytes",
|
||||||
|
"chrono",
|
||||||
|
"decorum",
|
||||||
|
"derive_more",
|
||||||
|
"enum-as-inner",
|
||||||
|
"ethnum",
|
||||||
|
"hex",
|
||||||
|
"itertools",
|
||||||
|
"rand 0.9.2",
|
||||||
|
"second-stack",
|
||||||
|
"sha3",
|
||||||
|
"smallvec",
|
||||||
|
"spacetimedb-bindings-macro",
|
||||||
|
"spacetimedb-primitives",
|
||||||
|
"thiserror",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.117"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.19.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-xid"
|
||||||
|
version = "0.2.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uuid"
|
||||||
|
version = "1.22.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.4.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasip2"
|
||||||
|
version = "1.0.2+wasi-0.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
|
||||||
|
dependencies = [
|
||||||
|
"wit-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasip3"
|
||||||
|
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||||
|
dependencies = [
|
||||||
|
"wit-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-encoder"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||||
|
dependencies = [
|
||||||
|
"leb128fmt",
|
||||||
|
"wasmparser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-metadata"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"indexmap",
|
||||||
|
"wasm-encoder",
|
||||||
|
"wasmparser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasmparser"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"hashbrown 0.15.5",
|
||||||
|
"indexmap",
|
||||||
|
"semver",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen"
|
||||||
|
version = "0.51.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||||
|
dependencies = [
|
||||||
|
"wit-bindgen-rust-macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen-core"
|
||||||
|
version = "0.51.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"heck 0.5.0",
|
||||||
|
"wit-parser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen-rust"
|
||||||
|
version = "0.51.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"heck 0.5.0",
|
||||||
|
"indexmap",
|
||||||
|
"prettyplease",
|
||||||
|
"syn",
|
||||||
|
"wasm-metadata",
|
||||||
|
"wit-bindgen-core",
|
||||||
|
"wit-component",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen-rust-macro"
|
||||||
|
version = "0.51.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"prettyplease",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
"wit-bindgen-core",
|
||||||
|
"wit-bindgen-rust",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-component"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"bitflags",
|
||||||
|
"indexmap",
|
||||||
|
"log",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"wasm-encoder",
|
||||||
|
"wasm-metadata",
|
||||||
|
"wasmparser",
|
||||||
|
"wit-parser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-parser"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"id-arena",
|
||||||
|
"indexmap",
|
||||||
|
"log",
|
||||||
|
"semver",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"unicode-xid",
|
||||||
|
"wasmparser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy"
|
||||||
|
version = "0.8.42"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy-derive"
|
||||||
|
version = "0.8.42"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
11
spacetimedb/Cargo.toml
Normal file
11
spacetimedb/Cargo.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
[package]
|
||||||
|
name = "sidelinja-realtime"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
spacetimedb = "1.0"
|
||||||
|
log = "0.4"
|
||||||
148
spacetimedb/src/lib.rs
Normal file
148
spacetimedb/src/lib.rs
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
use spacetimedb::{table, reducer, Table, ReducerContext, Timestamp};
|
||||||
|
|
||||||
|
// === Tabeller ===
|
||||||
|
|
||||||
|
/// Chat-melding. Speiler PostgreSQL `messages`-tabellen.
|
||||||
|
/// `public` = alle tilkoblede klienter i samme subscription kan lese.
|
||||||
|
#[table(name = chat_message, public)]
|
||||||
|
pub struct ChatMessage {
|
||||||
|
/// PostgreSQL node UUID (settes av klienten ved oppvarming, auto ved ny melding)
|
||||||
|
#[primary_key]
|
||||||
|
pub id: String,
|
||||||
|
pub channel_id: String,
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub author_id: String,
|
||||||
|
pub author_name: String,
|
||||||
|
pub body: String,
|
||||||
|
pub message_type: String,
|
||||||
|
pub reply_to: String,
|
||||||
|
pub created_at: Timestamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outbox for synkronisering til PostgreSQL.
|
||||||
|
/// Rust sync-worker leser denne og batch-skriver til PG.
|
||||||
|
#[table(name = sync_outbox, public)]
|
||||||
|
pub struct SyncOutbox {
|
||||||
|
#[auto_inc]
|
||||||
|
#[primary_key]
|
||||||
|
pub id: u64,
|
||||||
|
pub table_name: String,
|
||||||
|
pub action: String,
|
||||||
|
pub payload: String,
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub created_at: Timestamp,
|
||||||
|
pub synced: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Reducers ===
|
||||||
|
|
||||||
|
/// Send en ny melding. Kalles fra klienten.
|
||||||
|
/// Oppretter ChatMessage + SyncOutbox-event.
|
||||||
|
#[reducer]
|
||||||
|
pub fn send_message(
|
||||||
|
ctx: &ReducerContext,
|
||||||
|
id: String,
|
||||||
|
channel_id: String,
|
||||||
|
workspace_id: String,
|
||||||
|
author_name: String,
|
||||||
|
body: String,
|
||||||
|
reply_to: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if body.trim().is_empty() {
|
||||||
|
return Err("Melding kan ikke være tom".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bygg payload først (før verdiene flyttes inn i ChatMessage)
|
||||||
|
let payload = format!(
|
||||||
|
r#"{{"id":"{}","channel_id":"{}","workspace_id":"{}","author_id":"{}","body":"{}","reply_to":"{}"}}"#,
|
||||||
|
id, channel_id, workspace_id, ctx.sender.to_hex(),
|
||||||
|
body.trim().replace('"', r#"\""#),
|
||||||
|
reply_to
|
||||||
|
);
|
||||||
|
|
||||||
|
let msg = ChatMessage {
|
||||||
|
id,
|
||||||
|
channel_id,
|
||||||
|
workspace_id: workspace_id.clone(),
|
||||||
|
author_id: ctx.sender.to_hex().to_string(),
|
||||||
|
author_name,
|
||||||
|
body: body.trim().to_string(),
|
||||||
|
message_type: "text".to_string(),
|
||||||
|
reply_to,
|
||||||
|
created_at: ctx.timestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.db.chat_message().insert(msg);
|
||||||
|
|
||||||
|
ctx.db.sync_outbox().insert(SyncOutbox {
|
||||||
|
id: 0,
|
||||||
|
table_name: "messages".to_string(),
|
||||||
|
action: "insert".to_string(),
|
||||||
|
payload,
|
||||||
|
workspace_id,
|
||||||
|
created_at: ctx.timestamp,
|
||||||
|
synced: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
log::info!("Melding sendt");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Laster meldinger fra PostgreSQL ved oppvarming.
|
||||||
|
/// Kalles av sync-worker, ikke av klienter direkte.
|
||||||
|
#[reducer]
|
||||||
|
pub fn load_messages(
|
||||||
|
ctx: &ReducerContext,
|
||||||
|
messages_json: Vec<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let count = messages_json.len();
|
||||||
|
for json_str in messages_json {
|
||||||
|
// Enkel parsing — sync-worker sender ferdig-formaterte meldinger
|
||||||
|
let parts: Vec<&str> = json_str.splitn(8, '|').collect();
|
||||||
|
if parts.len() < 8 {
|
||||||
|
log::warn!("Ugyldig melding ved oppvarming: {}", json_str);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.db.chat_message().insert(ChatMessage {
|
||||||
|
id: parts[0].to_string(),
|
||||||
|
channel_id: parts[1].to_string(),
|
||||||
|
workspace_id: parts[2].to_string(),
|
||||||
|
author_id: parts[3].to_string(),
|
||||||
|
author_name: parts[4].to_string(),
|
||||||
|
body: parts[5].to_string(),
|
||||||
|
message_type: parts[6].to_string(),
|
||||||
|
reply_to: parts[7].to_string(),
|
||||||
|
created_at: ctx.timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("Oppvarming fullført: {} meldinger lastet", count);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Markerer sync-outbox-events som synket.
|
||||||
|
/// Kalles av sync-worker etter vellykket PG-skriving.
|
||||||
|
#[reducer]
|
||||||
|
pub fn mark_synced(ctx: &ReducerContext, ids: Vec<u64>) -> Result<(), String> {
|
||||||
|
for id in &ids {
|
||||||
|
if let Some(mut entry) = ctx.db.sync_outbox().id().find(*id) {
|
||||||
|
entry.synced = true;
|
||||||
|
ctx.db.sync_outbox().id().update(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log::info!("{} sync-events markert som synket", ids.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Livssyklus ===
|
||||||
|
|
||||||
|
#[reducer(client_connected)]
|
||||||
|
pub fn client_connected(ctx: &ReducerContext) {
|
||||||
|
log::info!("Klient tilkoblet: {}", ctx.sender.to_hex());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[reducer(client_disconnected)]
|
||||||
|
pub fn client_disconnected(ctx: &ReducerContext) {
|
||||||
|
log::info!("Klient frakoblet: {}", ctx.sender.to_hex());
|
||||||
|
}
|
||||||
5
web/.gitignore
vendored
Normal file
5
web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules/
|
||||||
|
.svelte-kit/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
2145
web/package-lock.json
generated
Normal file
2145
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
27
web/package.json
Normal file
27
web/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"name": "sidelinja-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@auth/core": "^0.41.1",
|
||||||
|
"@auth/sveltekit": "^1.11.1",
|
||||||
|
"@sveltejs/adapter-node": "^5.5.4",
|
||||||
|
"@sveltejs/kit": "^2.55.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
|
"postgres": "^3.4.8",
|
||||||
|
"spacetimedb": "^2.0.4",
|
||||||
|
"svelte": "^5.53.12",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^8.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"svelte-check": "^4.4.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
24
web/src/app.d.ts
vendored
Normal file
24
web/src/app.d.ts
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import type { Workspace } from '$lib/server/db';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
interface Locals {
|
||||||
|
/** Innlogget bruker fra Authentik (satt av Auth.js) */
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
image?: string;
|
||||||
|
} | null;
|
||||||
|
/** Aktiv workspace (satt av hooks basert på cookie) */
|
||||||
|
workspace: Workspace | null;
|
||||||
|
}
|
||||||
|
interface PageData {
|
||||||
|
user: App.Locals['user'];
|
||||||
|
workspace: App.Locals['workspace'];
|
||||||
|
workspaces: Workspace[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
12
web/src/app.html
Normal file
12
web/src/app.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="nb">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
66
web/src/hooks.server.ts
Normal file
66
web/src/hooks.server.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import type { Handle } from '@sveltejs/kit';
|
||||||
|
import { sequence } from '@sveltejs/kit/hooks';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
import { handle as authHandle } from '$lib/server/auth';
|
||||||
|
import { getWorkspaceForUser, getUserWorkspaces } from '$lib/server/db';
|
||||||
|
|
||||||
|
const WORKSPACE_COOKIE = 'sidelinja_workspace';
|
||||||
|
const isDev = env.NODE_ENV !== 'production' && !env.AUTHENTIK_CLIENT_ID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev-only auth: simulerer innlogget bruker uten OIDC.
|
||||||
|
* Setter locals.auth() til å returnere en fast dev-bruker.
|
||||||
|
*/
|
||||||
|
const devAuthHandle: Handle = async ({ event, resolve }) => {
|
||||||
|
const DEV_USER = { id: 'dev-user-1', name: 'Vegard', email: 'vegard@localhost' };
|
||||||
|
|
||||||
|
// Legg til auth()-metode som hooks og layout forventer
|
||||||
|
event.locals.auth = async () => ({ user: DEV_USER, expires: '' });
|
||||||
|
return resolve(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Workspace-resolving: les aktiv workspace fra cookie,
|
||||||
|
* verifiser tilgang, og legg i locals.
|
||||||
|
*/
|
||||||
|
const workspaceHandle: Handle = async ({ event, resolve }) => {
|
||||||
|
const session = await event.locals.auth();
|
||||||
|
const user = session?.user;
|
||||||
|
|
||||||
|
if (user?.id) {
|
||||||
|
event.locals.user = {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name ?? '',
|
||||||
|
email: user.email ?? '',
|
||||||
|
image: user.image ?? undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
const workspaceId = event.cookies.get(WORKSPACE_COOKIE);
|
||||||
|
if (workspaceId) {
|
||||||
|
event.locals.workspace = await getWorkspaceForUser(workspaceId, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hvis ingen gyldig workspace i cookie, velg den første
|
||||||
|
if (!event.locals.workspace) {
|
||||||
|
const workspaces = await getUserWorkspaces(user.id);
|
||||||
|
if (workspaces.length > 0) {
|
||||||
|
event.locals.workspace = workspaces[0];
|
||||||
|
event.cookies.set(WORKSPACE_COOKIE, workspaces[0].id, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 60 * 60 * 24 * 365
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
event.locals.user = null;
|
||||||
|
event.locals.workspace = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolve(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handle = isDev
|
||||||
|
? sequence(devAuthHandle, workspaceHandle)
|
||||||
|
: sequence(authHandle, workspaceHandle);
|
||||||
25
web/src/lib/blocks/CalendarBlock.svelte
Normal file
25
web/src/lib/blocks/CalendarBlock.svelte
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<script lang="ts">
|
||||||
|
let { props = {} }: { props?: Record<string, unknown> } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="placeholder">
|
||||||
|
<span class="icon">📅</span>
|
||||||
|
<p class="label">Kalender</p>
|
||||||
|
<p class="hint">Kommer snart</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
color: #8b92a5;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.icon { font-size: 2rem; }
|
||||||
|
.label { font-weight: 600; color: #e1e4e8; }
|
||||||
|
.hint { font-size: 0.8rem; }
|
||||||
|
</style>
|
||||||
290
web/src/lib/blocks/ChatBlock.svelte
Normal file
290
web/src/lib/blocks/ChatBlock.svelte
Normal file
|
|
@ -0,0 +1,290 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { createChat } from '$lib/chat/create.svelte';
|
||||||
|
import type { Message, ChatConnection } from '$lib/chat/types';
|
||||||
|
|
||||||
|
let { props = {} }: { props?: Record<string, unknown> } = $props();
|
||||||
|
|
||||||
|
const channelId = props.channelId as string | undefined;
|
||||||
|
|
||||||
|
let chat = $state<ChatConnection | null>(null);
|
||||||
|
let input = $state('');
|
||||||
|
let sending = $state(false);
|
||||||
|
let messagesEl: HTMLDivElement | undefined;
|
||||||
|
let inputEl: HTMLTextAreaElement | undefined;
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
if (!chat || !input.trim() || sending) return;
|
||||||
|
const body = input.trim();
|
||||||
|
input = '';
|
||||||
|
sending = true;
|
||||||
|
try {
|
||||||
|
await chat.send(body);
|
||||||
|
scrollToBottom();
|
||||||
|
} finally {
|
||||||
|
sending = false;
|
||||||
|
requestAnimationFrame(() => inputEl?.focus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
messagesEl?.scrollTo(0, messagesEl.scrollHeight);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleTimeString('nb-NO', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
const today = new Date();
|
||||||
|
if (d.toDateString() === today.toDateString()) return 'I dag';
|
||||||
|
const yesterday = new Date(today);
|
||||||
|
yesterday.setDate(today.getDate() - 1);
|
||||||
|
if (d.toDateString() === yesterday.toDateString()) return 'I går';
|
||||||
|
return d.toLocaleDateString('nb-NO', { day: 'numeric', month: 'short' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let messages = $derived(chat?.messages ?? []);
|
||||||
|
let prevCount = 0;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const count = messages.length;
|
||||||
|
if (count > prevCount) {
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
prevCount = count;
|
||||||
|
});
|
||||||
|
|
||||||
|
let grouped = $derived.by(() => {
|
||||||
|
const groups: { date: string; messages: Message[] }[] = [];
|
||||||
|
let currentDate = '';
|
||||||
|
for (const msg of messages) {
|
||||||
|
const date = formatDate(msg.created_at);
|
||||||
|
if (date !== currentDate) {
|
||||||
|
currentDate = date;
|
||||||
|
groups.push({ date, messages: [] });
|
||||||
|
}
|
||||||
|
groups[groups.length - 1].messages.push(msg);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (channelId) {
|
||||||
|
const user = $page.data.user;
|
||||||
|
chat = createChat(channelId, {
|
||||||
|
id: user?.id ?? 'anonymous',
|
||||||
|
name: user?.name ?? 'Ukjent'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return () => chat?.destroy();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !channelId}
|
||||||
|
<div class="no-channel">
|
||||||
|
<p>Ingen kanal konfigurert for denne blokken.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="chat">
|
||||||
|
<div class="messages" bind:this={messagesEl}>
|
||||||
|
{#each grouped as group}
|
||||||
|
<div class="date-divider">
|
||||||
|
<span>{group.date}</span>
|
||||||
|
</div>
|
||||||
|
{#each group.messages as msg (msg.id)}
|
||||||
|
<div class="message">
|
||||||
|
<div class="message-header">
|
||||||
|
<span class="author">{msg.author_name ?? 'Ukjent'}</span>
|
||||||
|
<span class="time">{formatTime(msg.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="message-body">{msg.body}</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if messages.length === 0 && !chat?.error}
|
||||||
|
<div class="empty">Ingen meldinger ennå. Skriv noe!</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if chat?.error}
|
||||||
|
<div class="error">{chat.error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="input-row">
|
||||||
|
<textarea
|
||||||
|
bind:this={inputEl}
|
||||||
|
bind:value={input}
|
||||||
|
onkeydown={onKeydown}
|
||||||
|
placeholder="Skriv en melding..."
|
||||||
|
rows="1"
|
||||||
|
></textarea>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={send}
|
||||||
|
disabled={sending || !input.trim()}
|
||||||
|
aria-label="Send"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.75rem 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-divider span {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #8b92a5;
|
||||||
|
background: #0f1117;
|
||||||
|
padding: 0.15rem 0.6rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #7dd3fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #8b92a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-body {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #e1e4e8;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
color: #8b92a5;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #f87171;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid #2d3148;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
flex: 1;
|
||||||
|
background: #0f1117;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #e1e4e8;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: none;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea::placeholder {
|
||||||
|
color: #8b92a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: #3b82f6;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row button:disabled {
|
||||||
|
background: #1e2235;
|
||||||
|
color: #8b92a5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row button:not(:disabled):hover {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-channel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: #8b92a5;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
25
web/src/lib/blocks/GraphBlock.svelte
Normal file
25
web/src/lib/blocks/GraphBlock.svelte
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<script lang="ts">
|
||||||
|
let { props = {} }: { props?: Record<string, unknown> } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="placeholder">
|
||||||
|
<span class="icon">🕸️</span>
|
||||||
|
<p class="label">Graph</p>
|
||||||
|
<p class="hint">Kommer snart</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
color: #8b92a5;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.icon { font-size: 2rem; }
|
||||||
|
.label { font-weight: 600; color: #e1e4e8; }
|
||||||
|
.hint { font-size: 0.8rem; }
|
||||||
|
</style>
|
||||||
25
web/src/lib/blocks/KanbanBlock.svelte
Normal file
25
web/src/lib/blocks/KanbanBlock.svelte
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<script lang="ts">
|
||||||
|
let { props = {} }: { props?: Record<string, unknown> } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="placeholder">
|
||||||
|
<span class="icon">📋</span>
|
||||||
|
<p class="label">Kanban</p>
|
||||||
|
<p class="hint">Kommer snart</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
color: #8b92a5;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.icon { font-size: 2rem; }
|
||||||
|
.label { font-weight: 600; color: #e1e4e8; }
|
||||||
|
.hint { font-size: 0.8rem; }
|
||||||
|
</style>
|
||||||
25
web/src/lib/blocks/NotesBlock.svelte
Normal file
25
web/src/lib/blocks/NotesBlock.svelte
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<script lang="ts">
|
||||||
|
let { props = {} }: { props?: Record<string, unknown> } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="placeholder">
|
||||||
|
<span class="icon">📝</span>
|
||||||
|
<p class="label">Notes</p>
|
||||||
|
<p class="hint">Kommer snart</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
color: #8b92a5;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.icon { font-size: 2rem; }
|
||||||
|
.label { font-weight: 600; color: #e1e4e8; }
|
||||||
|
.hint { font-size: 0.8rem; }
|
||||||
|
</style>
|
||||||
25
web/src/lib/blocks/ResearchBlock.svelte
Normal file
25
web/src/lib/blocks/ResearchBlock.svelte
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<script lang="ts">
|
||||||
|
let { props = {} }: { props?: Record<string, unknown> } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="placeholder">
|
||||||
|
<span class="icon">🔍</span>
|
||||||
|
<p class="label">Research</p>
|
||||||
|
<p class="hint">Kommer snart</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
color: #8b92a5;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.icon { font-size: 2rem; }
|
||||||
|
.label { font-weight: 600; color: #e1e4e8; }
|
||||||
|
.hint { font-size: 0.8rem; }
|
||||||
|
</style>
|
||||||
25
web/src/lib/blocks/StatsBlock.svelte
Normal file
25
web/src/lib/blocks/StatsBlock.svelte
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<script lang="ts">
|
||||||
|
let { props = {} }: { props?: Record<string, unknown> } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="placeholder">
|
||||||
|
<span class="icon">📊</span>
|
||||||
|
<p class="label">Stats</p>
|
||||||
|
<p class="hint">Kommer snart</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
color: #8b92a5;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.icon { font-size: 2rem; }
|
||||||
|
.label { font-weight: 600; color: #e1e4e8; }
|
||||||
|
.hint { font-size: 0.8rem; }
|
||||||
|
</style>
|
||||||
50
web/src/lib/blocks/registry.ts
Normal file
50
web/src/lib/blocks/registry.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import type { Component } from 'svelte';
|
||||||
|
|
||||||
|
export interface BlockMeta {
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
component: () => Promise<{ default: Component }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const blockRegistry: Record<string, BlockMeta> = {
|
||||||
|
chat: {
|
||||||
|
label: 'Chat',
|
||||||
|
icon: '💬',
|
||||||
|
component: () => import('./ChatBlock.svelte')
|
||||||
|
},
|
||||||
|
kanban: {
|
||||||
|
label: 'Kanban',
|
||||||
|
icon: '📋',
|
||||||
|
component: () => import('./KanbanBlock.svelte')
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
label: 'Statistikk',
|
||||||
|
icon: '📊',
|
||||||
|
component: () => import('./StatsBlock.svelte')
|
||||||
|
},
|
||||||
|
graph: {
|
||||||
|
label: 'Kunnskapsgraf',
|
||||||
|
icon: '🕸️',
|
||||||
|
component: () => import('./GraphBlock.svelte')
|
||||||
|
},
|
||||||
|
research: {
|
||||||
|
label: 'Research',
|
||||||
|
icon: '🔍',
|
||||||
|
component: () => import('./ResearchBlock.svelte')
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
label: 'Notater',
|
||||||
|
icon: '📝',
|
||||||
|
component: () => import('./NotesBlock.svelte')
|
||||||
|
},
|
||||||
|
calendar: {
|
||||||
|
label: 'Kalender',
|
||||||
|
icon: '📅',
|
||||||
|
component: () => import('./CalendarBlock.svelte')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const blockTypes = Object.entries(blockRegistry).map(([value, meta]) => ({
|
||||||
|
value,
|
||||||
|
label: `${meta.icon} ${meta.label}`
|
||||||
|
}));
|
||||||
22
web/src/lib/chat/create.svelte.ts
Normal file
22
web/src/lib/chat/create.svelte.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import type { ChatConnection, ChatUser } from './types';
|
||||||
|
import { createPgChat } from './pg.svelte';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { createSpacetimeChat } from './spacetime.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory som velger chat-adapter basert på konfigurasjon.
|
||||||
|
*
|
||||||
|
* Når VITE_SPACETIMEDB_URL er satt, brukes hybrid-adapter
|
||||||
|
* (PG for historikk + SpacetimeDB for sanntid).
|
||||||
|
* Ellers ren PG-polling.
|
||||||
|
*/
|
||||||
|
export function createChat(channelId: string, user: ChatUser): ChatConnection {
|
||||||
|
if (browser) {
|
||||||
|
const spacetimeUrl = import.meta.env.VITE_SPACETIMEDB_URL;
|
||||||
|
if (spacetimeUrl) {
|
||||||
|
return createSpacetimeChat(channelId, spacetimeUrl, 'sidelinja-realtime', user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPgChat(channelId);
|
||||||
|
}
|
||||||
2
web/src/lib/chat/index.ts
Normal file
2
web/src/lib/chat/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export type { Message, ChatConnection, ChatUser } from './types';
|
||||||
|
export { createChat } from './create.svelte';
|
||||||
23
web/src/lib/chat/module_bindings/chat_message_table.ts
Normal file
23
web/src/lib/chat/module_bindings/chat_message_table.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||||
|
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||||
|
|
||||||
|
/* eslint-disable */
|
||||||
|
/* tslint:disable */
|
||||||
|
import {
|
||||||
|
TypeBuilder as __TypeBuilder,
|
||||||
|
t as __t,
|
||||||
|
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||||
|
type Infer as __Infer,
|
||||||
|
} from "spacetimedb";
|
||||||
|
|
||||||
|
export default __t.row({
|
||||||
|
id: __t.string().primaryKey(),
|
||||||
|
channelId: __t.string().name("channel_id"),
|
||||||
|
workspaceId: __t.string().name("workspace_id"),
|
||||||
|
authorId: __t.string().name("author_id"),
|
||||||
|
authorName: __t.string().name("author_name"),
|
||||||
|
body: __t.string(),
|
||||||
|
messageType: __t.string().name("message_type"),
|
||||||
|
replyTo: __t.string().name("reply_to"),
|
||||||
|
createdAt: __t.timestamp().name("created_at"),
|
||||||
|
});
|
||||||
135
web/src/lib/chat/module_bindings/index.ts
Normal file
135
web/src/lib/chat/module_bindings/index.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||||
|
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||||
|
|
||||||
|
// This was generated using spacetimedb cli version 2.0.5 (commit d60138999206c06c776829072f46b5d1c1101f7e).
|
||||||
|
|
||||||
|
/* eslint-disable */
|
||||||
|
/* tslint:disable */
|
||||||
|
import {
|
||||||
|
DbConnectionBuilder as __DbConnectionBuilder,
|
||||||
|
DbConnectionImpl as __DbConnectionImpl,
|
||||||
|
SubscriptionBuilderImpl as __SubscriptionBuilderImpl,
|
||||||
|
TypeBuilder as __TypeBuilder,
|
||||||
|
Uuid as __Uuid,
|
||||||
|
convertToAccessorMap as __convertToAccessorMap,
|
||||||
|
makeQueryBuilder as __makeQueryBuilder,
|
||||||
|
procedureSchema as __procedureSchema,
|
||||||
|
procedures as __procedures,
|
||||||
|
reducerSchema as __reducerSchema,
|
||||||
|
reducers as __reducers,
|
||||||
|
schema as __schema,
|
||||||
|
t as __t,
|
||||||
|
table as __table,
|
||||||
|
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||||
|
type DbConnectionConfig as __DbConnectionConfig,
|
||||||
|
type ErrorContextInterface as __ErrorContextInterface,
|
||||||
|
type Event as __Event,
|
||||||
|
type EventContextInterface as __EventContextInterface,
|
||||||
|
type Infer as __Infer,
|
||||||
|
type QueryBuilder as __QueryBuilder,
|
||||||
|
type ReducerEventContextInterface as __ReducerEventContextInterface,
|
||||||
|
type RemoteModule as __RemoteModule,
|
||||||
|
type SubscriptionEventContextInterface as __SubscriptionEventContextInterface,
|
||||||
|
type SubscriptionHandleImpl as __SubscriptionHandleImpl,
|
||||||
|
} from "spacetimedb";
|
||||||
|
|
||||||
|
// Import all reducer arg schemas
|
||||||
|
import LoadMessagesReducer from "./load_messages_reducer";
|
||||||
|
import MarkSyncedReducer from "./mark_synced_reducer";
|
||||||
|
import SendMessageReducer from "./send_message_reducer";
|
||||||
|
|
||||||
|
// Import all procedure arg schemas
|
||||||
|
|
||||||
|
// Import all table schema definitions
|
||||||
|
import ChatMessageRow from "./chat_message_table";
|
||||||
|
import SyncOutboxRow from "./sync_outbox_table";
|
||||||
|
|
||||||
|
/** Type-only namespace exports for generated type groups. */
|
||||||
|
|
||||||
|
/** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */
|
||||||
|
const tablesSchema = __schema({
|
||||||
|
chat_message: __table({
|
||||||
|
name: 'chat_message',
|
||||||
|
indexes: [
|
||||||
|
{ accessor: 'id', name: 'chat_message_id_idx_btree', algorithm: 'btree', columns: [
|
||||||
|
'id',
|
||||||
|
] },
|
||||||
|
],
|
||||||
|
constraints: [
|
||||||
|
{ name: 'chat_message_id_key', constraint: 'unique', columns: ['id'] },
|
||||||
|
],
|
||||||
|
}, ChatMessageRow),
|
||||||
|
sync_outbox: __table({
|
||||||
|
name: 'sync_outbox',
|
||||||
|
indexes: [
|
||||||
|
{ accessor: 'id', name: 'sync_outbox_id_idx_btree', algorithm: 'btree', columns: [
|
||||||
|
'id',
|
||||||
|
] },
|
||||||
|
],
|
||||||
|
constraints: [
|
||||||
|
{ name: 'sync_outbox_id_key', constraint: 'unique', columns: ['id'] },
|
||||||
|
],
|
||||||
|
}, SyncOutboxRow),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */
|
||||||
|
const reducersSchema = __reducers(
|
||||||
|
__reducerSchema("load_messages", LoadMessagesReducer),
|
||||||
|
__reducerSchema("mark_synced", MarkSyncedReducer),
|
||||||
|
__reducerSchema("send_message", SendMessageReducer),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */
|
||||||
|
const proceduresSchema = __procedures(
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The remote SpacetimeDB module schema, both runtime and type information. */
|
||||||
|
const REMOTE_MODULE = {
|
||||||
|
versionInfo: {
|
||||||
|
cliVersion: "2.0.5" as const,
|
||||||
|
},
|
||||||
|
tables: tablesSchema.schemaType.tables,
|
||||||
|
reducers: reducersSchema.reducersType.reducers,
|
||||||
|
...proceduresSchema,
|
||||||
|
} satisfies __RemoteModule<
|
||||||
|
typeof tablesSchema.schemaType,
|
||||||
|
typeof reducersSchema.reducersType,
|
||||||
|
typeof proceduresSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** The tables available in this remote SpacetimeDB module. Each table reference doubles as a query builder. */
|
||||||
|
export const tables: __QueryBuilder<typeof tablesSchema.schemaType> = __makeQueryBuilder(tablesSchema.schemaType);
|
||||||
|
|
||||||
|
/** The reducers available in this remote SpacetimeDB module. */
|
||||||
|
export const reducers = __convertToAccessorMap(reducersSchema.reducersType.reducers);
|
||||||
|
|
||||||
|
/** The context type returned in callbacks for all possible events. */
|
||||||
|
export type EventContext = __EventContextInterface<typeof REMOTE_MODULE>;
|
||||||
|
/** The context type returned in callbacks for reducer events. */
|
||||||
|
export type ReducerEventContext = __ReducerEventContextInterface<typeof REMOTE_MODULE>;
|
||||||
|
/** The context type returned in callbacks for subscription events. */
|
||||||
|
export type SubscriptionEventContext = __SubscriptionEventContextInterface<typeof REMOTE_MODULE>;
|
||||||
|
/** The context type returned in callbacks for error events. */
|
||||||
|
export type ErrorContext = __ErrorContextInterface<typeof REMOTE_MODULE>;
|
||||||
|
/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */
|
||||||
|
export type SubscriptionHandle = __SubscriptionHandleImpl<typeof REMOTE_MODULE>;
|
||||||
|
|
||||||
|
/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */
|
||||||
|
export class SubscriptionBuilder extends __SubscriptionBuilderImpl<typeof REMOTE_MODULE> {}
|
||||||
|
|
||||||
|
/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */
|
||||||
|
export class DbConnectionBuilder extends __DbConnectionBuilder<DbConnection> {}
|
||||||
|
|
||||||
|
/** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */
|
||||||
|
export class DbConnection extends __DbConnectionImpl<typeof REMOTE_MODULE> {
|
||||||
|
/** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */
|
||||||
|
static builder = (): DbConnectionBuilder => {
|
||||||
|
return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig<typeof REMOTE_MODULE>) => new DbConnection(config));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */
|
||||||
|
override subscriptionBuilder = (): SubscriptionBuilder => {
|
||||||
|
return new SubscriptionBuilder(this);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
15
web/src/lib/chat/module_bindings/load_messages_reducer.ts
Normal file
15
web/src/lib/chat/module_bindings/load_messages_reducer.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||||
|
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||||
|
|
||||||
|
/* eslint-disable */
|
||||||
|
/* tslint:disable */
|
||||||
|
import {
|
||||||
|
TypeBuilder as __TypeBuilder,
|
||||||
|
t as __t,
|
||||||
|
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||||
|
type Infer as __Infer,
|
||||||
|
} from "spacetimedb";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
messagesJson: __t.array(__t.string()),
|
||||||
|
};
|
||||||
15
web/src/lib/chat/module_bindings/mark_synced_reducer.ts
Normal file
15
web/src/lib/chat/module_bindings/mark_synced_reducer.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||||
|
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||||
|
|
||||||
|
/* eslint-disable */
|
||||||
|
/* tslint:disable */
|
||||||
|
import {
|
||||||
|
TypeBuilder as __TypeBuilder,
|
||||||
|
t as __t,
|
||||||
|
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||||
|
type Infer as __Infer,
|
||||||
|
} from "spacetimedb";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
ids: __t.array(__t.u64()),
|
||||||
|
};
|
||||||
20
web/src/lib/chat/module_bindings/send_message_reducer.ts
Normal file
20
web/src/lib/chat/module_bindings/send_message_reducer.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||||
|
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||||
|
|
||||||
|
/* eslint-disable */
|
||||||
|
/* tslint:disable */
|
||||||
|
import {
|
||||||
|
TypeBuilder as __TypeBuilder,
|
||||||
|
t as __t,
|
||||||
|
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||||
|
type Infer as __Infer,
|
||||||
|
} from "spacetimedb";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: __t.string(),
|
||||||
|
channelId: __t.string(),
|
||||||
|
workspaceId: __t.string(),
|
||||||
|
authorName: __t.string(),
|
||||||
|
body: __t.string(),
|
||||||
|
replyTo: __t.string(),
|
||||||
|
};
|
||||||
21
web/src/lib/chat/module_bindings/sync_outbox_table.ts
Normal file
21
web/src/lib/chat/module_bindings/sync_outbox_table.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||||
|
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||||
|
|
||||||
|
/* eslint-disable */
|
||||||
|
/* tslint:disable */
|
||||||
|
import {
|
||||||
|
TypeBuilder as __TypeBuilder,
|
||||||
|
t as __t,
|
||||||
|
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||||
|
type Infer as __Infer,
|
||||||
|
} from "spacetimedb";
|
||||||
|
|
||||||
|
export default __t.row({
|
||||||
|
id: __t.u64().primaryKey(),
|
||||||
|
tableName: __t.string().name("table_name"),
|
||||||
|
action: __t.string(),
|
||||||
|
payload: __t.string(),
|
||||||
|
workspaceId: __t.string().name("workspace_id"),
|
||||||
|
createdAt: __t.timestamp().name("created_at"),
|
||||||
|
synced: __t.bool(),
|
||||||
|
});
|
||||||
36
web/src/lib/chat/module_bindings/types.ts
Normal file
36
web/src/lib/chat/module_bindings/types.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||||
|
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||||
|
|
||||||
|
/* eslint-disable */
|
||||||
|
/* tslint:disable */
|
||||||
|
import {
|
||||||
|
TypeBuilder as __TypeBuilder,
|
||||||
|
t as __t,
|
||||||
|
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||||
|
type Infer as __Infer,
|
||||||
|
} from "spacetimedb";
|
||||||
|
|
||||||
|
export const ChatMessage = __t.object("ChatMessage", {
|
||||||
|
id: __t.string(),
|
||||||
|
channelId: __t.string(),
|
||||||
|
workspaceId: __t.string(),
|
||||||
|
authorId: __t.string(),
|
||||||
|
authorName: __t.string(),
|
||||||
|
body: __t.string(),
|
||||||
|
messageType: __t.string(),
|
||||||
|
replyTo: __t.string(),
|
||||||
|
createdAt: __t.timestamp(),
|
||||||
|
});
|
||||||
|
export type ChatMessage = __Infer<typeof ChatMessage>;
|
||||||
|
|
||||||
|
export const SyncOutbox = __t.object("SyncOutbox", {
|
||||||
|
id: __t.u64(),
|
||||||
|
tableName: __t.string(),
|
||||||
|
action: __t.string(),
|
||||||
|
payload: __t.string(),
|
||||||
|
workspaceId: __t.string(),
|
||||||
|
createdAt: __t.timestamp(),
|
||||||
|
synced: __t.bool(),
|
||||||
|
});
|
||||||
|
export type SyncOutbox = __Infer<typeof SyncOutbox>;
|
||||||
|
|
||||||
10
web/src/lib/chat/module_bindings/types/procedures.ts
Normal file
10
web/src/lib/chat/module_bindings/types/procedures.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||||
|
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||||
|
|
||||||
|
/* eslint-disable */
|
||||||
|
/* tslint:disable */
|
||||||
|
import { type Infer as __Infer } from "spacetimedb";
|
||||||
|
|
||||||
|
// Import all procedure arg schemas
|
||||||
|
|
||||||
|
|
||||||
16
web/src/lib/chat/module_bindings/types/reducers.ts
Normal file
16
web/src/lib/chat/module_bindings/types/reducers.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||||
|
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||||
|
|
||||||
|
/* eslint-disable */
|
||||||
|
/* tslint:disable */
|
||||||
|
import { type Infer as __Infer } from "spacetimedb";
|
||||||
|
|
||||||
|
// Import all reducer arg schemas
|
||||||
|
import LoadMessagesReducer from "../load_messages_reducer";
|
||||||
|
import MarkSyncedReducer from "../mark_synced_reducer";
|
||||||
|
import SendMessageReducer from "../send_message_reducer";
|
||||||
|
|
||||||
|
export type LoadMessagesParams = __Infer<typeof LoadMessagesReducer>;
|
||||||
|
export type MarkSyncedParams = __Infer<typeof MarkSyncedReducer>;
|
||||||
|
export type SendMessageParams = __Infer<typeof SendMessageReducer>;
|
||||||
|
|
||||||
60
web/src/lib/chat/pg.svelte.ts
Normal file
60
web/src/lib/chat/pg.svelte.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import type { Message, ChatConnection } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chat-adapter som poller PostgreSQL via REST API.
|
||||||
|
* Brukes som fallback når SpacetimeDB ikke er tilgjengelig,
|
||||||
|
* og som referanseimplementasjon for testing.
|
||||||
|
*/
|
||||||
|
export function createPgChat(channelId: string): ChatConnection {
|
||||||
|
let messages = $state<Message[]>([]);
|
||||||
|
let error = $state('');
|
||||||
|
let connected = $state(false);
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let destroyed = false;
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
if (destroyed) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/channels/${channelId}/messages`);
|
||||||
|
if (!res.ok) throw new Error('Feil ved lasting');
|
||||||
|
messages = await res.json();
|
||||||
|
error = '';
|
||||||
|
connected = true;
|
||||||
|
} catch {
|
||||||
|
error = 'Kunne ikke laste meldinger';
|
||||||
|
connected = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send(body: string) {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/channels/${channelId}/messages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ body })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Feil ved sending');
|
||||||
|
await refresh();
|
||||||
|
} catch {
|
||||||
|
error = 'Kunne ikke sende melding';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
destroyed = true;
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start polling
|
||||||
|
refresh();
|
||||||
|
timer = setInterval(refresh, 3000);
|
||||||
|
|
||||||
|
return {
|
||||||
|
get messages() { return messages; },
|
||||||
|
get error() { return error; },
|
||||||
|
get connected() { return connected; },
|
||||||
|
send,
|
||||||
|
destroy
|
||||||
|
};
|
||||||
|
}
|
||||||
163
web/src/lib/chat/spacetime.svelte.ts
Normal file
163
web/src/lib/chat/spacetime.svelte.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
import type { Message, ChatConnection, ChatUser } from './types';
|
||||||
|
import { DbConnection, type EventContext } from './module_bindings';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hybrid chat-adapter:
|
||||||
|
* - Henter eksisterende meldinger fra PostgreSQL via REST (som PG-adapteren)
|
||||||
|
* - Lytter på nye meldinger i sanntid via SpacetimeDB WebSocket
|
||||||
|
* - Sender nye meldinger via SpacetimeDB reducer (→ synkes til PG av worker)
|
||||||
|
*
|
||||||
|
* Ingen oppvarming nødvendig — PG har historikken, SpacetimeDB har sanntid.
|
||||||
|
*/
|
||||||
|
export function createSpacetimeChat(
|
||||||
|
channelId: string,
|
||||||
|
spacetimeUrl: string,
|
||||||
|
moduleName: string,
|
||||||
|
user: ChatUser
|
||||||
|
): ChatConnection {
|
||||||
|
let messages = $state<Message[]>([]);
|
||||||
|
let error = $state('');
|
||||||
|
let connected = $state(false);
|
||||||
|
let conn: InstanceType<typeof DbConnection> | null = null;
|
||||||
|
let destroyed = false;
|
||||||
|
|
||||||
|
// Hent historikk fra PG
|
||||||
|
async function loadFromPg() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/channels/${channelId}/messages`);
|
||||||
|
if (!res.ok) throw new Error('Feil ved lasting');
|
||||||
|
messages = await res.json();
|
||||||
|
} catch {
|
||||||
|
error = 'Kunne ikke laste meldinger';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Koble til SpacetimeDB for sanntidsoppdateringer
|
||||||
|
function connectRealtime() {
|
||||||
|
try {
|
||||||
|
conn = DbConnection.builder()
|
||||||
|
.withUri(spacetimeUrl)
|
||||||
|
.withDatabaseName(moduleName)
|
||||||
|
.onConnect((connection) => {
|
||||||
|
if (destroyed) return;
|
||||||
|
connected = true;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem('spacetime_token', '');
|
||||||
|
} catch { /* SSR-safe */ }
|
||||||
|
|
||||||
|
// Abonner på meldinger for denne kanalen
|
||||||
|
connection.subscriptionBuilder()
|
||||||
|
.onError(() => {
|
||||||
|
console.error('[spacetime] subscription error');
|
||||||
|
})
|
||||||
|
.subscribe([
|
||||||
|
`SELECT * FROM chat_message WHERE channel_id = '${channelId}'`
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
.onDisconnect(() => {
|
||||||
|
connected = false;
|
||||||
|
})
|
||||||
|
.onConnectError((_ctx, err) => {
|
||||||
|
console.warn('[spacetime] connection error, PG-data beholdes:', err);
|
||||||
|
// Beholder PG-data — ingen error til bruker
|
||||||
|
})
|
||||||
|
.withToken(getStoredToken() ?? '')
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Nye meldinger i sanntid
|
||||||
|
conn.db.chat_message.onInsert((ctx: EventContext, row) => {
|
||||||
|
if (destroyed) return;
|
||||||
|
if (row.channelId !== channelId) return;
|
||||||
|
// Dedupliser mot PG-data
|
||||||
|
if (messages.some(m => m.id === row.id)) return;
|
||||||
|
|
||||||
|
const msg = spacetimeRowToMessage(row);
|
||||||
|
messages = [...messages, msg];
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[spacetime] setup feilet, bruker kun PG:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function spacetimeRowToMessage(row: any): Message {
|
||||||
|
let createdAt: string;
|
||||||
|
try {
|
||||||
|
const micros = row.createdAt?.microsSinceEpoch;
|
||||||
|
const ms = typeof micros === 'bigint' ? Number(micros / 1000n) : Number(micros) / 1000;
|
||||||
|
createdAt = new Date(ms).toISOString();
|
||||||
|
} catch {
|
||||||
|
createdAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
body: row.body,
|
||||||
|
message_type: row.messageType,
|
||||||
|
created_at: createdAt,
|
||||||
|
author_name: row.authorName || null,
|
||||||
|
author_id: row.authorId || null,
|
||||||
|
reply_to: row.replyTo || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send(body: string) {
|
||||||
|
if (conn && connected) {
|
||||||
|
// Send via SpacetimeDB — umiddelbar push til alle klienter
|
||||||
|
try {
|
||||||
|
conn.reducers.sendMessage({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
channelId,
|
||||||
|
workspaceId: '',
|
||||||
|
authorName: user.name,
|
||||||
|
body,
|
||||||
|
replyTo: ''
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// Fall gjennom til PG
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: send via PG REST API
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/channels/${channelId}/messages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ body })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Feil ved sending');
|
||||||
|
await loadFromPg();
|
||||||
|
} catch {
|
||||||
|
error = 'Kunne ikke sende melding';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
destroyed = true;
|
||||||
|
if (conn) {
|
||||||
|
conn.disconnect();
|
||||||
|
conn = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoredToken(): string | undefined {
|
||||||
|
try {
|
||||||
|
return sessionStorage.getItem('spacetime_token') ?? undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begge deler parallelt
|
||||||
|
loadFromPg();
|
||||||
|
connectRealtime();
|
||||||
|
|
||||||
|
return {
|
||||||
|
get messages() { return messages; },
|
||||||
|
get error() { return error; },
|
||||||
|
get connected() { return connected; },
|
||||||
|
send,
|
||||||
|
destroy
|
||||||
|
};
|
||||||
|
}
|
||||||
27
web/src/lib/chat/types.ts
Normal file
27
web/src/lib/chat/types.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
export interface ChatUser {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
body: string;
|
||||||
|
message_type: string;
|
||||||
|
created_at: string;
|
||||||
|
author_name: string | null;
|
||||||
|
author_id: string | null;
|
||||||
|
reply_to: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Felles kontrakt for chat-tilkoblinger.
|
||||||
|
* Implementeres av PG-polling og SpacetimeDB.
|
||||||
|
* Alle felter er reaktive (Svelte 5 $state).
|
||||||
|
*/
|
||||||
|
export interface ChatConnection {
|
||||||
|
readonly messages: Message[];
|
||||||
|
readonly error: string;
|
||||||
|
readonly connected: boolean;
|
||||||
|
send(body: string): Promise<void>;
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
48
web/src/lib/components/BlockShell.svelte
Normal file
48
web/src/lib/components/BlockShell.svelte
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let { title, children }: { title: string; children: Snippet } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="block-shell">
|
||||||
|
<div class="block-header">
|
||||||
|
<span class="block-title">{title}</span>
|
||||||
|
</div>
|
||||||
|
<div class="block-content">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.block-shell {
|
||||||
|
background: #161822;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-bottom: 1px solid #2d3148;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-title {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #8b92a5;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
61
web/src/lib/components/PageGrid.svelte
Normal file
61
web/src/lib/components/PageGrid.svelte
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Component } from 'svelte';
|
||||||
|
import type { PageConfig } from '$lib/types/pages';
|
||||||
|
import { getGridColumns } from '$lib/types/pages';
|
||||||
|
import { blockRegistry } from '$lib/blocks/registry';
|
||||||
|
import BlockShell from './BlockShell.svelte';
|
||||||
|
|
||||||
|
let { page }: { page: PageConfig } = $props();
|
||||||
|
|
||||||
|
let resolved = $state<Record<string, Component>>({});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
for (const block of page.blocks) {
|
||||||
|
const meta = blockRegistry[block.type];
|
||||||
|
if (meta && !resolved[block.id]) {
|
||||||
|
meta.component().then((m) => {
|
||||||
|
resolved[block.id] = m.default;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let gridColumns = $derived(getGridColumns(page.layout));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page-grid" style:grid-template-columns={gridColumns}>
|
||||||
|
{#each page.blocks as block (block.id)}
|
||||||
|
<BlockShell title={block.title}>
|
||||||
|
{@const BlockComponent = resolved[block.id]}
|
||||||
|
{#if BlockComponent}
|
||||||
|
<BlockComponent props={block.props ?? {}} />
|
||||||
|
{:else}
|
||||||
|
<div class="loading">Laster...</div>
|
||||||
|
{/if}
|
||||||
|
</BlockShell>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
height: calc(100vh - 48px - 3rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.page-grid {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: #8b92a5;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
282
web/src/lib/components/Sidebar.svelte
Normal file
282
web/src/lib/components/Sidebar.svelte
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Workspace } from '$lib/server/db';
|
||||||
|
import type { PageConfig } from '$lib/types/pages';
|
||||||
|
import WorkspaceSwitcher from './WorkspaceSwitcher.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(false),
|
||||||
|
user,
|
||||||
|
workspace,
|
||||||
|
workspaces,
|
||||||
|
authProvider
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
user: { id: string; name: string; email: string; image?: string };
|
||||||
|
workspace: Workspace | null;
|
||||||
|
workspaces: Workspace[];
|
||||||
|
authProvider: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Swipe-fra-venstre detection
|
||||||
|
let touchStartX = 0;
|
||||||
|
let touchCurrentX = 0;
|
||||||
|
let swiping = $state(false);
|
||||||
|
let swipeOffset = $state(0);
|
||||||
|
|
||||||
|
const SIDEBAR_WIDTH = 280;
|
||||||
|
const EDGE_THRESHOLD = 30;
|
||||||
|
const SWIPE_THRESHOLD = 80;
|
||||||
|
|
||||||
|
function onTouchStart(e: TouchEvent) {
|
||||||
|
const x = e.touches[0].clientX;
|
||||||
|
// Åpne: swipe fra venstre kant
|
||||||
|
if (!open && x < EDGE_THRESHOLD) {
|
||||||
|
swiping = true;
|
||||||
|
touchStartX = x;
|
||||||
|
touchCurrentX = x;
|
||||||
|
swipeOffset = 0;
|
||||||
|
}
|
||||||
|
// Lukke: swipe tilbake mot venstre
|
||||||
|
if (open) {
|
||||||
|
swiping = true;
|
||||||
|
touchStartX = x;
|
||||||
|
touchCurrentX = x;
|
||||||
|
swipeOffset = SIDEBAR_WIDTH;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchMove(e: TouchEvent) {
|
||||||
|
if (!swiping) return;
|
||||||
|
touchCurrentX = e.touches[0].clientX;
|
||||||
|
const delta = touchCurrentX - touchStartX;
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
// Åpner: offset 0 → SIDEBAR_WIDTH
|
||||||
|
swipeOffset = Math.max(0, Math.min(SIDEBAR_WIDTH, delta));
|
||||||
|
} else {
|
||||||
|
// Lukker: offset SIDEBAR_WIDTH → 0
|
||||||
|
swipeOffset = Math.max(0, Math.min(SIDEBAR_WIDTH, SIDEBAR_WIDTH + delta));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchEnd() {
|
||||||
|
if (!swiping) return;
|
||||||
|
swiping = false;
|
||||||
|
|
||||||
|
if (!open && swipeOffset > SWIPE_THRESHOLD) {
|
||||||
|
open = true;
|
||||||
|
} else if (open && swipeOffset < SIDEBAR_WIDTH - SWIPE_THRESHOLD) {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
swipeOffset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape' && open) {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pages = $derived<PageConfig[]>(
|
||||||
|
((workspace?.settings as Record<string, unknown>)?.pages as PageConfig[]) ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
// Beregn transform basert på state
|
||||||
|
let sidebarTransform = $derived.by(() => {
|
||||||
|
if (swiping) {
|
||||||
|
return `translateX(${swipeOffset - SIDEBAR_WIDTH}px)`;
|
||||||
|
}
|
||||||
|
return open ? 'translateX(0)' : `translateX(-100%)`;
|
||||||
|
});
|
||||||
|
|
||||||
|
let backdropOpacity = $derived.by(() => {
|
||||||
|
if (swiping) {
|
||||||
|
return swipeOffset / SIDEBAR_WIDTH;
|
||||||
|
}
|
||||||
|
return open ? 1 : 0;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={onKeydown} />
|
||||||
|
<svelte:document
|
||||||
|
on:touchstart={onTouchStart}
|
||||||
|
on:touchmove={onTouchMove}
|
||||||
|
on:touchend={onTouchEnd}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if open || swiping}
|
||||||
|
<button
|
||||||
|
class="backdrop"
|
||||||
|
class:no-transition={swiping}
|
||||||
|
style:opacity={backdropOpacity}
|
||||||
|
onclick={() => (open = false)}
|
||||||
|
aria-label="Lukk meny"
|
||||||
|
tabindex="-1"
|
||||||
|
></button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<nav
|
||||||
|
class="sidebar"
|
||||||
|
class:no-transition={swiping}
|
||||||
|
style:transform={sidebarTransform}
|
||||||
|
>
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<div class="sidebar-title-row">
|
||||||
|
<h1>Sidelinja</h1>
|
||||||
|
<button class="close-btn" onclick={() => (open = false)} aria-label="Lukk meny">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<WorkspaceSwitcher {workspaces} active={workspace} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="nav-links">
|
||||||
|
<li><a href="/" onclick={() => (open = false)}>Oversikt</a></li>
|
||||||
|
{#each pages as page}
|
||||||
|
<li>
|
||||||
|
<a href="/p/{page.slug}" onclick={() => (open = false)}>
|
||||||
|
{#if page.icon}<span class="nav-icon">{page.icon}</span>{/if}
|
||||||
|
{page.title}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
<li class="nav-divider"></li>
|
||||||
|
<li><a href="/admin/pages" onclick={() => (open = false)}>Rediger sider</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<span class="user-name">{user.name}</span>
|
||||||
|
<form method="POST" action="/auth/signout">
|
||||||
|
<button type="submit">Logg ut</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 90;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop.no-transition {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 100;
|
||||||
|
width: 280px;
|
||||||
|
background: #161822;
|
||||||
|
border-right: 1px solid #2d3148;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.no-transition {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title-row h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #8b92a5;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background: #1e2235;
|
||||||
|
color: #e1e4e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 1rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
color: #8b92a5;
|
||||||
|
text-decoration: none;
|
||||||
|
display: block;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover {
|
||||||
|
background: #1e2235;
|
||||||
|
color: #e1e4e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon {
|
||||||
|
margin-right: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: #2d3148;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid #2d3148;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #8b92a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer button {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
color: #8b92a5;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer button:hover {
|
||||||
|
border-color: #8b92a5;
|
||||||
|
color: #e1e4e8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
88
web/src/lib/components/WorkspaceSwitcher.svelte
Normal file
88
web/src/lib/components/WorkspaceSwitcher.svelte
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Workspace } from '$lib/server/db';
|
||||||
|
|
||||||
|
let { workspaces, active }: { workspaces: Workspace[]; active: Workspace | null } = $props();
|
||||||
|
let open = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if workspaces.length > 0}
|
||||||
|
<div class="switcher">
|
||||||
|
<button class="trigger" onclick={() => (open = !open)}>
|
||||||
|
<span class="workspace-name">{active?.name ?? 'Velg workspace'}</span>
|
||||||
|
<span class="chevron">{open ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open && workspaces.length > 1}
|
||||||
|
<ul class="dropdown">
|
||||||
|
{#each workspaces as ws}
|
||||||
|
{#if ws.id !== active?.id}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/?switch_workspace={ws.id}"
|
||||||
|
onclick={() => (open = false)}
|
||||||
|
>
|
||||||
|
{ws.name}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.switcher {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: #1e2235;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #e1e4e8;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger:hover {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #8b92a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: #1e2235;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
border-radius: 6px;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0.25rem;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown a {
|
||||||
|
display: block;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
color: #8b92a5;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown a:hover {
|
||||||
|
background: #262a3e;
|
||||||
|
color: #e1e4e8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
63
web/src/lib/server/auth.ts
Normal file
63
web/src/lib/server/auth.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { SvelteKitAuth } from '@auth/sveltekit';
|
||||||
|
import Credentials from '@auth/core/providers/credentials';
|
||||||
|
import type { Provider } from '@auth/core/providers';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
|
const isDev = env.NODE_ENV !== 'production' && !env.AUTHENTIK_CLIENT_ID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentik OIDC-provider (produksjon).
|
||||||
|
*/
|
||||||
|
const authentik: Provider = {
|
||||||
|
id: 'authentik',
|
||||||
|
name: 'Authentik',
|
||||||
|
type: 'oidc',
|
||||||
|
issuer: env.AUTHENTIK_ISSUER,
|
||||||
|
clientId: env.AUTHENTIK_CLIENT_ID,
|
||||||
|
clientSecret: env.AUTHENTIK_CLIENT_SECRET,
|
||||||
|
profile(profile) {
|
||||||
|
return {
|
||||||
|
id: profile.sub,
|
||||||
|
name: profile.name ?? profile.preferred_username,
|
||||||
|
email: profile.email,
|
||||||
|
image: profile.picture
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev-only credentials provider.
|
||||||
|
* Logger inn som dev-user-1 uten OIDC.
|
||||||
|
*/
|
||||||
|
const devCredentials = Credentials({
|
||||||
|
id: 'dev-login',
|
||||||
|
name: 'Dev Login',
|
||||||
|
credentials: {},
|
||||||
|
authorize() {
|
||||||
|
return {
|
||||||
|
id: 'dev-user-1',
|
||||||
|
name: 'Vegard',
|
||||||
|
email: 'vegard@localhost'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { handle, signIn, signOut } = SvelteKitAuth({
|
||||||
|
providers: isDev ? [devCredentials] : [authentik],
|
||||||
|
secret: env.AUTH_SECRET || 'dev-secret-not-for-production',
|
||||||
|
trustHost: true,
|
||||||
|
callbacks: {
|
||||||
|
jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
token.id = user.id;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
session({ session, token }) {
|
||||||
|
if (session.user && token.id) {
|
||||||
|
session.user.id = token.id as string;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
46
web/src/lib/server/db.ts
Normal file
46
web/src/lib/server/db.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
|
export const sql = postgres(env.DATABASE_URL || 'postgres://sidelinja:localdev@localhost:5432/sidelinja', {
|
||||||
|
max: 10,
|
||||||
|
idle_timeout: 30,
|
||||||
|
connect_timeout: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface Workspace {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
domain: string | null;
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hent alle workspaces brukeren er medlem av */
|
||||||
|
export async function getUserWorkspaces(userId: string): Promise<Workspace[]> {
|
||||||
|
return sql<Workspace[]>`
|
||||||
|
SELECT w.id, w.name, w.slug, w.domain, w.settings
|
||||||
|
FROM workspaces w
|
||||||
|
JOIN workspace_members wm ON wm.workspace_id = w.id
|
||||||
|
WHERE wm.user_id = ${userId}
|
||||||
|
ORDER BY w.name
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hent én workspace, verifiser at brukeren har tilgang */
|
||||||
|
export async function getWorkspaceForUser(workspaceId: string, userId: string): Promise<Workspace | null> {
|
||||||
|
const rows = await sql<Workspace[]>`
|
||||||
|
SELECT w.id, w.name, w.slug, w.domain, w.settings
|
||||||
|
FROM workspaces w
|
||||||
|
JOIN workspace_members wm ON wm.workspace_id = w.id
|
||||||
|
WHERE w.id = ${workspaceId} AND wm.user_id = ${userId}
|
||||||
|
`;
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sett workspace-kontekst for RLS.
|
||||||
|
* Kall dette før spørringer som trenger workspace-isolasjon.
|
||||||
|
*/
|
||||||
|
export async function setWorkspaceContext(workspaceId: string) {
|
||||||
|
await sql`SELECT set_config('app.current_workspace_id', ${workspaceId}, true)`;
|
||||||
|
}
|
||||||
28
web/src/lib/types/pages.ts
Normal file
28
web/src/lib/types/pages.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
export type LayoutTemplate = '1-col' | '2-col' | '2-1' | '1-2' | '3-col';
|
||||||
|
|
||||||
|
export interface BlockConfig {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
props?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageConfig {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
icon?: string;
|
||||||
|
layout: LayoutTemplate;
|
||||||
|
blocks: BlockConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const layoutOptions: { value: LayoutTemplate; label: string; grid: string }[] = [
|
||||||
|
{ value: '1-col', label: '1 kolonne', grid: '1fr' },
|
||||||
|
{ value: '2-col', label: '2 kolonner', grid: '1fr 1fr' },
|
||||||
|
{ value: '2-1', label: '2/3 + 1/3', grid: '2fr 1fr' },
|
||||||
|
{ value: '1-2', label: '1/3 + 2/3', grid: '1fr 2fr' },
|
||||||
|
{ value: '3-col', label: '3 kolonner', grid: '1fr 1fr 1fr' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getGridColumns(layout: LayoutTemplate): string {
|
||||||
|
return layoutOptions.find((o) => o.value === layout)?.grid ?? '1fr';
|
||||||
|
}
|
||||||
18
web/src/routes/+layout.server.ts
Normal file
18
web/src/routes/+layout.server.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
import { getUserWorkspaces } from '$lib/server/db';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
|
const isDev = env.NODE_ENV !== 'production' && !env.AUTHENTIK_CLIENT_ID;
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||||
|
const workspaces = locals.user
|
||||||
|
? await getUserWorkspaces(locals.user.id)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: locals.user,
|
||||||
|
workspace: locals.workspace,
|
||||||
|
workspaces,
|
||||||
|
authProvider: isDev ? 'dev-login' : 'authentik'
|
||||||
|
};
|
||||||
|
};
|
||||||
152
web/src/routes/+layout.svelte
Normal file
152
web/src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { LayoutData } from './$types';
|
||||||
|
import WorkspaceSwitcher from '$lib/components/WorkspaceSwitcher.svelte';
|
||||||
|
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||||
|
|
||||||
|
let { data, children } = $props<{ data: LayoutData; children: any }>();
|
||||||
|
|
||||||
|
let sidebarOpen = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Sidelinja</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
{#if data.user}
|
||||||
|
<div class="app">
|
||||||
|
<header class="topbar">
|
||||||
|
<button class="hamburger" onclick={() => (sidebarOpen = true)} aria-label="Åpne meny">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6" />
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12" />
|
||||||
|
<line x1="3" y1="18" x2="21" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span class="topbar-title">{data.workspace?.name ?? 'Sidelinja'}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Sidebar
|
||||||
|
bind:open={sidebarOpen}
|
||||||
|
user={data.user}
|
||||||
|
workspace={data.workspace}
|
||||||
|
workspaces={data.workspaces}
|
||||||
|
authProvider={data.authProvider}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="login-page">
|
||||||
|
<h1>Sidelinja</h1>
|
||||||
|
<p>Redaksjonelt operativsystem for podcast-produksjon</p>
|
||||||
|
<form method="POST" action="/auth/callback/credentials">
|
||||||
|
<input type="hidden" name="csrfToken" value="" />
|
||||||
|
{#if data.authProvider === 'dev-login'}
|
||||||
|
<button type="submit" formaction="/auth/signin/dev-login">Dev Login</button>
|
||||||
|
{:else}
|
||||||
|
<button type="submit" formaction="/auth/signin/authentik">Logg inn med Authentik</button>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(*, *::before, *::after) {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body) {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #0f1117;
|
||||||
|
color: #e1e4e8;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 1rem;
|
||||||
|
background: #161822;
|
||||||
|
border-bottom: 1px solid #2d3148;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #8b92a5;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger:hover {
|
||||||
|
background: #1e2235;
|
||||||
|
color: #e1e4e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e1e4e8;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page p {
|
||||||
|
color: #8b92a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page button {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page button:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
22
web/src/routes/+page.server.ts
Normal file
22
web/src/routes/+page.server.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
const WORKSPACE_COOKIE = 'sidelinja_workspace';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ url, cookies, locals }) => {
|
||||||
|
const switchTo = url.searchParams.get('switch_workspace');
|
||||||
|
|
||||||
|
if (switchTo && locals.user) {
|
||||||
|
const { getWorkspaceForUser } = await import('$lib/server/db');
|
||||||
|
const ws = await getWorkspaceForUser(switchTo, locals.user.id);
|
||||||
|
if (ws) {
|
||||||
|
cookies.set(WORKSPACE_COOKIE, ws.id, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 60 * 60 * 24 * 365
|
||||||
|
});
|
||||||
|
}
|
||||||
|
redirect(303, '/');
|
||||||
|
}
|
||||||
|
};
|
||||||
13
web/src/routes/+page.svelte
Normal file
13
web/src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data } = $props<{ data: PageData }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if data.workspace}
|
||||||
|
<h2>{data.workspace.name}</h2>
|
||||||
|
<p style="color: #8b92a5; margin-top: 0.5rem;">Workspace: {data.workspace.slug}</p>
|
||||||
|
{:else}
|
||||||
|
<h2>Ingen workspace</h2>
|
||||||
|
<p style="color: #8b92a5; margin-top: 0.5rem;">Du er ikke medlem av noen workspace ennå.</p>
|
||||||
|
{/if}
|
||||||
53
web/src/routes/admin/pages/+page.server.ts
Normal file
53
web/src/routes/admin/pages/+page.server.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { error, fail } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
import type { PageConfig } from '$lib/types/pages';
|
||||||
|
import { sql } from '$lib/server/db';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
if (!locals.workspace) error(404);
|
||||||
|
|
||||||
|
const settings = locals.workspace.settings as Record<string, unknown>;
|
||||||
|
const pages = (settings?.pages as PageConfig[]) ?? [];
|
||||||
|
|
||||||
|
return { pages };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
save: async ({ request, locals }) => {
|
||||||
|
if (!locals.workspace) error(401);
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const raw = formData.get('pages');
|
||||||
|
|
||||||
|
if (typeof raw !== 'string') return fail(400, { error: 'Mangler sidedata' });
|
||||||
|
|
||||||
|
let pages: PageConfig[];
|
||||||
|
try {
|
||||||
|
pages = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return fail(400, { error: 'Ugyldig JSON' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enkel validering
|
||||||
|
for (const page of pages) {
|
||||||
|
if (!page.slug || !page.title || !page.layout || !Array.isArray(page.blocks)) {
|
||||||
|
return fail(400, { error: `Ugyldig side: ${page.title || '(uten tittel)'}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
UPDATE workspaces
|
||||||
|
SET settings = jsonb_set(
|
||||||
|
COALESCE(settings, '{}'::jsonb),
|
||||||
|
'{pages}',
|
||||||
|
${sql.json(pages as any)}
|
||||||
|
)
|
||||||
|
WHERE id = ${locals.workspace.id}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Oppdater locals så sidebar reflekterer endringer umiddelbart
|
||||||
|
(locals.workspace.settings as Record<string, unknown>).pages = pages;
|
||||||
|
|
||||||
|
return { success: true, pages };
|
||||||
|
}
|
||||||
|
};
|
||||||
417
web/src/routes/admin/pages/+page.svelte
Normal file
417
web/src/routes/admin/pages/+page.svelte
Normal file
|
|
@ -0,0 +1,417 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
import type { PageConfig, BlockConfig, LayoutTemplate } from '$lib/types/pages';
|
||||||
|
import { layoutOptions } from '$lib/types/pages';
|
||||||
|
import { blockTypes } from '$lib/blocks/registry';
|
||||||
|
|
||||||
|
let { data, form } = $props<{ data: PageData; form: any }>();
|
||||||
|
|
||||||
|
let initialPages = $derived(data.pages);
|
||||||
|
let pages = $state<PageConfig[]>([]);
|
||||||
|
let initialized = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!initialized) {
|
||||||
|
pages = structuredClone(initialPages);
|
||||||
|
initialized = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let editingIndex = $state<number | null>(null);
|
||||||
|
|
||||||
|
let editing = $derived(editingIndex !== null ? pages[editingIndex] : null);
|
||||||
|
|
||||||
|
function addPage() {
|
||||||
|
pages.push({
|
||||||
|
slug: '',
|
||||||
|
title: '',
|
||||||
|
icon: '',
|
||||||
|
layout: '2-col',
|
||||||
|
blocks: []
|
||||||
|
});
|
||||||
|
editingIndex = pages.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePage(i: number) {
|
||||||
|
pages.splice(i, 1);
|
||||||
|
if (editingIndex === i) editingIndex = null;
|
||||||
|
else if (editingIndex !== null && editingIndex > i) editingIndex--;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addBlock() {
|
||||||
|
if (editing) {
|
||||||
|
editing.blocks.push({
|
||||||
|
id: `block-${Date.now()}`,
|
||||||
|
type: 'chat',
|
||||||
|
title: 'Ny blokk'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeBlock(blockIndex: number) {
|
||||||
|
if (editing) {
|
||||||
|
editing.blocks.splice(blockIndex, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveBlock(blockIndex: number, dir: -1 | 1) {
|
||||||
|
if (!editing) return;
|
||||||
|
const target = blockIndex + dir;
|
||||||
|
if (target < 0 || target >= editing.blocks.length) return;
|
||||||
|
const temp = editing.blocks[blockIndex];
|
||||||
|
editing.blocks[blockIndex] = editing.blocks[target];
|
||||||
|
editing.blocks[target] = temp;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(text: string): string {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[æå]/g, 'a')
|
||||||
|
.replace(/ø/g, 'o')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTitleInput(e: Event) {
|
||||||
|
if (!editing) return;
|
||||||
|
const title = (e.target as HTMLInputElement).value;
|
||||||
|
editing.title = title;
|
||||||
|
// Auto-slug bare hvis slug er tom eller matcher forrige auto-slug
|
||||||
|
if (!editing.slug || editing.slug === slugify(editing.title)) {
|
||||||
|
editing.slug = slugify(title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pagesJson = $derived(JSON.stringify(pages));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="admin-pages">
|
||||||
|
<div class="header">
|
||||||
|
<h2>Sider</h2>
|
||||||
|
<button class="btn btn-primary" onclick={addPage}>+ Ny side</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if form?.error}
|
||||||
|
<div class="error-msg">{form.error}</div>
|
||||||
|
{/if}
|
||||||
|
{#if form?.success}
|
||||||
|
<div class="success-msg">Lagret!</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="page-list">
|
||||||
|
{#each pages as page, i}
|
||||||
|
<div class="page-item" class:active={editingIndex === i}>
|
||||||
|
<div class="page-item-info">
|
||||||
|
<span class="page-icon">{page.icon || '📄'}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{page.title || '(uten tittel)'}</strong>
|
||||||
|
<span class="page-slug">/{page.slug || '...'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="page-item-actions">
|
||||||
|
<button class="btn btn-small" onclick={() => (editingIndex = editingIndex === i ? null : i)}>
|
||||||
|
{editingIndex === i ? 'Lukk' : 'Rediger'}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-small btn-danger" onclick={() => removePage(i)}>Slett</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if editingIndex === i && editing}
|
||||||
|
<div class="edit-panel">
|
||||||
|
<div class="field-row">
|
||||||
|
<label>
|
||||||
|
Tittel
|
||||||
|
<input type="text" value={editing.title} oninput={onTitleInput} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Slug
|
||||||
|
<input type="text" bind:value={editing.slug} />
|
||||||
|
</label>
|
||||||
|
<label class="field-small">
|
||||||
|
Ikon
|
||||||
|
<input type="text" bind:value={editing.icon} placeholder="📰" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Layout
|
||||||
|
<select bind:value={editing.layout}>
|
||||||
|
{#each layoutOptions as opt}
|
||||||
|
<option value={opt.value}>{opt.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="blocks-section">
|
||||||
|
<div class="blocks-header">
|
||||||
|
<h3>Blokker</h3>
|
||||||
|
<button class="btn btn-small" onclick={addBlock}>+ Legg til</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#each editing.blocks as block, bi}
|
||||||
|
<div class="block-row">
|
||||||
|
<select bind:value={block.type}>
|
||||||
|
{#each blockTypes as bt}
|
||||||
|
<option value={bt.value}>{bt.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<input type="text" bind:value={block.title} placeholder="Tittel" />
|
||||||
|
<div class="block-row-actions">
|
||||||
|
<button class="btn btn-icon" onclick={() => moveBlock(bi, -1)} disabled={bi === 0}>↑</button>
|
||||||
|
<button class="btn btn-icon" onclick={() => moveBlock(bi, 1)} disabled={bi === editing.blocks.length - 1}>↓</button>
|
||||||
|
<button class="btn btn-icon btn-danger" onclick={() => removeBlock(bi)}>×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if editing.blocks.length === 0}
|
||||||
|
<p class="hint">Ingen blokker ennå. Klikk «+ Legg til» for å begynne.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if pages.length === 0}
|
||||||
|
<p class="hint">Ingen sider konfigurert. Klikk «+ Ny side» for å lage den første.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="?/save" class="save-form">
|
||||||
|
<input type="hidden" name="pages" value={pagesJson} />
|
||||||
|
<button type="submit" class="btn btn-primary btn-save">Lagre alle sider</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.admin-pages {
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
background: #3b1219;
|
||||||
|
border: 1px solid #6b2028;
|
||||||
|
color: #f87171;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-msg {
|
||||||
|
background: #0f2a1a;
|
||||||
|
border: 1px solid #1a4a2e;
|
||||||
|
color: #4ade80;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #161822;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-item.active {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-item-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-icon {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-slug {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #8b92a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-item-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-panel {
|
||||||
|
background: #161822;
|
||||||
|
border: 1px solid #3b82f6;
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 6px 6px;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row label {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-small {
|
||||||
|
max-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #8b92a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
background: #0f1117;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #e1e4e8;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocks-section {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocks-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #8b92a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-row select {
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-row input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-row-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: #8b92a5;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-form {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
background: #1e2235;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
color: #e1e4e8;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
border-color: #8b92a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #3b82f6;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
border-color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.field-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.field-small {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
17
web/src/routes/api/channels/+server.ts
Normal file
17
web/src/routes/api/channels/+server.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { sql } from '$lib/server/db';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ locals }) => {
|
||||||
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
|
||||||
|
const channels = await sql`
|
||||||
|
SELECT c.id, c.name, c.config
|
||||||
|
FROM channels c
|
||||||
|
JOIN nodes n ON n.id = c.id
|
||||||
|
WHERE n.workspace_id = ${locals.workspace.id}
|
||||||
|
ORDER BY c.name
|
||||||
|
`;
|
||||||
|
|
||||||
|
return json(channels);
|
||||||
|
};
|
||||||
79
web/src/routes/api/channels/[id]/messages/+server.ts
Normal file
79
web/src/routes/api/channels/[id]/messages/+server.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { sql } from '$lib/server/db';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params, url, locals }) => {
|
||||||
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
|
||||||
|
const channelId = params.id;
|
||||||
|
const after = url.searchParams.get('after');
|
||||||
|
const limit = Math.min(Number(url.searchParams.get('limit') ?? 50), 100);
|
||||||
|
|
||||||
|
// Verifiser at kanalen tilhører workspace
|
||||||
|
const [channel] = await sql`
|
||||||
|
SELECT c.id FROM channels c
|
||||||
|
JOIN nodes n ON n.id = c.id
|
||||||
|
WHERE c.id = ${channelId} AND n.workspace_id = ${locals.workspace.id}
|
||||||
|
`;
|
||||||
|
if (!channel) error(404, 'Kanal ikke funnet');
|
||||||
|
|
||||||
|
const messages = after
|
||||||
|
? await sql`
|
||||||
|
SELECT m.id, m.body, m.message_type, m.created_at, m.reply_to,
|
||||||
|
u.display_name as author_name, u.authentik_id as author_id
|
||||||
|
FROM messages m
|
||||||
|
LEFT JOIN users u ON u.authentik_id = m.author_id
|
||||||
|
WHERE m.channel_id = ${channelId} AND m.created_at > ${after}
|
||||||
|
ORDER BY m.created_at ASC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`
|
||||||
|
: await sql`
|
||||||
|
SELECT m.id, m.body, m.message_type, m.created_at, m.reply_to,
|
||||||
|
u.display_name as author_name, u.authentik_id as author_id
|
||||||
|
FROM messages m
|
||||||
|
LEFT JOIN users u ON u.authentik_id = m.author_id
|
||||||
|
WHERE m.channel_id = ${channelId}
|
||||||
|
ORDER BY m.created_at DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`.then((rows) => rows.reverse());
|
||||||
|
|
||||||
|
return json(messages);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
|
||||||
|
const channelId = params.id;
|
||||||
|
const { body, replyTo } = await request.json();
|
||||||
|
|
||||||
|
if (!body || typeof body !== 'string' || body.trim().length === 0) {
|
||||||
|
error(400, 'Melding kan ikke være tom');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifiser at kanalen tilhører workspace
|
||||||
|
const [channel] = await sql`
|
||||||
|
SELECT c.id FROM channels c
|
||||||
|
JOIN nodes n ON n.id = c.id
|
||||||
|
WHERE c.id = ${channelId} AND n.workspace_id = ${locals.workspace.id}
|
||||||
|
`;
|
||||||
|
if (!channel) error(404, 'Kanal ikke funnet');
|
||||||
|
|
||||||
|
// Opprett node + melding i én transaksjon
|
||||||
|
const [message] = await sql`
|
||||||
|
WITH new_node AS (
|
||||||
|
INSERT INTO nodes (workspace_id, node_type)
|
||||||
|
VALUES (${locals.workspace.id}, 'melding')
|
||||||
|
RETURNING id
|
||||||
|
)
|
||||||
|
INSERT INTO messages (id, channel_id, author_id, body, reply_to)
|
||||||
|
SELECT new_node.id, ${channelId}, ${locals.user.id}, ${body.trim()}, ${replyTo ?? null}
|
||||||
|
FROM new_node
|
||||||
|
RETURNING id, body, message_type, created_at, reply_to
|
||||||
|
`;
|
||||||
|
|
||||||
|
return json({
|
||||||
|
...message,
|
||||||
|
author_name: locals.user.name,
|
||||||
|
author_id: locals.user.id
|
||||||
|
}, { status: 201 });
|
||||||
|
};
|
||||||
12
web/src/routes/api/health/+server.ts
Normal file
12
web/src/routes/api/health/+server.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { sql } from '$lib/server/db';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async () => {
|
||||||
|
try {
|
||||||
|
await sql`SELECT 1`;
|
||||||
|
return json({ status: 'ok', db: 'connected' });
|
||||||
|
} catch {
|
||||||
|
return json({ status: 'error', db: 'disconnected' }, { status: 503 });
|
||||||
|
}
|
||||||
|
};
|
||||||
15
web/src/routes/p/[slug]/+page.server.ts
Normal file
15
web/src/routes/p/[slug]/+page.server.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import type { PageConfig } from '$lib/types/pages';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
|
if (!locals.workspace) error(404);
|
||||||
|
|
||||||
|
const settings = locals.workspace.settings as Record<string, unknown>;
|
||||||
|
const pages = (settings?.pages as PageConfig[]) ?? [];
|
||||||
|
const page = pages.find((p) => p.slug === params.slug);
|
||||||
|
|
||||||
|
if (!page) error(404, 'Siden finnes ikke');
|
||||||
|
|
||||||
|
return { page };
|
||||||
|
};
|
||||||
8
web/src/routes/p/[slug]/+page.svelte
Normal file
8
web/src/routes/p/[slug]/+page.svelte
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import PageGrid from '$lib/components/PageGrid.svelte';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data } = $props<{ data: PageData }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageGrid page={data.page} />
|
||||||
14
web/svelte.config.js
Normal file
14
web/svelte.config.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import adapter from '@sveltejs/adapter-node';
|
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
kit: {
|
||||||
|
adapter: adapter({
|
||||||
|
envPrefix: 'SIDELINJA_'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
14
web/tsconfig.json
Normal file
14
web/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
web/vite.config.ts
Normal file
6
web/vite.config.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [sveltekit()]
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue