diff --git a/craft/src/panels/right/styles/shared.tsx b/craft/src/panels/right/styles/shared.tsx index 6a5fba4..8b7c853 100644 --- a/craft/src/panels/right/styles/shared.tsx +++ b/craft/src/panels/right/styles/shared.tsx @@ -3,6 +3,7 @@ import { useEditor } from '@craftjs/core'; import { GRADIENTS, } from '../../../constants/presets'; +import { uploadAsset } from '../../../utils/assets'; /* ---------- Helper: auto text color for bg ---------- */ export function autoTextColor(bg: string): string { @@ -17,23 +18,11 @@ export function autoTextColor(bg: string): string { return '#ffffff'; } -/* ---------- Helper: upload to WHP ---------- */ -export async function uploadToWhp(file: File): Promise { - 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; } -} +/* ---------- Helper: upload to WHP ---------- + Thin re-export over the shared `uploadAsset` util (`@/utils/assets`) so + the existing callers importing `uploadToWhp` from here keep working + unchanged. */ +export const uploadToWhp = uploadAsset; /* ---------- Shared inline styles ---------- */ export const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4, textTransform: 'capitalize' }; diff --git a/craft/src/panels/right/styles/shared.uploadToWhp.test.ts b/craft/src/panels/right/styles/shared.uploadToWhp.test.ts new file mode 100644 index 0000000..4e75f65 --- /dev/null +++ b/craft/src/panels/right/styles/shared.uploadToWhp.test.ts @@ -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'); + }); +}); diff --git a/craft/src/utils/assets.test.ts b/craft/src/utils/assets.test.ts new file mode 100644 index 0000000..69bcc7c --- /dev/null +++ b/craft/src/utils/assets.test.ts @@ -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; + + 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; + 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; + 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; + 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; + 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; + 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; + 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; + fetchMock.mockRejectedValue(new Error('network down')); + expect(await listAssets()).toEqual([]); + }); +}); diff --git a/craft/src/utils/assets.ts b/craft/src/utils/assets.ts new file mode 100644 index 0000000..4d78276 --- /dev/null +++ b/craft/src/utils/assets.ts @@ -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 { + 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 { + 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 []; + } +}