Initial commit
This commit is contained in:
59
lib/adapters/media-adapter.ts
Normal file
59
lib/adapters/media-adapter.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type {
|
||||
MediaAdapter,
|
||||
MediaItem,
|
||||
MediaPage,
|
||||
} from "@reacteditor/plugin-media";
|
||||
|
||||
// Requests go to our own /api/media proxy routes, which inject the API key
|
||||
// server-side. No credentials are sent from the client.
|
||||
export const mediaAdapter: MediaAdapter = {
|
||||
fetchList: async ({ query, cursor, signal }) => {
|
||||
const url = new URL("/api/media", window.location.origin);
|
||||
if (query) url.searchParams.set("query", query);
|
||||
if (cursor) url.searchParams.set("cursor", cursor);
|
||||
|
||||
const res = await fetch(url, { method: "GET", signal });
|
||||
if (!res.ok) throw new Error(`List failed: ${res.status}`);
|
||||
return (await res.json()) as MediaPage;
|
||||
},
|
||||
|
||||
// XHR (not fetch) so we get real upload progress.
|
||||
upload: (file, opts) =>
|
||||
new Promise<MediaItem>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/api/media");
|
||||
|
||||
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(`/api/media/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
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 ?? "";
|
||||
64
lib/resolve-route.ts
Normal file
64
lib/resolve-route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import schema from "@/app.schema.json";
|
||||
|
||||
export type ResolvedRoute = {
|
||||
key: string;
|
||||
path: string;
|
||||
params: Record<string, string>;
|
||||
};
|
||||
|
||||
const ROUTE_KEYS = Object.keys(schema as Record<string, unknown>);
|
||||
|
||||
/**
|
||||
* Matches a concrete path's segments against a single express-style pattern
|
||||
* (e.g. `/products/:handle`). Returns the captured params, or null on mismatch.
|
||||
*/
|
||||
const matchPattern = (
|
||||
pattern: string,
|
||||
segments: string[],
|
||||
): Record<string, string> | null => {
|
||||
const patternSegments = pattern === "/" ? [] : pattern.slice(1).split("/");
|
||||
if (patternSegments.length !== segments.length) return null;
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
const part = patternSegments[i];
|
||||
if (part.startsWith(":")) {
|
||||
params[part.slice(1)] = segments[i];
|
||||
} else if (part !== segments[i]) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves catch-all slug segments to a route key defined in the schema,
|
||||
* supporting any express-style pattern (`/products/:handle`, `/blog/:slug`,
|
||||
* etc.). Static segments are preferred over dynamic ones when both match.
|
||||
*/
|
||||
const resolveRoute = (segments: string[] = []): ResolvedRoute => {
|
||||
// Editor routes live under `/editor/*`; the `editor` prefix is not part of
|
||||
// the schema route keys, so strip it before matching.
|
||||
const routeSegments =
|
||||
segments[0] === "editor" ? segments.slice(1) : segments;
|
||||
const path =
|
||||
routeSegments.length === 0 ? "/" : `/${routeSegments.join("/")}`;
|
||||
|
||||
let best: ResolvedRoute | null = null;
|
||||
let bestDynamicCount = Infinity;
|
||||
|
||||
for (const key of ROUTE_KEYS) {
|
||||
const params = matchPattern(key, routeSegments);
|
||||
if (!params) continue;
|
||||
|
||||
const dynamicCount = Object.keys(params).length;
|
||||
if (dynamicCount < bestDynamicCount) {
|
||||
best = { key, path, params };
|
||||
bestDynamicCount = dynamicCount;
|
||||
}
|
||||
}
|
||||
|
||||
return best ?? { key: path, path, params: {} };
|
||||
};
|
||||
|
||||
export default resolveRoute;
|
||||
54
lib/use-demo-data.ts
Normal file
54
lib/use-demo-data.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import config, { componentKey } from "../config";
|
||||
import { getInitialData, initialData } from "../config/initial-data";
|
||||
import { Metadata, resolveAllData } from "@reacteditor/core";
|
||||
import { Components, UserData } from "../config/types";
|
||||
import { RootProps } from "../config/root";
|
||||
|
||||
const isBrowser = typeof window !== "undefined";
|
||||
|
||||
export const useDemoData = ({
|
||||
path,
|
||||
isEdit,
|
||||
metadata = {},
|
||||
}: {
|
||||
path: string;
|
||||
isEdit: boolean;
|
||||
metadata?: Metadata;
|
||||
}) => {
|
||||
// unique b64 key that updates each time we add / remove components
|
||||
const key = `react-editor-demo:${componentKey}:${path}`;
|
||||
|
||||
const [data] = useState<Partial<UserData>>(() => {
|
||||
if (isBrowser) {
|
||||
const dataStr = localStorage.getItem(key);
|
||||
|
||||
if (dataStr) {
|
||||
return JSON.parse(dataStr);
|
||||
}
|
||||
|
||||
return getInitialData(path);
|
||||
}
|
||||
});
|
||||
|
||||
// Normally this would happen on the server, but we can't
|
||||
// do that because we're using local storage as a database
|
||||
const [resolvedData, setResolvedData] = useState<Partial<UserData>>(data);
|
||||
|
||||
useEffect(() => {
|
||||
if (data && !isEdit) {
|
||||
resolveAllData<Components, RootProps>(data, config, metadata).then(
|
||||
setResolvedData
|
||||
);
|
||||
}
|
||||
}, [data, isEdit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEdit) {
|
||||
const title = data?.root?.props?.title || data?.root?.title;
|
||||
document.title = title || "";
|
||||
}
|
||||
}, [data, isEdit]);
|
||||
|
||||
return { data, resolvedData, key };
|
||||
};
|
||||
11
lib/utils.ts
Normal file
11
lib/utils.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function truncate(text: string, max = 80): string {
|
||||
if (!text) return "";
|
||||
return text.length > max ? text.slice(0, max - 1).trimEnd() + "…" : text;
|
||||
}
|
||||
Reference in New Issue
Block a user