@pramen/cms-editor 0.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -0
- package/dist/index.html +12 -0
- package/dist/main.c43qkeax.js +339 -0
- package/package.json +28 -0
- package/src/api.ts +106 -0
- package/src/app.tsx +688 -0
- package/src/fields.tsx +212 -0
- package/src/main.tsx +11 -0
- package/src/styles.ts +108 -0
- package/src/types.ts +118 -0
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pramen/cms-editor",
|
|
3
|
+
"version": "0.0.14",
|
|
4
|
+
"description": "Visual block/page editor for @pramen/cms — a standalone React SPA that talks to the CMS handlers over HTTP.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/netvarec/pramen.git",
|
|
9
|
+
"directory": "packages/cms-editor"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/netvarec/pramen#readme",
|
|
12
|
+
"bugs": "https://github.com/netvarec/pramen/issues",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"files": ["dist", "src"],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "bun run scripts/build.ts",
|
|
17
|
+
"dev": "bun run scripts/build.ts --watch",
|
|
18
|
+
"prepublishOnly": "bun run build"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"react": "^19.0.0",
|
|
22
|
+
"react-dom": "^19.0.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/react": "^19.0.0",
|
|
26
|
+
"@types/react-dom": "^19.0.0"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// HTTP client for the CMS handlers. Bearer token + the pramen `{ ok, result }` envelope,
|
|
2
|
+
// same transport shape as @pramen/admin's api.ts. Config is persisted in localStorage.
|
|
3
|
+
|
|
4
|
+
import type { AssembledPage, AuditEntry, BlockType, ContentType, Media, Page } from "./types";
|
|
5
|
+
|
|
6
|
+
export interface Config {
|
|
7
|
+
baseUrl: string;
|
|
8
|
+
token: string;
|
|
9
|
+
tenant: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class ApiError extends Error {
|
|
13
|
+
constructor(
|
|
14
|
+
message: string,
|
|
15
|
+
readonly code: string,
|
|
16
|
+
readonly status: number,
|
|
17
|
+
) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "ApiError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const LS = "pramen.cmsEditor";
|
|
24
|
+
export function loadConfig(): Config {
|
|
25
|
+
try {
|
|
26
|
+
const raw = localStorage.getItem(LS);
|
|
27
|
+
if (raw) return JSON.parse(raw) as Config;
|
|
28
|
+
} catch {
|
|
29
|
+
/* ignore */
|
|
30
|
+
}
|
|
31
|
+
return { baseUrl: "http://localhost:8787", token: "", tenant: "main" };
|
|
32
|
+
}
|
|
33
|
+
export function saveConfig(cfg: Config): void {
|
|
34
|
+
localStorage.setItem(LS, JSON.stringify(cfg));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class Api {
|
|
38
|
+
constructor(private cfg: Config) {}
|
|
39
|
+
|
|
40
|
+
setConfig(cfg: Config): void {
|
|
41
|
+
this.cfg = cfg;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private base(): string {
|
|
45
|
+
return this.cfg.baseUrl.replace(/\/+$/, "");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Call a CMS RPC handler. Throws ApiError on a non-`ok` envelope. */
|
|
49
|
+
async call<T = unknown>(name: string, input?: unknown): Promise<T> {
|
|
50
|
+
const res = await fetch(`${this.base()}/rpc/${name}`, {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: {
|
|
53
|
+
"content-type": "application/json",
|
|
54
|
+
"x-pramen-tenant": this.cfg.tenant || "main",
|
|
55
|
+
...(this.cfg.token ? { authorization: `Bearer ${this.cfg.token}` } : {}),
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify(input ?? {}),
|
|
58
|
+
});
|
|
59
|
+
let body: { ok?: boolean; result?: unknown; error?: string; code?: string };
|
|
60
|
+
try {
|
|
61
|
+
body = await res.json();
|
|
62
|
+
} catch {
|
|
63
|
+
throw new ApiError(`non-JSON response (HTTP ${res.status})`, "bad_response", res.status);
|
|
64
|
+
}
|
|
65
|
+
if (body.ok !== true) {
|
|
66
|
+
const msg = body.error ?? `request failed (HTTP ${res.status})`;
|
|
67
|
+
const hint = res.status === 403 ? " — check your token has an editor/reviewer role" : "";
|
|
68
|
+
throw new ApiError(msg + hint, body.code ?? "error", res.status);
|
|
69
|
+
}
|
|
70
|
+
return body.result as T;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Upload bytes to a signed url (relative → resolved against the base). */
|
|
74
|
+
async put(signedUrl: string, body: Blob | ArrayBuffer, contentType: string): Promise<void> {
|
|
75
|
+
const url = signedUrl.startsWith("http") ? signedUrl : `${this.base()}${signedUrl}`;
|
|
76
|
+
const res = await fetch(url, { method: "PUT", headers: { "content-type": contentType }, body });
|
|
77
|
+
if (!res.ok) throw new ApiError(`upload failed (HTTP ${res.status})`, "upload_failed", res.status);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Absolute URL for a relative media/serving path. */
|
|
81
|
+
resolve(path: string): string {
|
|
82
|
+
return path.startsWith("http") ? path : `${this.base()}${path}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// --- typed convenience wrappers ---
|
|
86
|
+
listBlockTypes = () => this.call<BlockType[]>("listBlockTypes");
|
|
87
|
+
listContentTypes = () => this.call<ContentType[]>("listContentTypes");
|
|
88
|
+
getContentType = (id: string) => this.call<ContentType | null>("getContentType", { id });
|
|
89
|
+
listPages = () => this.call<Page[]>("listPages");
|
|
90
|
+
getPagePreview = (slug: string, locale?: string) => this.call<AssembledPage>("getPage", { slug, locale, preview: true });
|
|
91
|
+
listPageAudit = (pageId: string) => this.call<AuditEntry[]>("listPageAudit", { pageId });
|
|
92
|
+
|
|
93
|
+
// --- media ---
|
|
94
|
+
listMedia = (limit = 50, offset = 0) => this.call<Media[]>("listMedia", { limit, offset });
|
|
95
|
+
getMedia = (id: string) => this.call<Media | null>("getMedia", { id });
|
|
96
|
+
updateMedia = (id: string, alt: string | null) => this.call<Media>("updateMedia", { id, alt });
|
|
97
|
+
deleteMedia = (id: string) => this.call<{ ok: true }>("deleteMedia", { id });
|
|
98
|
+
|
|
99
|
+
/** Full upload flow: sign → PUT the bytes → persist a `cms_media` row. Returns the row. */
|
|
100
|
+
async uploadMedia(file: File): Promise<Media> {
|
|
101
|
+
const contentType = file.type || "application/octet-stream";
|
|
102
|
+
const signed = await this.call<{ url: string; ref: { key: string; contentType: string; filename?: string } }>("signMediaUpload", { contentType, filename: file.name });
|
|
103
|
+
await this.put(signed.url, await file.arrayBuffer(), contentType);
|
|
104
|
+
return this.call<Media>("createMedia", { ref: signed.ref, alt: file.name });
|
|
105
|
+
}
|
|
106
|
+
}
|