Add prosekit rich-text field, Gitea repo embeds, automation KV read endpoint; fix apex/www session-cookie mismatch on /auth/callback
Deploy / deploy (push) Successful in 29s
Deploy / deploy (push) Successful in 29s
- type: prosekit requirement kind, backed by public/prosekit-editor.js
(ProseMirror via prosekit, loaded from esm.sh, no bundler) - mirrors
its HTML into a paired hidden input so it reuses the existing
RwSignal/on:input wiring.
- Pasting a project.uhhm.no/<owner>/<repo> URL in the editor embeds a
repo card, resolved server-side via a new /gitea-repo handler
(content::gitea_repo_handler) so the browser never needs Gitea API
CORS.
- New /automation/kv/{bucket} handler, bearer-token gated
(AUTOMATION_READ_TOKEN), for backing automations (n8n) to read a
NATS KV bucket without a browser session.
- Fix: a login started on one of apex/www set its session cookie
there, but Kanidm's redirect_uri is fixed to PUBLIC_URL - landing
the callback on a different, empty session ("no login in
progress"). Caddy now redirects www -> apex so every visit stays on
one canonical host.
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
// Glue for `type: prosekit` requirement fields (see AlternativeCard in
|
||||
// src/app.rs) - mounts a headless ProseKit (ProseMirror) rich-text
|
||||
// editor into a container element, then mirrors its rendered HTML into
|
||||
// a paired hidden <input> on every change, firing a real `input` event
|
||||
// on it. That lets the Rust side reuse the exact same RwSignal/on:input
|
||||
// wiring every other requirement kind already goes through, instead of
|
||||
// needing its own submit-time collection path.
|
||||
//
|
||||
// Loaded straight from a CDN as an ES module (no bundler in this
|
||||
// project, same pattern as yes.js) - prosekit ships pre-bundled ESM,
|
||||
// and re-exports the ProseMirror packages it's built on under
|
||||
// `prosekit/pm/*`, so importing primitives (InputRule, etc.) from there
|
||||
// instead of a separate prosemirror-* package guarantees they're the
|
||||
// exact same module instance the editor itself uses.
|
||||
//
|
||||
// Least-verified part of this file (worth an actual hwatu pass after
|
||||
// deploy, not just trusting the docs): the exact shape `createEditor`
|
||||
// wants for `defaultContent` when seeding from an HTML string, and the
|
||||
// precise `definePasteHandler` callback signature. Everything else here
|
||||
// mirrors prosekit's own documented examples closely.
|
||||
|
||||
import 'https://esm.sh/prosekit/basic/style.css'
|
||||
import 'https://esm.sh/prosekit/basic/typography.css'
|
||||
import { defineBasicExtension } from 'https://esm.sh/prosekit/basic'
|
||||
import {
|
||||
createEditor,
|
||||
defineNodeSpec,
|
||||
definePasteHandler,
|
||||
defineUpdateHandler,
|
||||
union,
|
||||
} from 'https://esm.sh/prosekit/core'
|
||||
|
||||
// project.uhhm.no/<owner>/<repo>, no trailing path - a bare repo link,
|
||||
// not a file/issue/PR link within one (those aren't "the repo", and
|
||||
// trying to card-ify them too isn't what was asked for).
|
||||
const GITEA_REPO_URL_RE = /^https:\/\/project\.uhhm\.no\/([\w.-]+)\/([\w.-]+)\/?$/
|
||||
|
||||
// Loads each stylesheet once regardless of how many editors this page
|
||||
// mounts - <link> tags, not a JS import, since plain `import 'x.css'`
|
||||
// isn't valid outside a bundler and esm.sh's CSS-as-JS support isn't
|
||||
// worth depending on here.
|
||||
function ensureStylesheet(href) {
|
||||
if (document.querySelector(`link[href="${href}"]`)) return
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = href
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
|
||||
function defineGiteaRepoEmbed() {
|
||||
return defineNodeSpec({
|
||||
name: 'giteaRepoEmbed',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
attrs: {
|
||||
owner: { default: '' },
|
||||
repo: { default: '' },
|
||||
description: { default: '' },
|
||||
url: { default: '' },
|
||||
},
|
||||
parseDOM: [
|
||||
{
|
||||
tag: 'div[data-gitea-repo]',
|
||||
getAttrs: (dom) => ({
|
||||
owner: dom.getAttribute('data-owner') || '',
|
||||
repo: dom.getAttribute('data-repo') || '',
|
||||
description: dom.getAttribute('data-description') || '',
|
||||
url: dom.getAttribute('data-url') || '',
|
||||
}),
|
||||
},
|
||||
],
|
||||
// Plain inline styles, not a class - this same markup is what ends
|
||||
// up as the newsletter's HTML body, read by an email client with no
|
||||
// access to this app's stylesheet.
|
||||
toDOM: (node) => {
|
||||
const { owner, repo, description, url } = node.attrs
|
||||
return [
|
||||
'div',
|
||||
{
|
||||
'data-gitea-repo': 'true',
|
||||
'data-owner': owner,
|
||||
'data-repo': repo,
|
||||
'data-description': description,
|
||||
'data-url': url,
|
||||
style:
|
||||
'display:block;border:1px solid #ddd;border-radius:8px;' +
|
||||
'padding:12px 16px;margin:8px 0;font-family:inherit;',
|
||||
},
|
||||
[
|
||||
'a',
|
||||
{ href: url, style: 'font-weight:600;text-decoration:none;color:inherit;' },
|
||||
`${owner}/${repo}`,
|
||||
],
|
||||
['div', { style: 'opacity:0.7;font-size:0.9em;margin-top:4px;' }, description],
|
||||
]
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Resolves owner/repo via portal's own /gitea-repo handler
|
||||
// (content::gitea_repo_handler in src/content.rs), not Gitea's API
|
||||
// directly - keeps this editor from needing a CORS allowance on
|
||||
// Gitea's side, and matches every other backing store in this app
|
||||
// being server-resolved only.
|
||||
async function resolveGiteaRepo(owner, repo) {
|
||||
const res = await fetch(
|
||||
`/gitea-repo?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`,
|
||||
)
|
||||
if (!res.ok) {
|
||||
throw new Error(`resolve_gitea_repo failed with status ${res.status}`)
|
||||
}
|
||||
return await res.json()
|
||||
}
|
||||
|
||||
function defineGiteaRepoEmbedPasteRule() {
|
||||
return definePasteHandler((view, event) => {
|
||||
const text = event.clipboardData?.getData('text/plain')?.trim()
|
||||
const match = text && text.match(GITEA_REPO_URL_RE)
|
||||
if (!match) return false
|
||||
|
||||
event.preventDefault()
|
||||
const [, owner, repo] = match
|
||||
const pos = view.state.selection.from
|
||||
|
||||
resolveGiteaRepo(owner, repo)
|
||||
.then((info) => {
|
||||
const nodeType = view.state.schema.nodes.giteaRepoEmbed
|
||||
if (!nodeType) return
|
||||
const node = nodeType.create(info)
|
||||
const tr = view.state.tr.replaceRangeWith(pos, pos, node)
|
||||
view.dispatch(tr)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('gitea repo embed failed', err)
|
||||
})
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function mountEditor(container, hiddenInput, initial) {
|
||||
ensureStylesheet('https://esm.sh/prosekit/basic/style.css')
|
||||
ensureStylesheet('https://esm.sh/prosekit/basic/typography.css')
|
||||
|
||||
const extension = union(
|
||||
defineBasicExtension(),
|
||||
defineGiteaRepoEmbed(),
|
||||
defineGiteaRepoEmbedPasteRule(),
|
||||
)
|
||||
|
||||
const editor = createEditor({
|
||||
extension,
|
||||
// Seeds from the hidden input's current value (only ever non-empty
|
||||
// when re-mounting content that already went through this same
|
||||
// editor, since that's the only place this HTML shape comes from).
|
||||
defaultContent: initial || undefined,
|
||||
})
|
||||
|
||||
const sync = () => {
|
||||
const html = editor.view.dom.innerHTML
|
||||
if (hiddenInput.value === html) return
|
||||
hiddenInput.value = html
|
||||
hiddenInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
|
||||
editor.use(defineUpdateHandler(sync))
|
||||
editor.mount(container)
|
||||
sync()
|
||||
}
|
||||
Reference in New Issue
Block a user