Compare commits

...

2 Commits

Author SHA1 Message Date
Rami Bitar
87ce5ff1d9 update the page data to use globals 2026-06-05 14:37:34 -04:00
Rami Bitar
2144fac188 update add api endpoints for chat and media 2026-06-05 12:19:48 -04:00
9 changed files with 121 additions and 85 deletions

View File

@@ -5,11 +5,13 @@ import { Render } from "@reacteditor/core";
import { appConfig } from "@/editor.config"; import { appConfig } from "@/editor.config";
import resolveRoute from "@/lib/resolve-route"; import resolveRoute from "@/lib/resolve-route";
import schema from "@/app.schema.json"; import schema from "@/app.schema.json";
import globals from "@/app.globals.json";
export default function Page() { export default function Page() {
const params = useParams(); const params = useParams();
const slug = Array.isArray(params?.slug) ? (params.slug as string[]) : []; const slug = Array.isArray(params?.slug) ? (params.slug as string[]) : [];
const { key } = resolveRoute(slug); const { key } = resolveRoute(slug);
const pageData = (schema as any)[key]; const { root, content } = (schema as any)[key] ?? {};
return <Render config={appConfig as any} data={pageData} />; const data = { root, content, globals };
return <Render config={appConfig as any} data={data} />;
} }

27
app/api/chat/route.ts Normal file
View File

@@ -0,0 +1,27 @@
import { NextRequest } from "next/server";
import { CLOUD_BASE, FRONTEND_API_KEY } from "@/lib/cloud";
// Proxy the editor's AI chat requests to Frontend Cloud, injecting the API key
// server-side so it is never exposed to the client. The cloud response is
// streamed straight back to the caller.
export async function POST(req: NextRequest) {
const upstream = await fetch(`${CLOUD_BASE}/api/chat`, {
method: "POST",
headers: {
"content-type": req.headers.get("content-type") ?? "application/json",
"x-api-key": FRONTEND_API_KEY,
},
body: req.body,
// Required by undici when forwarding a streaming request body.
// @ts-expect-error - duplex is valid but missing from the lib types.
duplex: "half",
});
return new Response(upstream.body, {
status: upstream.status,
headers: {
"content-type":
upstream.headers.get("content-type") ?? "application/octet-stream",
},
});
}

View File

@@ -0,0 +1,25 @@
import { NextRequest } from "next/server";
import { CLOUD_BASE, FRONTEND_API_KEY } from "@/lib/cloud";
// DELETE /api/media/:id — remove a media asset.
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const upstream = await fetch(
`${CLOUD_BASE}/api/media/${encodeURIComponent(id)}`,
{
method: "DELETE",
headers: { "x-api-key": FRONTEND_API_KEY },
},
);
return new Response(upstream.body, {
status: upstream.status,
headers: {
"content-type":
upstream.headers.get("content-type") ?? "application/json",
},
});
}

47
app/api/media/route.ts Normal file
View File

@@ -0,0 +1,47 @@
import { NextRequest } from "next/server";
import { CLOUD_BASE, FRONTEND_API_KEY } from "@/lib/cloud";
// GET /api/media — list/search media. Forwards query + cursor params.
export async function GET(req: NextRequest) {
const incoming = new URL(req.url);
const url = new URL("/api/media", CLOUD_BASE);
const query = incoming.searchParams.get("query");
const cursor = incoming.searchParams.get("cursor");
if (query) url.searchParams.set("query", query);
if (cursor) url.searchParams.set("cursor", cursor);
const upstream = await fetch(url, {
method: "GET",
headers: { "x-api-key": FRONTEND_API_KEY },
});
return new Response(upstream.body, {
status: upstream.status,
headers: {
"content-type":
upstream.headers.get("content-type") ?? "application/json",
},
});
}
// POST /api/media — upload. Forwards the multipart body as-is.
export async function POST(req: NextRequest) {
const upstream = await fetch(`${CLOUD_BASE}/api/media`, {
method: "POST",
headers: {
"content-type": req.headers.get("content-type") ?? "",
"x-api-key": FRONTEND_API_KEY,
},
body: req.body,
// @ts-expect-error - duplex is valid but missing from the lib types.
duplex: "half",
});
return new Response(upstream.body, {
status: upstream.status,
headers: {
"content-type":
upstream.headers.get("content-type") ?? "application/json",
},
});
}

View File

