feat(editor): adopt backend final-fix contract (client_id, warnings, limits, head parity)

client_id per editor load (memory only), already_saved, save warnings via notices,
upload_max_bytes enforced client-side with the server error text on 413,
project_max_bytes warning, headCode in the Preview font scan, pinned backend heads.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-10-07 13:49:53 -07:00
1 parent eb401cd979
commit 9d425afa29
17 files changed
+581 -38

No files matched your search

@@ -60,3 +60,40 @@ describe('ImageBlock render falls back to the placeholder for an explicit empty
container.remove();
});
});
describe('ImageBlock drop upload honours the server limits and error text', () => {
const dropFile = (file: File) => {
const img = container.querySelector('img')!;
const ev = new Event('drop', { bubbles: true, cancelable: true }) as any;
ev.dataTransfer = { files: [file] };
return act(async () => { img.dispatchEvent(ev); await Promise.resolve(); await Promise.resolve(); });
};
test('an oversize image is refused with the readable message and never sent', async () => {
const { setSiteLimits, onUploadError } = await import('../../utils/site-limits');
const errors: string[] = [];
const off = onUploadError((m) => errors.push(m));
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
(window as any).WHP_CONFIG = { apiUrl: '/api', siteId: 1, csrfToken: 't' };
setSiteLimits({ upload_max_bytes: 4 });
render(<ImageBlock />);
await dropFile(new File(['12345'], 'huge.png', { type: 'image/png' }));
expect(fetchMock).not.toHaveBeenCalled();
expect(errors).toHaveLength(1);
expect(errors[0]).toContain('huge.png');
off(); setSiteLimits(undefined); delete (window as any).WHP_CONFIG; vi.unstubAllGlobals();
});
test('a 413 from the server surfaces its own error text', async () => {
const { onUploadError } = await import('../../utils/site-limits');
const errors: string[] = [];
const off = onUploadError((m) => errors.push(m));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 413, json: async () => ({ success: false, error: 'That file is too large. The site builder accepts files up to 64 MB.' }) }));
(window as any).WHP_CONFIG = { apiUrl: '/api', siteId: 1, csrfToken: 't' };
render(<ImageBlock />);
await dropFile(new File(['x'], 'a.png', { type: 'image/png' }));
expect(errors).toEqual(['That file is too large. The site builder accepts files up to 64 MB.']);
off(); delete (window as any).WHP_CONFIG; vi.unstubAllGlobals();
});
});
+4 -18
View File
@@ -2,6 +2,7 @@ import React, { CSSProperties, useCallback, useRef } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, safeImageUrl } from '../../utils/escape';
import { uploadAsset } from '../../utils/assets';
export const PLACEHOLDER_SRC = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300'%3E%3Cdefs%3E%3ClinearGradient id='bg' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%23f1f5f9'/%3E%3Cstop offset='100%25' stop-color='%23e2e8f0'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23bg)' width='400' height='300' rx='12'/%3E%3Crect x='2' y='2' width='396' height='296' rx='10' fill='none' stroke='%23cbd5e1' stroke-width='2' stroke-dasharray='8 4'/%3E%3Cg transform='translate(200,110)'%3E%3Crect x='-28' y='-28' width='56' height='56' rx='12' fill='%23cbd5e1' opacity='0.5'/%3E%3Cpath d='M-12 8 L-4 -2 L2 4 L8 -6 L16 8Z' fill='%2394a3b8'/%3E%3Ccircle cx='-6' cy='-10' r='5' fill='%2394a3b8'/%3E%3C/g%3E%3Ctext x='200' y='160' text-anchor='middle' fill='%2364748b' font-family='Inter,sans-serif' font-size='15' font-weight='500'%3EDrop image here%3C/text%3E%3Ctext x='200' y='182' text-anchor='middle' fill='%2394a3b8' font-family='Inter,sans-serif' font-size='12'%3Eor click to upload%3C/text%3E%3C/svg%3E";
@@ -28,24 +29,9 @@ export function pxAttr(v: unknown): string | undefined {
return m ? m[1] : undefined;
}
// Helper: upload a file to the WHP API and return the proxy URL
async function uploadToWhp(file: File): Promise<string | null> {
const cfg = (window as any).WHP_CONFIG;
if (!cfg) return URL.createObjectURL(file); // Standalone fallback
const formData = new FormData();
formData.append('file', file);
try {
const resp = await fetch(`${cfg.apiUrl}?action=upload_asset&site_id=${cfg.siteId}`, {
method: 'POST',
headers: { 'X-CSRF-Token': cfg.csrfToken },
body: formData,
});
const data = await resp.json();
if (data.success && data.url) return data.url;
return null;
} catch { return null; }
}
// Upload goes through the shared util: it enforces limits.upload_max_bytes and
// reports the server's own error text (e.g. the 413 size message).
const uploadToWhp = uploadAsset;
export const ImageBlock: UserComponent<ImageBlockProps> = ({
src = PLACEHOLDER_SRC,