@velora-cms/plugin-sdk 0.9.0
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/LICENSE +202 -0
- package/README.md +27 -0
- package/dist/admin-section-registry.d.ts +4 -0
- package/dist/admin-section-registry.js +17 -0
- package/dist/built-in-serializers.d.ts +3 -0
- package/dist/built-in-serializers.js +18 -0
- package/dist/built-in-settings-schemas.d.ts +6 -0
- package/dist/built-in-settings-schemas.js +37 -0
- package/dist/built-ins.d.ts +1 -0
- package/dist/built-ins.js +142 -0
- package/dist/data-type-plugin.d.ts +21 -0
- package/dist/data-type-plugin.js +1 -0
- package/dist/datatypes-entry.d.ts +2 -0
- package/dist/datatypes-entry.js +10 -0
- package/dist/define-plugin.d.ts +25 -0
- package/dist/define-plugin.js +51 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +27 -0
- package/dist/manifest-entry.d.ts +6 -0
- package/dist/manifest-entry.js +9 -0
- package/dist/manifest-schema.d.ts +728 -0
- package/dist/manifest-schema.js +348 -0
- package/dist/manifest.d.ts +370 -0
- package/dist/manifest.js +12 -0
- package/dist/plugin-context.d.ts +30 -0
- package/dist/plugin-context.js +58 -0
- package/dist/registry.d.ts +4 -0
- package/dist/registry.js +17 -0
- package/dist/simple-storage-value-provider.d.ts +9 -0
- package/dist/simple-storage-value-provider.js +58 -0
- package/dist/structured-storage-types.d.ts +53 -0
- package/dist/structured-storage-types.js +11 -0
- package/dist/views/image-view.d.ts +3 -0
- package/dist/views/image-view.js +108 -0
- package/dist/views/rich-text-view.d.ts +2 -0
- package/dist/views/rich-text-view.js +58 -0
- package/package.json +58 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { Button, Input, PropertyField } from "@velora-cms/ui";
|
|
4
|
+
import { usePluginApi, usePluginSettings, usePluginSettingsValue, usePluginValue } from "../plugin-context.js";
|
|
5
|
+
// downloadUrl is a time-limited presigned URL, never persisted — refetch
|
|
6
|
+
// GET /api/media/:id whenever the field's value (media id) changes
|
|
7
|
+
// rather than caching one.
|
|
8
|
+
function useMediaPreview(mediaId) {
|
|
9
|
+
const fetchApi = usePluginApi();
|
|
10
|
+
const [preview, setPreview] = useState(null);
|
|
11
|
+
const [loading, setLoading] = useState(false);
|
|
12
|
+
const [error, setError] = useState(null);
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!mediaId) {
|
|
15
|
+
setPreview(null);
|
|
16
|
+
setError(null);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
let cancelled = false;
|
|
20
|
+
setLoading(true);
|
|
21
|
+
setError(null);
|
|
22
|
+
fetchApi(`/api/media/${mediaId}`)
|
|
23
|
+
.then(async (response) => {
|
|
24
|
+
if (!response.ok)
|
|
25
|
+
throw new Error(`Failed to load media (${response.status})`);
|
|
26
|
+
const body = (await response.json());
|
|
27
|
+
if (!cancelled)
|
|
28
|
+
setPreview({ filename: body.filename, downloadUrl: body.downloadUrl });
|
|
29
|
+
})
|
|
30
|
+
.catch((err) => {
|
|
31
|
+
if (!cancelled)
|
|
32
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
33
|
+
})
|
|
34
|
+
.finally(() => {
|
|
35
|
+
if (!cancelled)
|
|
36
|
+
setLoading(false);
|
|
37
|
+
});
|
|
38
|
+
return () => {
|
|
39
|
+
cancelled = true;
|
|
40
|
+
};
|
|
41
|
+
}, [mediaId, fetchApi]);
|
|
42
|
+
return { preview, loading, error };
|
|
43
|
+
}
|
|
44
|
+
function ImageThumbnail({ mediaId }) {
|
|
45
|
+
const { preview, loading, error } = useMediaPreview(mediaId);
|
|
46
|
+
if (!mediaId)
|
|
47
|
+
return null;
|
|
48
|
+
if (loading)
|
|
49
|
+
return _jsx("p", { className: "text-xs text-text-secondary", children: "Loading\u2026" });
|
|
50
|
+
if (error)
|
|
51
|
+
return _jsx("p", { className: "text-xs text-danger-600", children: error });
|
|
52
|
+
if (!preview)
|
|
53
|
+
return null;
|
|
54
|
+
return (_jsx("img", { src: preview.downloadUrl, alt: preview.filename, className: "h-24 w-24 rounded-md border border-border-default object-cover" }));
|
|
55
|
+
}
|
|
56
|
+
export function ImageInputView() {
|
|
57
|
+
const { value, onChange } = usePluginValue();
|
|
58
|
+
const settings = usePluginSettings();
|
|
59
|
+
const fetchApi = usePluginApi();
|
|
60
|
+
const [uploading, setUploading] = useState(false);
|
|
61
|
+
const [error, setError] = useState(null);
|
|
62
|
+
async function handleFileChange(event) {
|
|
63
|
+
const file = event.target.files?.[0];
|
|
64
|
+
event.target.value = ""; // lets the same file be re-selected later
|
|
65
|
+
if (!file)
|
|
66
|
+
return;
|
|
67
|
+
setError(null);
|
|
68
|
+
if (settings.maxSizeBytes != null && file.size > settings.maxSizeBytes) {
|
|
69
|
+
setError(`File is too large (${file.size} bytes) — the configured limit is ${settings.maxSizeBytes} bytes.`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
setUploading(true);
|
|
73
|
+
try {
|
|
74
|
+
const formData = new FormData();
|
|
75
|
+
formData.append("file", file);
|
|
76
|
+
const response = await fetchApi("/api/media", { method: "POST", body: formData });
|
|
77
|
+
if (!response.ok)
|
|
78
|
+
throw new Error(`Upload failed (${response.status})`);
|
|
79
|
+
const media = (await response.json());
|
|
80
|
+
onChange(media.id);
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
setUploading(false);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(ImageThumbnail, { mediaId: value }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("input", { type: "file", accept: settings.acceptedTypes && settings.acceptedTypes.length > 0 ? settings.acceptedTypes.join(",") : "image/*", disabled: uploading, onChange: (e) => void handleFileChange(e), className: "text-sm text-text-primary" }), value && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", disabled: uploading, onClick: () => onChange(null), children: "Remove" }))] }), error && _jsx("p", { className: "text-xs text-danger-600", children: error })] }));
|
|
90
|
+
}
|
|
91
|
+
// Rendered by the Data Types editor only — a settings-EDITING view (see
|
|
92
|
+
// TextSettingsView in built-ins.tsx for the same pattern/rationale).
|
|
93
|
+
// acceptedTypes is edited as a comma-separated string (no multi-select
|
|
94
|
+
// widget precedent in this codebase) and parsed to string[] on change.
|
|
95
|
+
export function ImageSettingsView() {
|
|
96
|
+
const { value: settings, onChange } = usePluginSettingsValue();
|
|
97
|
+
return (_jsxs("div", { className: "flex flex-col gap-4", children: [_jsx(PropertyField, { label: "Accepted file types", htmlFor: "image-settings-accepted-types", helpText: "Comma-separated MIME types, e.g. image/png,image/jpeg. Leave blank to accept any image.", children: _jsx(Input, { id: "image-settings-accepted-types", value: settings.acceptedTypes?.join(",") ?? "", onChange: (e) => {
|
|
98
|
+
const types = e.target.value
|
|
99
|
+
.split(",")
|
|
100
|
+
.map((t) => t.trim())
|
|
101
|
+
.filter((t) => t.length > 0);
|
|
102
|
+
onChange({ ...settings, acceptedTypes: types.length > 0 ? types : undefined });
|
|
103
|
+
} }) }), _jsx(PropertyField, { label: "Max file size (bytes)", htmlFor: "image-settings-max-size", helpText: "e.g. 5000000 for ~5MB. Leave blank for no limit.", children: _jsx(Input, { id: "image-settings-max-size", type: "number", min: 1, value: settings.maxSizeBytes ?? "", onChange: (e) => onChange({ ...settings, maxSizeBytes: e.target.value === "" ? undefined : Number(e.target.value) }) }) })] }));
|
|
104
|
+
}
|
|
105
|
+
export function ImageReadOnlyView() {
|
|
106
|
+
const { value } = usePluginValue();
|
|
107
|
+
return _jsx(ImageThumbnail, { mediaId: value });
|
|
108
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import { EditorContent, useEditor } from "@tiptap/react";
|
|
4
|
+
import StarterKit from "@tiptap/starter-kit";
|
|
5
|
+
import { cn } from "@velora-cms/ui";
|
|
6
|
+
import { usePluginValue } from "../plugin-context.js";
|
|
7
|
+
// StarterKit only (bold/italic/headings/lists/paragraph) — the
|
|
8
|
+
// foundation for the full content editor, not a fully-featured toolbar
|
|
9
|
+
// yet (Session 40/41). No toolbar UI: StarterKit's default keyboard
|
|
10
|
+
// shortcuts (Ctrl+B, "# "/"* " input rules) are enough to prove this is
|
|
11
|
+
// a real editor, not a plain textarea.
|
|
12
|
+
//
|
|
13
|
+
// Tailwind's base reset strips default heading/list styling, so a few
|
|
14
|
+
// targeted descendant overrides keep formatting visually distinguishable
|
|
15
|
+
// without pulling in a typography plugin just for this.
|
|
16
|
+
const CONTENT_CLASSES = "text-sm text-text-primary [&_h1]:text-xl [&_h1]:font-bold [&_h2]:text-lg [&_h2]:font-bold " +
|
|
17
|
+
"[&_h3]:font-bold [&_strong]:font-bold [&_em]:italic [&_ul]:list-disc [&_ul]:ps-6 " +
|
|
18
|
+
"[&_ol]:list-decimal [&_ol]:ps-6 [&_p]:min-h-[1.25em]";
|
|
19
|
+
function RichTextEditor({ editable }) {
|
|
20
|
+
const { value, onChange } = usePluginValue();
|
|
21
|
+
// Guards the sync effect below against reacting to the update it just
|
|
22
|
+
// caused itself — without this, every keystroke would round-trip
|
|
23
|
+
// through setContent and reset the cursor.
|
|
24
|
+
const isInternalUpdate = useRef(false);
|
|
25
|
+
const editor = useEditor({
|
|
26
|
+
extensions: [StarterKit],
|
|
27
|
+
content: value ?? "",
|
|
28
|
+
editable,
|
|
29
|
+
onUpdate: ({ editor }) => {
|
|
30
|
+
isInternalUpdate.current = true;
|
|
31
|
+
onChange(editor.getJSON());
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!editor)
|
|
36
|
+
return;
|
|
37
|
+
if (isInternalUpdate.current) {
|
|
38
|
+
isInternalUpdate.current = false;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const current = JSON.stringify(editor.getJSON());
|
|
42
|
+
const next = JSON.stringify(value ?? {});
|
|
43
|
+
if (current !== next) {
|
|
44
|
+
editor.commands.setContent(value ?? "");
|
|
45
|
+
}
|
|
46
|
+
}, [value, editor]);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
editor?.setEditable(editable);
|
|
49
|
+
}, [editable, editor]);
|
|
50
|
+
return (_jsx(EditorContent, { editor: editor, className: cn(CONTENT_CLASSES, editable &&
|
|
51
|
+
"rounded-md border border-border-default bg-background-elevated px-3 py-2 focus-within:outline-none focus-within:ring-2 focus-within:ring-primary-500 focus-within:ring-offset-2 focus-within:ring-offset-background-base") }));
|
|
52
|
+
}
|
|
53
|
+
export function RichTextInputView() {
|
|
54
|
+
return _jsx(RichTextEditor, { editable: true });
|
|
55
|
+
}
|
|
56
|
+
export function RichTextReadOnlyView() {
|
|
57
|
+
return _jsx(RichTextEditor, { editable: false });
|
|
58
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@velora-cms/plugin-sdk",
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"license": "Apache-2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/velora-cms/velora.git",
|
|
14
|
+
"directory": "packages/plugin-sdk"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"import": "./dist/index.js",
|
|
23
|
+
"default": "./dist/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./manifest": {
|
|
26
|
+
"types": "./dist/manifest-entry.d.ts",
|
|
27
|
+
"import": "./dist/manifest-entry.js",
|
|
28
|
+
"default": "./dist/manifest-entry.js"
|
|
29
|
+
},
|
|
30
|
+
"./datatypes": {
|
|
31
|
+
"types": "./dist/datatypes-entry.d.ts",
|
|
32
|
+
"import": "./dist/datatypes-entry.js",
|
|
33
|
+
"default": "./dist/datatypes-entry.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@tiptap/pm": "3.27.1",
|
|
38
|
+
"@tiptap/react": "3.27.1",
|
|
39
|
+
"@tiptap/starter-kit": "3.27.1",
|
|
40
|
+
"zod": "4.4.3",
|
|
41
|
+
"@velora-cms/api-schemas": "0.9.0",
|
|
42
|
+
"@velora-cms/ui": "0.9.0",
|
|
43
|
+
"@velora-cms/design-tokens": "0.9.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"react": "^19.0.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/json-schema": "7.0.15",
|
|
50
|
+
"@types/react": "19.2.17",
|
|
51
|
+
"react": "19.2.7",
|
|
52
|
+
"typescript": "6.0.3"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "tsc -p .",
|
|
56
|
+
"test": "vitest run"
|
|
57
|
+
}
|
|
58
|
+
}
|