@@ -23,8 +23,8 @@ export default function EditorPage() {
const plugins = useMemo( const plugins = useMemo(
() => [ () => [
aiPlugin({ aiPlugin({
api: "https://cloud.frontend.co/api/chat", // Proxied through our server route so the API key stays server-side.
headers: { "x-api-key": process.env.NEXT_PUBLIC_FRONTEND_API_KEY }, api: "/api/chat",
}), }),
blocksPlugin(), blocksPlugin(),
outlinePlugin(), outlinePlugin(),

View File

@@ -4,20 +4,15 @@ import type {
MediaPage, MediaPage,
} from "@reacteditor/plugin-media"; } from "@reacteditor/plugin-media";
const CLOUD_BASE = "https://cloud.frontend.co"; // Requests go to our own /api/media proxy routes, which inject the API key
const FRONTEND_API_KEY = process.env.NEXT_PUBLIC_FRONTEND_API_KEY ?? ""; // server-side. No credentials are sent from the client.
export const mediaAdapter: MediaAdapter = { export const mediaAdapter: MediaAdapter = {
fetchList: async ({ query, cursor, signal }) => { fetchList: async ({ query, cursor, signal }) => {
const url = new URL("/api/media", CLOUD_BASE); const url = new URL("/api/media", window.location.origin);
if (query) url.searchParams.set("query", query); if (query) url.searchParams.set("query", query);
if (cursor) url.searchParams.set("cursor", cursor); if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, { const res = await fetch(url, { method: "GET", signal });
method: "GET",
headers: { "X-Api-Key": FRONTEND_API_KEY },
signal,
});
if (!res.ok) throw new Error(`List failed: ${res.status}`); if (!res.ok) throw new Error(`List failed: ${res.status}`);
return (await res.json()) as MediaPage; return (await res.json()) as MediaPage;
}, },
@@ -26,8 +21,7 @@ export const mediaAdapter: MediaAdapter = {
upload: (file, opts) => upload: (file, opts) =>
new Promise<MediaItem>((resolve, reject) => { new Promise<MediaItem>((resolve, reject) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open("POST", `${CLOUD_BASE}/api/media`); xhr.open("POST", "/api/media");
xhr.setRequestHeader("X-Api-Key", FRONTEND_API_KEY);
xhr.upload.onprogress = (e) => { xhr.upload.onprogress = (e) => {
if (e.lengthComputable) opts?.onProgress?.(e.loaded / e.total); if (e.lengthComputable) opts?.onProgress?.(e.loaded / e.total);
@@ -57,10 +51,9 @@ export const mediaAdapter: MediaAdapter = {
}), }),
delete: async (id) => { delete: async (id) => {
const res = await fetch( const res = await fetch(`/api/media/${encodeURIComponent(id)}`, {
`${CLOUD_BASE}/api/media/${encodeURIComponent(id)}`, method: "DELETE",
{ method: "DELETE", headers: { "X-Api-Key": FRONTEND_API_KEY } }, });
);
if (!res.ok) throw new Error(`Delete failed: ${res.status}`); if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
}, },
}; };

7
lib/cloud.ts Normal file
View File

@@ -0,0 +1,7 @@
// Server-only config for the Frontend Cloud proxy routes.
// The API key lives here (read from a non-public env var) so it is never
// shipped to the browser bundle.
export const CLOUD_BASE = "https://cloud.frontend.co";
export const FRONTEND_API_KEY = process.env.FRONTEND_API_KEY ?? "";

View File

@@ -1,65 +0,0 @@
import type {
MediaAdapter,
MediaItem,
MediaPage,
} from "@reacteditor/plugin-media";
const FRONTEND_CLOUD = "https://cloud.frontend.co";
const FRONTEND_API_KEY = process.env.NEXT_PUBLIC_FRONTEND_API_KEY ?? "";
export const frontendAiMediaAdapter: MediaAdapter = {
fetchList: async ({ query, cursor, signal }) => {
const url = new URL("/api/media", FRONTEND_CLOUD);
if (query) url.searchParams.set("query", query);
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, {
method: "GET",
headers: { "X-Api-Key": FRONTEND_API_KEY },
signal,
});
if (!res.ok) throw new Error(`List failed: ${res.status}`);
return (await res.json()) as MediaPage;
},
upload: (file, opts) =>
new Promise<MediaItem>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${FRONTEND_CLOUD}/api/media`);
xhr.setRequestHeader("X-Api-Key", FRONTEND_API_KEY);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) opts?.onProgress?.(e.loaded / e.total);
};
xhr.onload = () => {
if (xhr.status >= 400) {
reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`));
return;
}
try {
resolve(JSON.parse(xhr.responseText) as MediaItem);
} catch (err) {
reject(err instanceof Error ? err : new Error(String(err)));
}
};
xhr.onerror = () => reject(new Error("Network error"));
xhr.onabort = () => {
const err = new Error("Aborted");
err.name = "AbortError";
reject(err);
};
opts?.signal?.addEventListener("abort", () => xhr.abort());
const fd = new FormData();
fd.append("file", file);
xhr.send(fd);
}),
delete: async (id) => {
const res = await fetch(
`${FRONTEND_CLOUD}/api/media/${encodeURIComponent(id)}`,
{
method: "DELETE",
headers: { "X-Api-Key": FRONTEND_API_KEY },
},
);
if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
},
};

File diff suppressed because one or more lines are too long