Compare commits
2 Commits
555dade01a
...
87ce5ff1d9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87ce5ff1d9 | ||
|
|
2144fac188 |
@@ -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
27
app/api/chat/route.ts
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
25
app/api/media/[id]/route.ts
Normal file
25
app/api/media/[id]/route.ts
Normal 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
47
app/api/media/route.ts
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
7
lib/cloud.ts
Normal 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 ?? "";
|
||||||
@@ -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
Reference in New Issue
Block a user