Tiptap-editor og mentions→graf-edges

Ny Editor-komponent med Tiptap (bold, italic, code, mentions).
Chat og notater oppretter nå MENTIONS-edges i kunnskapsgrafen
automatisk ved lagring. SpacetimeDB-adapter skriver alltid via
PG API først for edge-atomisitet. RLS SET LOCAL fix i db.ts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-15 21:45:34 +01:00
parent 74110e842c
commit 592ebdf1d6
12 changed files with 1664 additions and 144 deletions

751
web/package-lock.json generated
View file

@ -13,6 +13,11 @@
"@sveltejs/adapter-node": "^5.5.4", "@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.55.0", "@sveltejs/kit": "^2.55.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tiptap/core": "^3.20.1",
"@tiptap/extension-mention": "^3.20.1",
"@tiptap/extension-placeholder": "^3.20.1",
"@tiptap/pm": "^3.20.1",
"@tiptap/starter-kit": "^3.20.1",
"postgres": "^3.4.8", "postgres": "^3.4.8",
"spacetimedb": "^2.0.4", "spacetimedb": "^2.0.4",
"svelte": "^5.53.12", "svelte": "^5.53.12",
@ -211,6 +216,12 @@
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@remirror/core-constants": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
"license": "MIT"
},
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.9", "version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz",
@ -964,6 +975,422 @@
"vite": "^8.0.0-beta.7 || ^8.0.0" "vite": "^8.0.0-beta.7 || ^8.0.0"
} }
}, },
"node_modules/@tiptap/core": {
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.1.tgz",
"integrity": "sha512-SwkPEWIfaDEZjC8SEIi4kZjqIYUbRgLUHUuQezo5GbphUNC8kM1pi3C3EtoOPtxXrEbY6e4pWEzW54Pcrd+rVA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "^3.20.1"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.20.1.tgz",
"integrity": "sha512-WzNXk/63PQI2fav4Ta6P0GmYRyu8Gap1pV3VUqaVK829iJ6Zt1T21xayATHEHWMK27VT1GLPJkx9Ycr2jfDyQw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.1"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.20.1.tgz",
"integrity": "sha512-fz++Qv6Rk/Hov0IYG/r7TJ1Y4zWkuGONe0UN5g0KY32NIMg3HeOHicbi4xsNWTm9uAOl3eawWDkezEMrleObMw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.1"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.20.1.tgz",
"integrity": "sha512-mbrlvOZo5OF3vLhp+3fk9KuL/6J/wsN0QxF6ZFRAHzQ9NkJdtdfARcBeBnkWXGN8inB6YxbTGY1/E4lmBkOpOw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "^3.20.1"
}
},
"node_modules/@tiptap/extension-code": {
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.20.1.tgz",
"integrity": "sha512-509DHINIA/Gg+eTG7TEkfsS8RUiPLH5xZNyLRT0A1oaoaJmECKfrV6aAm05IdfTyqDqz6LW5pbnX6DdUC4keug==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.1"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.20.1.tgz",
"integrity": "sha512-vKejwBq+Nlj4Ybd3qOyDxIQKzYymdNH+8eXkKwGShk2nfLJIxq69DCyGvmuHgipIO1qcYPJ149UNpGN+YGcdmA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.1",
"@tiptap/pm": "^3.20.1"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.20.1.tgz",
"integrity": "sha512-9vrqdGmRV7bQCSY3NLgu7UhIwgOCDp4sKqMNsoNRX0aZ021QQMTvBQDPkiRkCf7MNsnWrNNnr52PVnULEn3vFQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.1"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.20.1.tgz",
"integrity": "sha512-K18L9FX4znn+ViPSIbTLOGcIaXMx/gLNwAPE8wPLwswbHhQqdiY1zzdBw6drgOc1Hicvebo2dIoUlSXOZsOEcw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "^3.20.1"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.20.1.tgz",
"integrity": "sha512-kZOtttV6Ai8VUAgEng3h4WKFbtdSNJ6ps7r0cRPY+FctWhVmgNb/JJwwyC+vSilR7nRENAhrA/Cv/RxVlvLw+g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "^3.20.1"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.20.1.tgz",
"integrity": "sha512-9sKpmg/IIdlLXimYWUZ3PplIRcehv4Oc7V1miTqlnAthMzjMqigDkjjgte4JZV67RdnDJTQkRw8TklCAU28Emg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.1"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.20.1.tgz",
"integrity": "sha512-unudyfQP6FxnyWinxvPqe/51DG91J6AaJm666RnAubgYMCgym+33kBftx4j4A6qf+ddWYbD00thMNKOnVLjAEQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.1"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.20.1.tgz",
"integrity": "sha512-rjFKFXNntdl0jay8oIGFvvykHlpyQTLmrH3Ag2fj3i8yh6MVvqhtaDomYQbw5sxECd5hBkL+T4n2d2DRuVw/QQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.1",
"@tiptap/pm": "^3.20.1"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.20.1.tgz",
"integrity": "sha512-ZYRX13Kt8tR8JOzSXirH3pRpi8x30o7LHxZY58uXBdUvr3tFzOkh03qbN523+diidSVeHP/aMd/+IrplHRkQug==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.1"
}
},
"node_modules/@tiptap/extension-link": {
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.20.1.tgz",
"integrity": "sha512-oYTTIgsQMqpkSnJAuAc+UtIKMuI4lv9e1y4LfI1iYm6NkEUHhONppU59smhxHLzb3Ww7YpDffbp5IgDTAiJztA==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.1",
"@tiptap/pm": "^3.20.1"
}
},
"node_modules/@tiptap/extension-list": {
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.20.1.tgz",
"integrity": "sha512-euBRAn0mkV7R2VEE+AuOt3R0j9RHEMFXamPFmtvTo8IInxDClusrm6mJoDjS8gCGAXsQCRiAe1SCQBPgGbOOwg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.1",
"@tiptap/pm": "^3.20.1"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.20.1.tgz",
"integrity": "sha512-tzgnyTW82lYJkUnadYbatwkI9dLz/OWRSWuFpQPRje/ItmFMWuQ9c9NDD8qLbXPdEYnvrgSAA+ipCD/1G0qA0Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "^3.20.1"
}
},
"node_modules/@tiptap/extension-list-keymap": {
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.20.1.tgz",
"integrity": "sha512-Dr0xsQKx0XPOgDg7xqoWwfv7FFwZ3WeF3eOjqh3rDXlNHMj1v+UW5cj1HLphrsAZHTrVTn2C+VWPJkMZrSbpvQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "^3.20.1"
}
},
"node_modules/@tiptap/extension-mention": {
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-mention/-/extension-mention-3.20.1.tgz",
"integrity": "sha512-KOGokj7oH1QpcM8P02V+o6wHsVE0g7XEtdIy2vtq2vlFE3npNNNFkMa8F8VWX6qyC+VeVrNU6SIzS5MFY2TORA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.1",
"@tiptap/pm": "^3.20.1",
"@tiptap/suggestion": "^3.20.1"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.20.1.tgz",
"integrity": "sha512-Y+3Ad7OwAdagqdYwCnbqf7/to5ypD4NnUNHA0TXRCs7cAHRA8AdgPoIcGFpaaSpV86oosNU3yfeJouYeroffog==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "^3.20.1"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.20.1.tgz",
"integrity": "sha512-QFrAtXNyv7JSnomMQc1nx5AnG9mMznfbYJAbdOQYVdbLtAzTfiTuNPNbQrufy5ZGtGaHxDCoaygu2QEfzaKG+Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.1"
}
},
"node_modules/@tiptap/extension-placeholder": {
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.20.1.tgz",
"integrity": "sha512-k+jfbCugYGuIFBdojukgEopGazIMOgHrw46FnyN2X/6ICOIjQP2rh2ObslrsUOsJYoEevxCsNF9hZl1HvWX66g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "^3.20.1"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.20.1.tgz",
"integrity": "sha512-EYgyma10lpsY+rwbVQL9u+gA7hBlKLSMFH7Zgd37FSxukOjr+HE8iKPQQ+SwbGejyDsPlLT8Z5Jnuxo5Ng90Pg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.1"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.20.1.tgz",
"integrity": "sha512-7PlIbYW8UenV6NPOXHmv8IcmPGlGx6HFq66RmkJAOJRPXPkTLAiX0N8rQtzUJ6jDEHqoJpaHFEHJw0xzW1yF+A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.1"
}
},
"node_modules/@tiptap/extension-underline": {
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.20.1.tgz",
"integrity": "sha512-fmHvDKzwCgnZUwRreq8tYkb1YyEwgzZ6QQkAQ0CsCRtvRMqzerr3Duz0Als4i8voZTuGDEL3VR6nAJbLAb/wPg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.1"
}
},
"node_modules/@tiptap/extensions": {
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.20.1.tgz",
"integrity": "sha512-JRc/v+OBH0qLTdvQ7HvHWTxGJH73QOf1MC0R8NhOX2QnAbg2mPFv1h+FjGa2gfLGuCXBdWQomjekWkUKbC4e5A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.1",
"@tiptap/pm": "^3.20.1"
}
},
"node_modules/@tiptap/pm": {
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.1.tgz",
"integrity": "sha512-6kCiGLvpES4AxcEuOhb7HR7/xIeJWMjZlb6J7e8zpiIh5BoQc7NoRdctsnmFEjZvC19bIasccshHQ7H2zchWqw==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
"prosemirror-commands": "^1.6.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.1",
"prosemirror-menu": "^1.2.4",
"prosemirror-model": "^1.24.1",
"prosemirror-schema-basic": "^1.2.3",
"prosemirror-schema-list": "^1.5.0",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-trailing-node": "^3.0.0",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.38.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.20.1.tgz",
"integrity": "sha512-opqWxL/4OTEiqmVC0wsU4o3JhAf6LycJ2G/gRIZVAIFLljI9uHfpPMTFGxZ5w9IVVJaP5PJysfwW/635kKqkrw==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "^3.20.1",
"@tiptap/extension-blockquote": "^3.20.1",
"@tiptap/extension-bold": "^3.20.1",
"@tiptap/extension-bullet-list": "^3.20.1",
"@tiptap/extension-code": "^3.20.1",
"@tiptap/extension-code-block": "^3.20.1",
"@tiptap/extension-document": "^3.20.1",
"@tiptap/extension-dropcursor": "^3.20.1",
"@tiptap/extension-gapcursor": "^3.20.1",
"@tiptap/extension-hard-break": "^3.20.1",
"@tiptap/extension-heading": "^3.20.1",
"@tiptap/extension-horizontal-rule": "^3.20.1",
"@tiptap/extension-italic": "^3.20.1",
"@tiptap/extension-link": "^3.20.1",
"@tiptap/extension-list": "^3.20.1",
"@tiptap/extension-list-item": "^3.20.1",
"@tiptap/extension-list-keymap": "^3.20.1",
"@tiptap/extension-ordered-list": "^3.20.1",
"@tiptap/extension-paragraph": "^3.20.1",
"@tiptap/extension-strike": "^3.20.1",
"@tiptap/extension-text": "^3.20.1",
"@tiptap/extension-underline": "^3.20.1",
"@tiptap/extensions": "^3.20.1",
"@tiptap/pm": "^3.20.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/suggestion": {
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.20.1.tgz",
"integrity": "sha512-ng7olbzgZhWvPJVJygNQK5153CjquR2eJXpkLq7bRjHlahvt4TH4tGFYvGdYZcXuzbe2g9RoqT7NaPGL9CUq9w==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.1",
"@tiptap/pm": "^3.20.1"
}
},
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.1", "version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@ -986,6 +1413,28 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"license": "MIT"
},
"node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"license": "MIT"
},
"node_modules/@types/resolve": { "node_modules/@types/resolve": {
"version": "1.20.2", "version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@ -1023,6 +1472,12 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/aria-query": { "node_modules/aria-query": {
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
@ -1101,6 +1556,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/deepmerge": { "node_modules/deepmerge": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@ -1125,6 +1586,30 @@
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/esm-env": { "node_modules/esm-env": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
@ -1502,6 +1987,21 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/linkifyjs": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
"license": "MIT"
},
"node_modules/locate-character": { "node_modules/locate-character": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
@ -1517,6 +2017,29 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/markdown-it": {
"version": "14.1.1",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.0",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"license": "MIT"
},
"node_modules/mri": { "node_modules/mri": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@ -1585,6 +2108,12 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/path-parse": { "node_modules/path-parse": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@ -1684,6 +2213,210 @@
"url": "https://github.com/prettier/prettier?sponsor=1" "url": "https://github.com/prettier/prettier?sponsor=1"
} }
}, },
"node_modules/prosemirror-changeset": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz",
"integrity": "sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-collab": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
"integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-inputrules": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-markdown": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz",
"integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==",
"license": "MIT",
"dependencies": {
"@types/markdown-it": "^14.0.0",
"markdown-it": "^14.0.0",
"prosemirror-model": "^1.25.0"
}
},
"node_modules/prosemirror-menu": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.0.tgz",
"integrity": "sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==",
"license": "MIT",
"dependencies": {
"crelt": "^1.0.0",
"prosemirror-commands": "^1.0.0",
"prosemirror-history": "^1.0.0",
"prosemirror-state": "^1.0.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.4",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-basic": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz",
"integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-trailing-node": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
"integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
"license": "MIT",
"dependencies": {
"@remirror/core-constants": "3.0.0",
"escape-string-regexp": "^4.0.0"
},
"peerDependencies": {
"prosemirror-model": "^1.22.1",
"prosemirror-state": "^1.4.2",
"prosemirror-view": "^1.33.8"
}
},
"node_modules/prosemirror-transform": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.11.0.tgz",
"integrity": "sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.41.6",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.6.tgz",
"integrity": "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/pure-rand": { "node_modules/pure-rand": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz",
@ -1811,6 +2544,12 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/sade": { "node_modules/sade": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
@ -2032,6 +2771,12 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/url-polyfill": { "node_modules/url-polyfill": {
"version": "1.1.14", "version": "1.1.14",
"resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz", "resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz",
@ -2135,6 +2880,12 @@
} }
} }
}, },
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/zimmerframe": { "node_modules/zimmerframe": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",

