feat: extract shared infrastructure from shared_lib

Phase 1-3 of decoupling plan:
- Shared DB, models, infrastructure, browser, config, utils
- Event infrastructure (domain_events outbox, bus, processor)
- Structured logging
- Generic container concept (container_type/container_id)
- Alembic migrations for all schema changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-11 12:45:56 +00:00
commit ef806f8fbb
533 changed files with 276497 additions and 0 deletions

81
editor/src/Editor.jsx Normal file
View File

@@ -0,0 +1,81 @@
import { useMemo, useState, useEffect, useCallback } from "react";
import { KoenigComposer, KoenigEditor, CardMenuPlugin } from "@tryghost/koenig-lexical";
import "koenig-styles";
import makeFileUploader from "./useFileUpload";
export default function Editor({ initialState, onChange, csrfToken, uploadUrls, oembedUrl, unsplashApiKey, snippetsUrl }) {
const fileUploader = useMemo(() => makeFileUploader(csrfToken, uploadUrls), [csrfToken, uploadUrls]);
const [snippets, setSnippets] = useState([]);
useEffect(() => {
if (!snippetsUrl) return;
fetch(snippetsUrl, { headers: { "X-CSRFToken": csrfToken || "" } })
.then((r) => r.ok ? r.json() : [])
.then(setSnippets)
.catch(() => {});
}, [snippetsUrl, csrfToken]);
const createSnippet = useCallback(async ({ name, value }) => {
if (!snippetsUrl) return;
const resp = await fetch(snippetsUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": csrfToken || "",
},
body: JSON.stringify({ name, value: JSON.stringify(value) }),
});
if (!resp.ok) return;
const created = await resp.json();
setSnippets((prev) => {
const idx = prev.findIndex((s) => s.name === created.name);
if (idx >= 0) {
const next = [...prev];
next[idx] = created;
return next;
}
return [...prev, created].sort((a, b) => a.name.localeCompare(b.name));
});
}, [snippetsUrl, csrfToken]);
const cardConfig = useMemo(() => ({
fetchEmbed: async (url, { type } = {}) => {
const params = new URLSearchParams({ url });
if (type) params.set("type", type);
const resp = await fetch(`${oembedUrl}?${params}`, {
headers: { "X-CSRFToken": csrfToken || "" },
});
if (!resp.ok) return {};
return resp.json();
},
unsplash: unsplashApiKey
? { defaultHeaders: { Authorization: `Client-ID ${unsplashApiKey}` } }
: false,
membersEnabled: true,
snippets: snippets.map((s) => ({
id: s.id,
name: s.name,
value: typeof s.value === "string" ? JSON.parse(s.value) : s.value,
})),
createSnippet,
}), [oembedUrl, csrfToken, unsplashApiKey, snippets, createSnippet]);
return (
<KoenigComposer
initialEditorState={initialState || undefined}
fileUploader={fileUploader}
cardConfig={cardConfig}
>
<KoenigEditor
onChange={(serializedState) => {
if (onChange) {
onChange(JSON.stringify(serializedState));
}
}}
>
<CardMenuPlugin />
</KoenigEditor>
</KoenigComposer>
);
}

49
editor/src/index.jsx Normal file
View File

