feat(builder): shared asset upload/list util
Extract uploadAsset/listAssets into utils/assets.ts, lifting the exact uploadToWhp body and list_assets fetch pattern already duplicated across shared.tsx/ImageBlock/Logo/Navbar/etc. shared.tsx's uploadToWhp is now a thin re-export (`export const uploadToWhp = uploadAsset`) so its ~6 existing callers are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { useEditor } from '@craftjs/core';
|
|||||||
import {
|
import {
|
||||||
GRADIENTS,
|
GRADIENTS,
|
||||||
} from '../../../constants/presets';
|
} from '../../../constants/presets';
|
||||||
|
import { uploadAsset } from '../../../utils/assets';
|
||||||
|
|
||||||
/* ---------- Helper: auto text color for bg ---------- */
|
/* ---------- Helper: auto text color for bg ---------- */
|
||||||
export function autoTextColor(bg: string): string {
|
export function autoTextColor(bg: string): string {
|
||||||
@@ -17,23 +18,11 @@ export function autoTextColor(bg: string): string {
|
|||||||
return '#ffffff';
|
return '#ffffff';
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Helper: upload to WHP ---------- */
|
/* ---------- Helper: upload to WHP ----------
|
||||||
export async function uploadToWhp(file: File): Promise<string | null> {
|
Thin re-export over the shared `uploadAsset` util (`@/utils/assets`) so
|
||||||
const cfg = (window as any).WHP_CONFIG;
|
the existing callers importing `uploadToWhp` from here keep working
|
||||||
if (!cfg) return URL.createObjectURL(file);
|
unchanged. */
|
||||||
const formData = new FormData();
|
export const uploadToWhp = uploadAsset;
|
||||||
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; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- Shared inline styles ---------- */
|
/* ---------- Shared inline styles ---------- */
|
||||||
export const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4, textTransform: 'capitalize' };
|
export const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4, textTransform: 'capitalize' };
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, test, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { uploadToWhp } from './shared';
|
||||||
|
import { uploadAsset } from '../../../utils/assets';
|
||||||
|
|
||||||
|
describe('shared.uploadToWhp', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
delete (window as any).WHP_CONFIG;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is a thin re-export of uploadAsset (same function reference)', () => {
|
||||||
|
expect(uploadToWhp).toBe(uploadAsset);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('still works standalone (no WHP_CONFIG -> blob: URL) for existing callers', async () => {
|
||||||
|
(URL as any).createObjectURL = vi.fn(() => 'blob:mock-url');
|
||||||
|
const file = new File(['x'], 'photo.png', { type: 'image/png' });
|
||||||
|
const url = await uploadToWhp(file);
|
||||||
|
expect(url).toBe('blob:mock-url');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { uploadAsset, listAssets, Asset } from './assets';
|
||||||
|
|
||||||
|
const CFG = {
|
||||||
|
apiUrl: 'https://panel.example.com/api/site-builder',
|
||||||
|
siteId: 42,
|
||||||
|
csrfToken: 'tok-123',
|
||||||
|
};
|
||||||
|
|
||||||
|
function jsonResponse(body: any, ok = true) {
|
||||||
|
return { ok, json: async () => body } as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('uploadAsset', () => {
|
||||||
|
let createObjectURLSpy: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
createObjectURLSpy = vi.fn(() => 'blob:mock-url');
|
||||||
|
(URL as any).createObjectURL = createObjectURLSpy;
|
||||||
|
vi.stubGlobal('fetch', vi.fn());
|
||||||
|
delete (window as any).WHP_CONFIG;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
delete (window as any).WHP_CONFIG;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without window.WHP_CONFIG returns a blob: URL and does not call fetch', async () => {
|
||||||
|
const file = new File(['x'], 'photo.png', { type: 'image/png' });
|
||||||
|
const url = await uploadAsset(file);
|
||||||
|
expect(url).toBe('blob:mock-url');
|
||||||
|
expect(createObjectURLSpy).toHaveBeenCalledWith(file);
|
||||||
|
expect(fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with config, POSTs to upload_asset with X-CSRF-Token and returns data.url', async () => {
|
||||||
|
(window as any).WHP_CONFIG = CFG;
|
||||||
|
const fetchMock = fetch as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
fetchMock.mockResolvedValue(jsonResponse({ success: true, url: '/storage/assets/photo.png' }));
|
||||||
|
|
||||||
|
const file = new File(['x'], 'photo.png', { type: 'image/png' });
|
||||||
|
const url = await uploadAsset(file);
|
||||||
|
|
||||||
|
expect(url).toBe('/storage/assets/photo.png');
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [calledUrl, opts] = fetchMock.mock.calls[0];
|
||||||
|
expect(calledUrl).toBe(`${CFG.apiUrl}?action=upload_asset&site_id=${CFG.siteId}`);
|
||||||
|
expect(opts.method).toBe('POST');
|
||||||
|
expect(opts.headers['X-CSRF-Token']).toBe(CFG.csrfToken);
|
||||||
|
expect(opts.body).toBeInstanceOf(FormData);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with config, returns null when the server reports failure', async () => {
|
||||||
|
(window as any).WHP_CONFIG = CFG;
|
||||||
|
const fetchMock = fetch as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
fetchMock.mockResolvedValue(jsonResponse({ success: false }));
|
||||||
|
|
||||||
|
const file = new File(['x'], 'photo.png', { type: 'image/png' });
|
||||||
|
expect(await uploadAsset(file)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with config, returns null when fetch throws', async () => {
|
||||||
|
(window as any).WHP_CONFIG = CFG;
|
||||||
|
const fetchMock = fetch as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
fetchMock.mockRejectedValue(new Error('network down'));
|
||||||
|
|
||||||
|
const file = new File(['x'], 'photo.png', { type: 'image/png' });
|
||||||
|
expect(await uploadAsset(file)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listAssets', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn());
|
||||||
|
delete (window as any).WHP_CONFIG;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
delete (window as any).WHP_CONFIG;
|
||||||
|
});
|
||||||
|
|
||||||
|
const ASSETS: Asset[] = [
|
||||||
|
{ name: 'a.png', url: '/storage/assets/a.png', type: 'image/png' },
|
||||||
|
{ name: 'b.mp4', url: '/storage/assets/b.mp4', type: 'video/mp4' },
|
||||||
|
{ name: 'c.pdf', url: '/storage/assets/c.pdf', type: 'application/pdf' },
|
||||||
|
];
|
||||||
|
|
||||||
|
test('without window.WHP_CONFIG returns [] and does not call fetch', async () => {
|
||||||
|
const assets = await listAssets('image');
|
||||||
|
expect(assets).toEqual([]);
|
||||||
|
expect(fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with config, filters by type prefix for the requested mediaType', async () => {
|
||||||
|
(window as any).WHP_CONFIG = CFG;
|
||||||
|
const fetchMock = fetch as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
fetchMock.mockResolvedValue(jsonResponse({ success: true, assets: ASSETS }));
|
||||||
|
|
||||||
|
const images = await listAssets('image');
|
||||||
|
expect(images).toEqual([ASSETS[0]]);
|
||||||
|
|
||||||
|
const videos = await listAssets('video');
|
||||||
|
expect(videos).toEqual([ASSETS[1]]);
|
||||||
|
|
||||||
|
const [calledUrl] = fetchMock.mock.calls[0];
|
||||||
|
expect(calledUrl).toBe(`${CFG.apiUrl}?action=list_assets&site_id=${CFG.siteId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mediaType "any" (or omitted) returns all assets unfiltered', async () => {
|
||||||
|
(window as any).WHP_CONFIG = CFG;
|
||||||
|
const fetchMock = fetch as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
fetchMock.mockResolvedValue(jsonResponse({ success: true, assets: ASSETS }));
|
||||||
|
|
||||||
|
expect(await listAssets('any')).toEqual(ASSETS);
|
||||||
|
expect(await listAssets()).toEqual(ASSETS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with config, returns [] when the server reports failure or a non-array', async () => {
|
||||||
|
(window as any).WHP_CONFIG = CFG;
|
||||||
|
const fetchMock = fetch as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
fetchMock.mockResolvedValue(jsonResponse({ success: false }));
|
||||||
|
expect(await listAssets()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with config, returns [] when fetch throws', async () => {
|
||||||
|
(window as any).WHP_CONFIG = CFG;
|
||||||
|
const fetchMock = fetch as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
fetchMock.mockRejectedValue(new Error('network down'));
|
||||||
|
expect(await listAssets()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/* ---------- Shared asset upload/list util ----------
|
||||||
|
Single source of truth for talking to the WHP asset endpoints
|
||||||
|
(`upload_asset` / `list_assets`). Behavior is lifted verbatim from the
|
||||||
|
`uploadToWhp` helper duplicated across `shared.tsx` / `ImageBlock.tsx` /
|
||||||
|
`Logo.tsx` / `Navbar.tsx` / `VideoBlock.tsx` / `FeaturesGrid.tsx` and the
|
||||||
|
`list_assets` fetch pattern used in `ImageStylePanel.tsx` / `Logo.tsx` /
|
||||||
|
`Navbar.tsx` / `HeroStylePanel.tsx` / `ImageBlock.tsx` / `VideoBlock.tsx`,
|
||||||
|
so callers see identical behavior once switched over to this module. */
|
||||||
|
|
||||||
|
export interface Asset {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload a file to the WHP asset store. Falls back to a local `blob:` URL
|
||||||
|
* in standalone mode (no `window.WHP_CONFIG`). Returns `null` on failure. */
|
||||||
|
export async function uploadAsset(file: File): Promise<string | null> {
|
||||||
|
const cfg = (window as any).WHP_CONFIG;
|
||||||
|
if (!cfg) return URL.createObjectURL(file);
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List assets uploaded for the current site, optionally filtered by
|
||||||
|
* media type (matched against the asset's `type` prefix, e.g.
|
||||||
|
* `image/png`.startsWith('image')). Returns `[]` in standalone mode or
|
||||||
|
* on any failure. */
|
||||||
|
export async function listAssets(mediaType: 'image' | 'video' | 'any' = 'any'): Promise<Asset[]> {
|
||||||
|
const cfg = (window as any).WHP_CONFIG;
|
||||||
|
if (!cfg) return [];
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${cfg.apiUrl}?action=list_assets&site_id=${cfg.siteId}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!data.success || !Array.isArray(data.assets)) return [];
|
||||||
|
if (mediaType === 'any') return data.assets;
|
||||||
|
return data.assets.filter((a: any) => (a.type || '').startsWith(mediaType));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user