View file

@ -15,6 +15,11 @@
"@sveltejs/adapter-node": "^5.5.4", "@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.55.0", "@sveltejs/kit": "^2.55.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tiptap/core": "^3.20.1",
"@tiptap/extension-mention": "^3.20.1",
"@tiptap/extension-placeholder": "^3.20.1",
"@tiptap/pm": "^3.20.1",
"@tiptap/starter-kit": "^3.20.1",
"postgres": "^3.4.8", "postgres": "^3.4.8",
"spacetimedb": "^2.0.4", "spacetimedb": "^2.0.4",
"svelte": "^5.53.12", "svelte": "^5.53.12",

View file

@ -1,30 +1,35 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { createChat } from '$lib/chat/create.svelte'; import { createChat } from '$lib/chat/create.svelte';
import type { Message, ChatConnection } from '$lib/chat/types'; import type { Message, ChatConnection } from '$lib/chat/types';
import Editor from '$lib/components/Editor.svelte';
let { props = {} }: { props?: Record<string, unknown> } = $props(); let { props = {} }: { props?: Record<string, unknown> } = $props();
const channelId = props.channelId as string | undefined; const channelId = props.channelId as string | undefined;
let chat = $state<ChatConnection | null>(null); let chat = $state<ChatConnection | null>(null);
let input = $state('');
let sending = $state(false); let sending = $state(false);
let messagesEl: HTMLDivElement | undefined; let messagesEl: HTMLDivElement | undefined;
let inputEl: HTMLTextAreaElement | undefined;
async function send() { async function handleSubmit(html: string, json: Record<string, unknown>, mentions: Array<{ id: string; name: string; type: string; aliases: string[] }>) {
if (!chat || !input.trim() || sending) return; if (!chat || sending) return;
const body = input.trim();
input = '';
sending = true; sending = true;
try { try {
await chat.send(body); await chat.send(html, mentions.length > 0 ? mentions : undefined);
scrollToBottom(); scrollToBottom();
} finally { } finally {
sending = false; sending = false;
requestAnimationFrame(() => inputEl?.focus()); }
}
function handleMessageClick(e: MouseEvent) {
const target = e.target as HTMLElement;
if (target.classList.contains('mention') && target.dataset.id) {
e.preventDefault();
goto(`/entities/${target.dataset.id}`);
} }
} }
@ -34,13 +39,6 @@
}); });
} }
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
send();
}
}
function formatTime(iso: string): string { function formatTime(iso: string): string {
return new Date(iso).toLocaleTimeString('nb-NO', { hour: '2-digit', minute: '2-digit' }); return new Date(iso).toLocaleTimeString('nb-NO', { hour: '2-digit', minute: '2-digit' });
} }
@ -109,7 +107,9 @@
<span class="author">{msg.author_name ?? 'Ukjent'}</span> <span class="author">{msg.author_name ?? 'Ukjent'}</span>
<span class="time">{formatTime(msg.created_at)}</span> <span class="time">{formatTime(msg.created_at)}</span>
</div> </div>
<div class="message-body">{msg.body}</div> <!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="message-body" onclick={handleMessageClick}>{@html msg.body}</div>
</div> </div>
{/each} {/each}
{/each} {/each}
@ -124,23 +124,12 @@
{/if} {/if}
<div class="input-row"> <div class="input-row">
<textarea <Editor
bind:this={inputEl} mode="compact"
bind:value={input}
onkeydown={onKeydown}
placeholder="Skriv en melding..." placeholder="Skriv en melding..."
rows="1" onSubmit={handleSubmit}
></textarea> autofocus
<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>
</div> </div>
{/if} {/if}
@ -207,10 +196,48 @@
font-size: 0.85rem; font-size: 0.85rem;
color: #e1e4e8; color: #e1e4e8;
line-height: 1.4; line-height: 1.4;
white-space: pre-wrap;
word-break: break-word; word-break: break-word;
} }
/* Render HTML from Tiptap */
.message-body :global(p) {
margin: 0;
}
.message-body :global(p + p) {
margin-top: 0.3em;
}
.message-body :global(strong) {
font-weight: 700;
color: #f1f3f5;
}
.message-body :global(code) {
background: #1e2235;
padding: 0.1em 0.25em;
border-radius: 3px;
font-size: 0.8rem;
}
.message-body :global(.mention) {
color: #8b5cf6;
background: rgba(139, 92, 246, 0.12);
padding: 0.05em 0.25em;
border-radius: 3px;
font-weight: 500;
cursor: pointer;
text-decoration: none;
}
.message-body :global(.mention:hover) {
background: rgba(139, 92, 246, 0.25);
}
.message-body :global(.mention::before) {
content: '#';
}
.empty { .empty {
display: flex; display: flex;
align-items: center; align-items: center;
@ -227,58 +254,10 @@
} }
.input-row { .input-row {
display: flex;
gap: 0.5rem;
padding-top: 0.5rem; padding-top: 0.5rem;
border-top: 1px solid #2d3148; 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 { .no-channel {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -2,6 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { createNote } from '$lib/notes/create.svelte'; import { createNote } from '$lib/notes/create.svelte';
import type { NoteConnection } from '$lib/notes/types'; import type { NoteConnection } from '$lib/notes/types';
import Editor from '$lib/components/Editor.svelte';
let { props = {} }: { props?: Record<string, unknown> } = $props(); let { props = {} }: { props?: Record<string, unknown> } = $props();
@ -25,7 +26,7 @@
conn?.save({ title }); conn?.save({ title });
} }
function handleContentInput() { function handleContentUpdate() {
conn?.save({ content }); conn?.save({ content });
} }
@ -61,12 +62,14 @@
bind:value={title} bind:value={title}
oninput={handleTitleInput} oninput={handleTitleInput}
/> />
<textarea
class="note-content" <Editor
mode="extended"
placeholder="Skriv her..." placeholder="Skriv her..."
bind:value={content} bind:content={content}
oninput={handleContentInput} onUpdate={handleContentUpdate}
></textarea> />
<div class="note-footer"> <div class="note-footer">
{#if conn?.saving} {#if conn?.saving}
<span class="status saving">Lagrer...</span> <span class="status saving">Lagrer...</span>
@ -110,27 +113,6 @@
color: #8b92a5; color: #8b92a5;
} }
.note-content {
flex: 1;
background: transparent;
border: none;
color: #e1e4e8;
font-size: 0.85rem;
font-family: inherit;
line-height: 1.6;
resize: none;
padding: 0;
min-height: 100px;
}
.note-content:focus {
outline: none;
}
.note-content::placeholder {
color: #8b92a5;
}
.note-footer { .note-footer {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;

View file

@ -1,4 +1,4 @@
import type { Message, ChatConnection } from './types'; import type { Message, ChatConnection, MentionRef } from './types';
/** /**
* Chat-adapter som poller PostgreSQL via REST API. * Chat-adapter som poller PostgreSQL via REST API.
@ -26,13 +26,13 @@ export function createPgChat(channelId: string): ChatConnection {
} }
} }
async function send(body: string) { async function send(body: string, mentions?: MentionRef[]) {
error = ''; error = '';
try { try {
const res = await fetch(`/api/channels/${channelId}/messages`, { const res = await fetch(`/api/channels/${channelId}/messages`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body }) body: JSON.stringify({ body, mentions })
}); });
if (!res.ok) throw new Error('Feil ved sending'); if (!res.ok) throw new Error('Feil ved sending');
await refresh(); await refresh();

View file

@ -1,4 +1,4 @@
import type { Message, ChatConnection, ChatUser } from './types'; import type { Message, ChatConnection, ChatUser, MentionRef } from './types';
import { DbConnection, type EventContext } from './module_bindings'; import { DbConnection, type EventContext } from './module_bindings';
/** /**
@ -101,32 +101,34 @@ export function createSpacetimeChat(
}; };
} }
async function send(body: string) { async function send(body: string, mentions?: MentionRef[]) {
if (conn && connected) { // Alltid send via PG API — dette oppretter noden og MENTIONS-edges atomisk.
// Send via SpacetimeDB — umiddelbar push til alle klienter // SpacetimeDB brukes kun for real-time push til andre 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 { try {
const res = await fetch(`/api/channels/${channelId}/messages`, { const res = await fetch(`/api/channels/${channelId}/messages`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body }) body: JSON.stringify({ body, mentions })
}); });
if (!res.ok) throw new Error('Feil ved sending'); if (!res.ok) throw new Error('Feil ved sending');
// Push til SpacetimeDB for sanntidsvisning hos andre klienter
if (conn && connected) {
try {
const msg = await res.clone().json();
conn.reducers.sendMessage({
id: msg.id,
channelId,
workspaceId: '',
authorName: user.name,
body,
replyTo: ''
});
} catch {
// Ikke kritisk — PG er allerede oppdatert
}
}
await loadFromPg(); await loadFromPg();
} catch { } catch {
error = 'Kunne ikke sende melding'; error = 'Kunne ikke sende melding';

View file

@ -13,6 +13,13 @@ export interface Message {
reply_to: string | null; reply_to: string | null;
} }
export interface MentionRef {
id: string;
name: string;
type: string;
aliases: string[];
}
/** /**
* Felles kontrakt for chat-tilkoblinger. * Felles kontrakt for chat-tilkoblinger.
* Implementeres av PG-polling og SpacetimeDB. * Implementeres av PG-polling og SpacetimeDB.
@ -22,6 +29,6 @@ export interface ChatConnection {
readonly messages: Message[]; readonly messages: Message[];
readonly error: string; readonly error: string;
readonly connected: boolean; readonly connected: boolean;
send(body: string): Promise<void>; send(body: string, mentions?: MentionRef[]): Promise<void>;
destroy(): void; destroy(): void;
} }

View file

@ -0,0 +1,720 @@
<script lang="ts">
/**
* Universell editor-komponent.
* Fase 1: Tiptap med plaintext, #-mentions, markdown formatting.
*
* Modi:
* compact — chat-input (Enter = submit, ingen synlig toolbar)
* extended — notater/lengre tekst (Enter = newline, toolbar synlig)
*
* Bruk:
* <Editor mode="compact" onSubmit={(content) => ...} placeholder="Skriv en melding..." />
* <Editor mode="extended" bind:content onUpdate={() => ...} placeholder="Skriv her..." />
*/
import { onMount, onDestroy } from 'svelte';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import Mention from '@tiptap/extension-mention';
interface Entity {
id: string;
name: string;
type: string;
aliases: string[];
}
let {
mode = 'compact',
placeholder = '',
content = $bindable(''),
autofocus = false,
onSubmit = (_html: string, _json: Record<string, unknown>, _mentions: Entity[]) => {},
onUpdate = () => {},
onExpand = () => {}
}: {
mode?: 'compact' | 'extended';
placeholder?: string;
content?: string;
autofocus?: boolean;
onSubmit?: (html: string, json: Record<string, unknown>, mentions: Entity[]) => void;
onUpdate?: () => void;
onExpand?: () => void;
} = $props();
let editorEl: HTMLDivElement;
let editor = $state<Editor | null>(null);
let mentions: Entity[] = [];
let expanded = $state(false);
let hasContent = $state(false);
let rawMode = $state(false);
let rawContent = $state('');
// Mention suggestions state
let suggestions = $state<Entity[]>([]);
let selectedIndex = $state(0);
let suggestionsEl: HTMLDivElement | undefined;
let mentionPopupPos = $state<{ top: number; left: number } | null>(null);
let mentionCommandFn: ((item: any) => void) | null = null;
const typeColors: Record<string, string> = {
person: '#3b82f6',
organisasjon: '#f59e0b',
sted: '#10b981',
tema: '#8b5cf6',
konsept: '#ec4899'
};
function getEditorContent() {
if (!editor) return { html: '', json: {} };
return {
html: editor.getHTML(),
json: editor.getJSON()
};
}
function clearEditor() {
editor?.commands.clearContent();
mentions = [];
hasContent = false;
expanded = false;
}
async function searchEntities(query: string): Promise<Entity[]> {
try {
const res = await fetch(`/api/entities?q=${encodeURIComponent(query)}&limit=8`);
if (res.ok) return await res.json();
} catch { /* ignore */ }
return [];
}
function handleSubmit() {
if (!editor || !hasContent) return;
const { html, json } = getEditorContent();
onSubmit(html, json, [...mentions]);
clearEditor();
}
onMount(() => {
editor = new Editor({
element: editorEl,
extensions: [
StarterKit,
Placeholder.configure({
placeholder
}),
Mention.configure({
HTMLAttributes: {
class: 'mention'
},
suggestion: {
char: '#',
items: async ({ query }: { query: string }) => {
if (query.length < 1) return [];
return await searchEntities(query);
},
render: () => {
return {
onStart: (props: any) => {
mentionCommandFn = props.command;
suggestions = props.items;
selectedIndex = 0;
updatePopupPosition(props.clientRect);
},
onUpdate: (props: any) => {
mentionCommandFn = props.command;
suggestions = props.items;
selectedIndex = 0;
updatePopupPosition(props.clientRect);
},
onKeyDown: (props: any) => {
if (props.event.key === 'ArrowDown') {
selectedIndex = (selectedIndex + 1) % suggestions.length;
return true;
}
if (props.event.key === 'ArrowUp') {
selectedIndex = (selectedIndex - 1 + suggestions.length) % suggestions.length;
return true;
}
if (props.event.key === 'Enter' || props.event.key === 'Tab') {
if (suggestions.length > 0) {
mentionCommandFn?.(suggestions[selectedIndex]);
return true;
}
}
if (props.event.key === 'Escape') {
suggestions = [];
return true;
}
return false;
},
onExit: () => {
mentionCommandFn = null;
suggestions = [];
mentionPopupPos = null;
}
};
},
command: ({ editor: ed, range, props: item }: any) => {
ed.chain()
.focus()
.insertContentAt(range, [
{ type: 'mention', attrs: { id: item.id, label: item.name } },
{ type: 'text', text: ' ' }
])
.run();
mentions.push(item);
}
}
})
],
content: content || '',
editorProps: {
attributes: {
class: `editor-content ${mode}`,
'data-mode': mode
},
handleKeyDown: (_view, event) => {
// Compact mode: Enter = submit (unless shift held or suggestions open)
if (mode === 'compact' && event.key === 'Enter' && !event.shiftKey) {
if (suggestions.length > 0) return false; // Let mention handler deal with it
event.preventDefault();
handleSubmit();
return true;
}
return false;
}
},
onUpdate: ({ editor: ed }) => {
content = ed.getHTML();
hasContent = !ed.isEmpty;
onUpdate();
},
autofocus: autofocus ? 'end' : false
});
});
onDestroy(() => {
editor?.destroy();
});
function updatePopupPosition(clientRect: (() => DOMRect | null) | null) {
if (!clientRect) return;
const rect = clientRect();
if (!rect) return;
const editorRect = editorEl.getBoundingClientRect();
mentionPopupPos = {
left: rect.left - editorRect.left,
top: rect.top - editorRect.top - 4
};
}
function toggleExpand() {
expanded = !expanded;
if (expanded) onExpand();
if (!expanded) rawMode = false;
editor?.commands.focus();
}
function toggleRawMode() {
if (!editor) return;
if (rawMode) {
// Switching from raw → rendered: push textarea content into Tiptap
editor.commands.setContent(rawContent);
content = rawContent;
hasContent = !editor.isEmpty;
rawMode = false;
editor.commands.focus();
} else {
// Switching from rendered → raw: snapshot HTML into textarea
rawContent = editor.getHTML();
rawMode = true;
}
}
function handleRawInput(e: Event) {
rawContent = (e.target as HTMLTextAreaElement).value;
content = rawContent;
hasContent = rawContent.replace(/<[^>]*>/g, '').trim().length > 0;
}
function handleGlobalKeydown(e: KeyboardEvent) {
// Ctrl+/ or Cmd+/ toggles raw mode
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
e.preventDefault();
toggleRawMode();
}
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="editor-wrapper" class:compact={mode === 'compact'} class:extended={mode === 'extended'} class:expanded onkeydown={handleGlobalKeydown}>
{#if mode === 'extended' || expanded}
<div class="toolbar">
{#if !rawMode}
<button type="button" class="tool" class:active={editor?.isActive('bold')}
onclick={() => editor?.chain().focus().toggleBold().run()}
title="Bold (Ctrl+B)">B</button>
<button type="button" class="tool" class:active={editor?.isActive('italic')}
onclick={() => editor?.chain().focus().toggleItalic().run()}
title="Italic (Ctrl+I)"><em>I</em></button>
<button type="button" class="tool" class:active={editor?.isActive('strike')}
onclick={() => editor?.chain().focus().toggleStrike().run()}
title="Strikethrough">S̶</button>
<button type="button" class="tool" class:active={editor?.isActive('code')}
onclick={() => editor?.chain().focus().toggleCode().run()}
title="Inline code">{'<>'}</button>
<span class="separator"></span>
<button type="button" class="tool" class:active={editor?.isActive('heading', { level: 1 })}
onclick={() => editor?.chain().focus().toggleHeading({ level: 1 }).run()}
title="Heading 1">H1</button>
<button type="button" class="tool" class:active={editor?.isActive('heading', { level: 2 })}
onclick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()}
title="Heading 2">H2</button>
<button type="button" class="tool" class:active={editor?.isActive('heading', { level: 3 })}
onclick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()}
title="Heading 3">H3</button>
<span class="separator"></span>
<button type="button" class="tool" class:active={editor?.isActive('bulletList')}
onclick={() => editor?.chain().focus().toggleBulletList().run()}
title="Bullet list">•</button>
<button type="button" class="tool" class:active={editor?.isActive('orderedList')}
onclick={() => editor?.chain().focus().toggleOrderedList().run()}
title="Numbered list">1.</button>
<button type="button" class="tool" class:active={editor?.isActive('blockquote')}
onclick={() => editor?.chain().focus().toggleBlockquote().run()}
title="Quote">&#10077;</button>
<button type="button" class="tool" class:active={editor?.isActive('codeBlock')}
onclick={() => editor?.chain().focus().toggleCodeBlock().run()}
title="Code block">{'{ }'}</button>
<span class="separator"></span>
{/if}
<button type="button" class="tool raw-toggle" class:active={rawMode}
onclick={toggleRawMode}
title="Bytt raw/rendered (Ctrl+/)">{'</>'}</button>
</div>
{/if}
<div class="editor-container">
{#if rawMode}
<textarea
class="raw-editor"
value={rawContent}
oninput={handleRawInput}
spellcheck={false}
></textarea>
{:else}
<div bind:this={editorEl} class="editor-mount"></div>
{/if}
{#if mode === 'compact'}
<button type="button" class="expand-btn" onclick={toggleExpand} title={expanded ? 'Minimer' : 'Utvid editor'}>
{#if expanded}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="4 14 10 14 10 20"></polyline>
<polyline points="20 10 14 10 14 4"></polyline>
<line x1="14" y1="10" x2="21" y2="3"></line>
<line x1="3" y1="21" x2="10" y2="14"></line>
</svg>
{:else}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 3 21 3 21 9"></polyline>
<polyline points="9 21 3 21 3 15"></polyline>
<line x1="21" y1="3" x2="14" y2="10"></line>
<line x1="3" y1="21" x2="10" y2="14"></line>
</svg>
{/if}
</button>
{/if}
{#if mode === 'compact'}
<button
type="button"
class="send-btn"
onclick={handleSubmit}
disabled={!hasContent}
aria-label="Send"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
</svg>
</button>
{/if}
</div>
<!-- Mention suggestions popup -->
{#if suggestions.length > 0 && mentionPopupPos}
<div
class="mention-popup"
bind:this={suggestionsEl}
style:left="{mentionPopupPos.left}px"
style:bottom="calc(100% - {mentionPopupPos.top}px)"
>
{#each suggestions as entity, i (entity.id)}
<button
type="button"
class="suggestion"
class:selected={i === selectedIndex}
onmousedown={(e) => {
e.preventDefault();
mentionCommandFn?.(entity);
}}
>
<span class="dot" style:background={typeColors[entity.type] ?? '#8b92a5'}></span>
<span class="name">{entity.name}</span>
{#if entity.aliases?.length > 0}
<span class="alias">{entity.aliases[0]}</span>
{/if}
<span class="type">{entity.type}</span>
</button>
{/each}
</div>
{/if}
</div>
<style>
.editor-wrapper {
position: relative;
}
.editor-container {
display: flex;
align-items: flex-end;
gap: 0.4rem;
background: #0f1117;
border: 1px solid #2d3148;
border-radius: 6px;
padding: 0.4rem;
}
.compact .editor-container {
align-items: center;
}
.extended .editor-container,
.expanded .editor-container {
flex-direction: column;
align-items: stretch;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.editor-mount {
flex: 1;
min-width: 0;
}
/* Tiptap editor content styling */
.editor-wrapper :global(.editor-content) {
outline: none;
color: #e1e4e8;
font-size: 0.85rem;
font-family: inherit;
line-height: 1.5;
word-break: break-word;
}
.editor-wrapper :global(.editor-content.compact) {
max-height: 120px;
overflow-y: auto;
}
.expanded :global(.editor-content.compact) {
min-height: 120px;
max-height: 40vh;
}
.editor-wrapper :global(.editor-content.extended) {
min-height: 200px;
max-height: 60vh;
overflow-y: auto;
}
/* Raw editor (source view) */
.raw-editor {
flex: 1;
min-width: 0;
min-height: 200px;
max-height: 60vh;
background: transparent;
color: #a0a8b8;
border: none;
outline: none;
resize: none;
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
line-height: 1.5;
padding: 0;
tab-size: 2;
}
.compact .raw-editor {
min-height: 120px;
max-height: 40vh;
}
.editor-wrapper :global(.editor-content p) {
margin: 0;
}
.editor-wrapper :global(.editor-content p + p) {
margin-top: 0.4em;
}
/* Heading styles (extended mode) */
.editor-wrapper :global(.editor-content h1) {
font-size: 1.4rem;
font-weight: 700;
margin: 0.8em 0 0.3em;
color: #f1f3f5;
}
.editor-wrapper :global(.editor-content h2) {
font-size: 1.15rem;
font-weight: 600;
margin: 0.7em 0 0.25em;
color: #f1f3f5;
}
.editor-wrapper :global(.editor-content h3) {
font-size: 1rem;
font-weight: 600;
margin: 0.6em 0 0.2em;
color: #f1f3f5;
}
/* List styles */
.editor-wrapper :global(.editor-content ul),
.editor-wrapper :global(.editor-content ol) {
padding-left: 1.2em;
margin: 0.3em 0;
}
.editor-wrapper :global(.editor-content li) {
margin: 0.1em 0;
}
/* Blockquote */
.editor-wrapper :global(.editor-content blockquote) {
border-left: 3px solid #3b82f6;
padding-left: 0.75em;
margin: 0.4em 0;
color: #a0a8b8;
}
/* Code */
.editor-wrapper :global(.editor-content code) {
background: #1e2235;
padding: 0.15em 0.3em;
border-radius: 3px;
font-size: 0.8rem;
font-family: 'JetBrains Mono', monospace;
}
.editor-wrapper :global(.editor-content pre) {
background: #1e2235;
padding: 0.6em;
border-radius: 6px;
margin: 0.4em 0;
overflow-x: auto;
}
.editor-wrapper :global(.editor-content pre code) {
background: none;
padding: 0;
}
/* Inline formatting */
.editor-wrapper :global(.editor-content strong) {
font-weight: 700;
color: #f1f3f5;
}
.editor-wrapper :global(.editor-content em) {
font-style: italic;
}
.editor-wrapper :global(.editor-content s) {
text-decoration: line-through;
color: #8b92a5;
}
/* Mentions */
.editor-wrapper :global(.mention) {
color: #8b5cf6;
background: rgba(139, 92, 246, 0.12);
padding: 0.1em 0.3em;
border-radius: 3px;
font-weight: 500;
cursor: pointer;
}
.editor-wrapper :global(.mention::before) {
content: '#';
}
/* Placeholder */
.editor-wrapper :global(.tiptap p.is-editor-empty:first-child::before) {
content: attr(data-placeholder);
color: #8b92a5;
float: left;
height: 0;
pointer-events: none;
}
/* Toolbar */
.toolbar {
display: flex;
gap: 0.15rem;
padding: 0.3rem 0.4rem;
background: #0f1117;
border: 1px solid #2d3148;
border-bottom: none;
border-radius: 6px 6px 0 0;
flex-wrap: wrap;
}
.tool {
display: flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 26px;
padding: 0 0.35rem;
background: none;
border: none;
border-radius: 4px;
color: #8b92a5;
font-family: inherit;
font-size: 0.72rem;
font-weight: 600;
cursor: pointer;
}
.tool:hover {
background: #1e2235;
color: #e1e4e8;
}
.tool.active {
background: #1e2235;
color: #3b82f6;
}
.raw-toggle {
margin-left: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 0.65rem;
letter-spacing: -0.02em;
}
.separator {
width: 1px;
height: 18px;
background: #2d3148;
align-self: center;
margin: 0 0.15rem;
}
/* Send button (compact mode) */
.send-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: #3b82f6;
border: none;
border-radius: 6px;
color: white;
cursor: pointer;
flex-shrink: 0;
}
.send-btn:disabled {
background: #1e2235;
color: #8b92a5;
cursor: not-allowed;
}
.send-btn:not(:disabled):hover {
background: #2563eb;
}
/* Expand button */
.expand-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: none;
border: none;
border-radius: 4px;
color: #8b92a5;
cursor: pointer;
flex-shrink: 0;
}
.expand-btn:hover {
background: #1e2235;
color: #e1e4e8;
}
/* Mention popup */
.mention-popup {
position: absolute;
background: #161822;
border: 1px solid #2d3148;
border-radius: 6px;
max-height: 200px;
overflow-y: auto;
z-index: 50;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.3);
min-width: 200px;
}
.suggestion {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.5rem;
width: 100%;
border: none;
background: none;
color: #e1e4e8;
font-family: inherit;
font-size: 0.8rem;
cursor: pointer;
text-align: left;
}
.suggestion:hover, .suggestion.selected {
background: #1e2235;
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.alias {
color: #8b92a5;
font-size: 0.7rem;
}
.type {
color: #8b92a5;
font-size: 0.7rem;
flex-shrink: 0;
}
</style>

View file

@ -132,7 +132,7 @@
type="button" type="button"
class="suggestion" class="suggestion"
class:selected={i === selectedIndex} class:selected={i === selectedIndex}
onmousedown|preventDefault={() => selectSuggestion(entity)} onmousedown={(e: MouseEvent) => { e.preventDefault(); selectSuggestion(entity); }}
> >
<span class="dot" style:background={typeColors[entity.type] ?? '#8b92a5'}></span> <span class="dot" style:background={typeColors[entity.type] ?? '#8b92a5'}></span>
<span class="name">{entity.name}</span> <span class="name">{entity.name}</span>

View file

@ -39,7 +39,9 @@ export async function getWorkspaceForUser(workspaceId: string, userId: string):
/** /**
* Sett workspace-kontekst for RLS. * Sett workspace-kontekst for RLS.
* Kall dette før spørringer som trenger workspace-isolasjon. * Bruker set_config med is_local=true som tilsvarer SET LOCAL
* verdien forsvinner automatisk når transaksjonen avsluttes.
* Dette forhindrer workspace-lekkasje ved connection pooling.
*/ */
export async function setWorkspaceContext(workspaceId: string) { export async function setWorkspaceContext(workspaceId: string) {
await sql`SELECT set_config('app.current_workspace_id', ${workspaceId}, true)`; await sql`SELECT set_config('app.current_workspace_id', ${workspaceId}, true)`;

View file

@ -42,9 +42,11 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
export const POST: RequestHandler = async ({ params, request, locals }) => { export const POST: RequestHandler = async ({ params, request, locals }) => {
if (!locals.workspace || !locals.user) error(401); if (!locals.workspace || !locals.user) error(401);
const workspace = locals.workspace;
const user = locals.user;
const channelId = params.id; const channelId = params.id;
const { body, replyTo } = await request.json(); const { body, replyTo, mentions } = await request.json();
if (!body || typeof body !== 'string' || body.trim().length === 0) { if (!body || typeof body !== 'string' || body.trim().length === 0) {
error(400, 'Melding kan ikke være tom'); error(400, 'Melding kan ikke være tom');
@ -54,26 +56,51 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
const [channel] = await sql` const [channel] = await sql`
SELECT c.id FROM channels c SELECT c.id FROM channels c
JOIN nodes n ON n.id = c.id JOIN nodes n ON n.id = c.id
WHERE c.id = ${channelId} AND n.workspace_id = ${locals.workspace.id} WHERE c.id = ${channelId} AND n.workspace_id = ${workspace.id}
`; `;
if (!channel) error(404, 'Kanal ikke funnet'); if (!channel) error(404, 'Kanal ikke funnet');
// Opprett node + melding i én transaksjon // Opprett node + melding i PG
const [message] = await sql` const [message] = await sql`
WITH new_node AS ( WITH new_node AS (
INSERT INTO nodes (workspace_id, node_type) INSERT INTO nodes (workspace_id, node_type)
VALUES (${locals.workspace.id}, 'melding') VALUES (${workspace.id}, 'melding')
RETURNING id RETURNING id
) )
INSERT INTO messages (id, channel_id, author_id, body, reply_to) INSERT INTO messages (id, channel_id, author_id, body, reply_to)
SELECT new_node.id, ${channelId}, ${locals.user.id}, ${body.trim()}, ${replyTo ?? null} SELECT new_node.id, ${channelId}, ${user.id}, ${body.trim()}, ${replyTo ?? null}
FROM new_node FROM new_node
RETURNING id, body, message_type, created_at, reply_to RETURNING id, body, message_type, created_at, reply_to
`; `;
// Opprett MENTIONS-edges for hver #-mention
if (Array.isArray(mentions) && mentions.length > 0) {
const entityIds = mentions
.map((m: { id?: string }) => m.id)
.filter((id): id is string => typeof id === 'string' && id !== message.id);
if (entityIds.length > 0) {
// Verifiser at alle nevnte entiteter tilhører workspace
const validEntities = await sql`
SELECT id FROM nodes
WHERE id = ANY(${entityIds}) AND workspace_id = ${workspace.id}
`;
const validIds = new Set(validEntities.map((e) => (e as { id: string }).id));
for (const entityId of entityIds) {
if (!validIds.has(entityId)) continue;
await sql`
INSERT INTO graph_edges (workspace_id, source_id, target_id, relation_type, created_by, origin)
VALUES (${workspace.id}, ${message.id}, ${entityId}, 'MENTIONS', ${user.id}, 'user')
ON CONFLICT (source_id, target_id, relation_type) DO NOTHING
`;
}
}
}
return json({ return json({
...message, ...message,
author_name: locals.user.name, author_name: user.name,
author_id: locals.user.id author_id: user.id
}, { status: 201 }); }, { status: 201 });
}; };

View file

@ -2,6 +2,17 @@ import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { sql } from '$lib/server/db'; import { sql } from '$lib/server/db';
/** Trekk ut entity-UUIDs fra Tiptap HTML mentions (data-id attributter) */
function extractMentionIds(html: string): string[] {
const ids: string[] = [];
const regex = /data-id="([0-9a-f-]{36})"/g;
let match;
while ((match = regex.exec(html)) !== null) {
if (!ids.includes(match[1])) ids.push(match[1]);
}
return ids;
}
/** GET /api/notes/:noteId — Hent notat */ /** GET /api/notes/:noteId — Hent notat */
export const GET: RequestHandler = async ({ params, locals }) => { export const GET: RequestHandler = async ({ params, locals }) => {
if (!locals.workspace || !locals.user) error(401); if (!locals.workspace || !locals.user) error(401);
@ -20,13 +31,16 @@ export const GET: RequestHandler = async ({ params, locals }) => {
/** PATCH /api/notes/:noteId — Oppdater notat */ /** PATCH /api/notes/:noteId — Oppdater notat */
export const PATCH: RequestHandler = async ({ params, request, locals }) => { export const PATCH: RequestHandler = async ({ params, request, locals }) => {
if (!locals.workspace || !locals.user) error(401); if (!locals.workspace || !locals.user) error(401);
const workspace = locals.workspace;
const user = locals.user;
const noteId = params.noteId;
const updates = await request.json(); const updates = await request.json();
const [note] = await sql` const [note] = await sql`
SELECT m.id FROM messages m SELECT m.id FROM messages m
JOIN nodes nd ON nd.id = m.id JOIN nodes nd ON nd.id = m.id
WHERE m.id = ${params.noteId} AND nd.workspace_id = ${locals.workspace.id} WHERE m.id = ${noteId} AND nd.workspace_id = ${workspace.id}
`; `;
if (!note) error(404, 'Notat ikke funnet'); if (!note) error(404, 'Notat ikke funnet');
@ -34,9 +48,40 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
UPDATE messages SET UPDATE messages SET
title = COALESCE(${updates.title ?? null}, title), title = COALESCE(${updates.title ?? null}, title),
body = CASE WHEN ${updates.content !== undefined} THEN ${updates.content ?? ''} ELSE body END body = CASE WHEN ${updates.content !== undefined} THEN ${updates.content ?? ''} ELSE body END
WHERE id = ${params.noteId} WHERE id = ${noteId}
RETURNING id, title, body AS content, updated_at RETURNING id, title, body AS content, updated_at
`; `;
// Synkroniser MENTIONS-edges når content endres
if (updates.content !== undefined) {
const mentionIds = extractMentionIds(updates.content ?? '');
// Verifiser at nevnte entiteter tilhører workspace
let validIds: Set<string> = new Set();
if (mentionIds.length > 0) {
const validEntities = await sql`
SELECT id FROM nodes
WHERE id = ANY(${mentionIds}) AND workspace_id = ${workspace.id}
`;
validIds = new Set(validEntities.map((e) => (e as { id: string }).id));
}
// Slett gamle MENTIONS-edges fra dette notatet
await sql`
DELETE FROM graph_edges
WHERE source_id = ${noteId} AND relation_type = 'MENTIONS'
`;
// Opprett nye MENTIONS-edges
for (const entityId of mentionIds) {
if (!validIds.has(entityId)) continue;
await sql`
INSERT INTO graph_edges (workspace_id, source_id, target_id, relation_type, created_by, origin)
VALUES (${workspace.id}, ${noteId}, ${entityId}, 'MENTIONS', ${user.id}, 'user')
ON CONFLICT (source_id, target_id, relation_type) DO NOTHING
`;
}
}
return json(updated); return json(updated);
}; };