Add React editor project
This commit is contained in:
@@ -1,59 +0,0 @@
|
||||
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}`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { site } from '@/config/site';
|
||||
|
||||
/** The subset of root props (see config/root.tsx) that describes a page. */
|
||||
interface EditorPage {
|
||||
root?: {
|
||||
props?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
ogImage?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface MetadataOverrides {
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
/** Root-relative path, used for the canonical and og:url. */
|
||||
path?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a page.json's root props into Next metadata. The editor exposes
|
||||
* title/description/ogImage on the root, so editing a page in the editor is
|
||||
* what changes its tags; `overrides` lets dynamic routes layer fetched product,
|
||||
* collection or policy data on top.
|
||||
*/
|
||||
export function pageMetadata(
|
||||
page: EditorPage,
|
||||
overrides: MetadataOverrides = {}
|
||||
): Metadata {
|
||||
const props = page.root?.props ?? {};
|
||||
|
||||
// Editor-authored titles already carry the brand ("About — Shop"), so they
|
||||
// opt out of the root template with `absolute`. A fetched override is a bare
|
||||
// product or collection name and flows through the template instead.
|
||||
const title = overrides.title ?? props.title ?? site.storeName;
|
||||
const socialTitle = overrides.title ? `${title} — ${site.storeName}` : title;
|
||||
const description =
|
||||
overrides.description ?? props.description ?? site.description;
|
||||
const image = overrides.image ?? props.ogImage;
|
||||
|
||||
return {
|
||||
title: overrides.title ? title : { absolute: title },
|
||||
description,
|
||||
alternates: overrides.path ? { canonical: overrides.path } : undefined,
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
siteName: site.storeName,
|
||||
title: socialTitle,
|
||||
description,
|
||||
url: overrides.path ?? '/',
|
||||
images: image ? [{ url: image, alt: title }] : undefined,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: socialTitle,
|
||||
description,
|
||||
images: image ? [image] : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Every editor-driven route in the app.
|
||||
*
|
||||
* `key` is what the editor's page picker shows and what `onPublish` hands back;
|
||||
* `dir` is the folder under `app/` holding that route's `page.json`. Keeping
|
||||
* the list here means the publish API can validate a request against it rather
|
||||
* than trusting a path from the browser.
|
||||
*/
|
||||
export interface PageRoute {
|
||||
key: string;
|
||||
label: string;
|
||||
/** Path under `app/`, relative and without a leading slash. */
|
||||
dir: string;
|
||||
}
|
||||
|
||||
export const PAGE_ROUTES: PageRoute[] = [
|
||||
{ key: '/', label: 'Home', dir: '' },
|
||||
{ key: '/about', label: 'About', dir: 'about' },
|
||||
{ key: '/collections', label: 'Collections', dir: 'collections' },
|
||||
{
|
||||
key: '/collections/[handle]',
|
||||
label: 'Collection detail',
|
||||
dir: 'collections/[handle]',
|
||||
},
|
||||
{
|
||||
key: '/products/[handle]',
|
||||
label: 'Product detail',
|
||||
dir: 'products/[handle]',
|
||||
},
|
||||
{ key: '/search', label: 'Search', dir: 'search' },
|
||||
{ key: '/policies/[handle]', label: 'Policy', dir: 'policies/[handle]' },
|
||||
{ key: '/account', label: 'Account — orders', dir: 'account' },
|
||||
{ key: '/account/login', label: 'Account — sign in', dir: 'account/login' },
|
||||
{
|
||||
key: '/account/register',
|
||||
label: 'Account — register',
|
||||
dir: 'account/register',
|
||||
},
|
||||
{
|
||||
key: '/account/recover',
|
||||
label: 'Account — recover',
|
||||
dir: 'account/recover',
|
||||
},
|
||||
{
|
||||
key: '/account/reset/[id]/[token]',
|
||||
label: 'Account — set password',
|
||||
dir: 'account/reset/[id]/[token]',
|
||||
},
|
||||
{
|
||||
key: '/account/activate/[id]/[token]',
|
||||
label: 'Account — activate',
|
||||
dir: 'account/activate/[id]/[token]',
|
||||
},
|
||||
];
|
||||
|
||||
export const ROUTE_KEYS = PAGE_ROUTES.map((route) => route.key);
|
||||
|
||||
export function findPageRoute(key: string): PageRoute | undefined {
|
||||
return PAGE_ROUTES.find((route) => route.key === key);
|
||||
}
|
||||
|
||||
/**
|
||||
* URL of the editor for a route: every public route has an `/editor` child, so
|
||||
* `/products/warrior-club-hoodie` is edited at
|
||||
* `/products/warrior-club-hoodie/editor`.
|
||||
*
|
||||
* Dynamic segments are filled from `params` when the caller knows them. The
|
||||
* page picker doesn't — switching to `/products/[handle]` there lands on the
|
||||
* literal `[handle]`, where the blocks render their empty state until a product
|
||||
* is picked. Opening the editor from a real product page is the path that
|
||||
* gives them concrete params.
|
||||
*/
|
||||
export function editorHref(
|
||||
routeKey: string,
|
||||
params: Record<string, string | undefined> = {}
|
||||
): string {
|
||||
const base =
|
||||
routeKey === '/'
|
||||
? ''
|
||||
: routeKey.replace(
|
||||
/\[(\w+)\]/g,
|
||||
(segment, name: string) => params[name] ?? segment
|
||||
);
|
||||
|
||||
return `${base}/editor`;
|
||||
}
|
||||
+6
-5
@@ -1,11 +1,12 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
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;
|
||||
export function truncate(str: string, maxLength: number): string {
|
||||
if (!str) return '';
|
||||
if (str.length <= maxLength) return str;
|
||||
return str.slice(0, maxLength).trim() + '...';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user