@@ -0,0 +1,49 @@
import React from "react";
import { createRoot } from "react-dom/client";
import Editor from "./Editor";
/**
* Mount the Koenig editor into the given DOM element.
*
* @param {string} elementId - ID of the container element
* @param {object} opts
* @param {string} [opts.initialJson] - Serialised Lexical JSON (from Ghost)
* @param {string} [opts.csrfToken] - CSRF token for API calls
* @param {object} [opts.uploadUrls] - { image, media, file } upload endpoint URLs
* @param {string} [opts.oembedUrl] - oEmbed proxy endpoint URL
* @param {string} [opts.unsplashApiKey] - Unsplash API key for image search
*/
window.mountEditor = function mountEditor(elementId, opts = {}) {
const container = document.getElementById(elementId);
if (!container) {
console.error(`[editor] Element #${elementId} not found`);
return;
}
let currentJson = opts.initialJson || null;
function handleChange(json) {
currentJson = json;
// Stash the latest JSON in a hidden input for form submission
const hidden = document.getElementById("lexical-json-input");
if (hidden) hidden.value = json;
}
const root = createRoot(container);
root.render(
<Editor
initialState={opts.initialJson || null}
onChange={handleChange}
csrfToken={opts.csrfToken || ""}
uploadUrls={opts.uploadUrls || ""}
oembedUrl={opts.oembedUrl || "/editor-api/oembed/"}
unsplashApiKey={opts.unsplashApiKey || ""}
snippetsUrl={opts.snippetsUrl || ""}
/>
);
// Return handle for programmatic access
return {
getJson: () => currentJson,
};
};

View File

@@ -0,0 +1,99 @@
import { useState, useCallback, useRef } from "react";
/**
* Koenig expects `fileUploader.useFileUpload(type)` — a React hook it
* calls internally for each card type ("image", "audio", "file", etc.).
*
* `makeFileUploader(csrfToken, uploadUrls)` returns the object Koenig wants:
* { useFileUpload: (type) => { upload, progress, isLoading, errors, filesNumber } }
*
* `uploadUrls` is an object: { image, media, file }
* For backwards compat, a plain string is treated as the image URL.
*/
const URL_KEY_MAP = {
image: { urlKey: "image", responseKey: "images" },
audio: { urlKey: "media", responseKey: "media" },
video: { urlKey: "media", responseKey: "media" },
mediaThumbnail: { urlKey: "image", responseKey: "images" },
file: { urlKey: "file", responseKey: "files" },
};
export default function makeFileUploader(csrfToken, uploadUrls) {
// Normalise: string → object with all keys pointing to same URL
const urls =
typeof uploadUrls === "string"
? { image: uploadUrls, media: uploadUrls, file: uploadUrls }
: uploadUrls || {};
return {
fileTypes: {
image: { mimeTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'] },
audio: { mimeTypes: ['audio/mpeg', 'audio/ogg', 'audio/wav', 'audio/mp4', 'audio/aac'] },
video: { mimeTypes: ['video/mp4', 'video/webm', 'video/ogg'] },
mediaThumbnail: { mimeTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] },
file: { mimeTypes: [] },
},
useFileUpload(type) {
const mapping = URL_KEY_MAP[type] || URL_KEY_MAP.image;
const [progress, setProgress] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState([]);
const [filesNumber, setFilesNumber] = useState(0);
const csrfRef = useRef(csrfToken);
const urlRef = useRef(urls[mapping.urlKey] || urls.image || "/editor-api/images/upload/");
const responseKeyRef = useRef(mapping.responseKey);
const upload = useCallback(async (files) => {
const fileList = Array.from(files);
setFilesNumber(fileList.length);
setIsLoading(true);
setErrors([]);
setProgress(0);
const results = [];
for (let i = 0; i < fileList.length; i++) {
const file = fileList[i];
const formData = new FormData();
formData.append("file", file);
try {
const resp = await fetch(urlRef.current, {
method: "POST",
body: formData,
headers: {
"X-CSRFToken": csrfRef.current || "",
},
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
const msg =
err.errors?.[0]?.message || `Upload failed (${resp.status})`;
setErrors((prev) => [
...prev,
{ message: msg, fileName: file.name },
]);
continue;
}
const data = await resp.json();
const fileUrl = data[responseKeyRef.current]?.[0]?.url;
if (fileUrl) {
results.push({ url: fileUrl, fileName: file.name });
}
} catch (e) {
setErrors((prev) => [
...prev,
{ message: e.message, fileName: file.name },
]);
}
setProgress(Math.round(((i + 1) / fileList.length) * 100));
}
setIsLoading(false);
return results;
}, []);
return { upload, progress, isLoading, errors, filesNumber };
},
};
}