const BLOCK_TYPES = ['FullWidth', 'TwoColumns']; const SLOT_TYPES = ['text', 'image', 'video']; const MEDIA_TYPES = ['youtube', 'frameio', 'bunny', 'video', 'image']; let blockCounter = 0; const nextBlockId = (prefix = 'block') => `${prefix}-${Date.now()}-${blockCounter++}`; const toString = (value) => (typeof value === 'string' ? value : ''); // Blob URLs are temporary object URLs only valid in the current browser session. // Strip them so they are never serialized into the saved JSON structure. const sanitizeUrl = (value) => { const str = toString(value); return str.startsWith('blob:') ? '' : str; }; const normalizeAwards = (value) => { if (!value) { return []; } if (Array.isArray(value)) { return value.map((item) => toString(item).trim()).filter(Boolean); } return toString(value) .split('\n') .map((item) => item.trim()) .filter(Boolean); }; const normalizeMedia = (media) => { const candidate = media && typeof media === 'object' ? media : {}; const type = MEDIA_TYPES.includes(candidate.type) ? candidate.type : 'image'; return { type, url: sanitizeUrl(candidate.url), poster: sanitizeUrl(candidate.poster), autoplay: candidate.autoplay === true, loop: candidate.loop === true, muted: candidate.muted === true, }; }; const normalizeImage = (image, defaultLang = '') => { const candidate = image && typeof image === 'object' ? image : {}; const rawAlt = candidate.alt; let alt; if (!rawAlt) { alt = {}; } else if (typeof rawAlt === 'string') { alt = defaultLang ? { [defaultLang]: rawAlt } : { _: rawAlt }; } else if (typeof rawAlt === 'object') { alt = Object.fromEntries(Object.entries(rawAlt).map(([k, v]) => [k, toString(v)])); } else { alt = {}; } return { url: sanitizeUrl(candidate.url), alt, }; }; export const PROJECT_SCHEMA_EXAMPLE = { header: { headline: 'Modern Office Design', }, thumbnailMedia: { type: 'image', url: '', poster: '', }, heroMedia: { type: 'youtube', url: 'https://www.youtube.com/watch?v=ScMzIvxBSi4', }, metadata: { clientName: 'SOZ', awarded: ['SOF - Gold Award - Art Direction', 'SOF - Silver Award - Design'], description: 'A modular project schema that can drive both the public page and the cPad editor preview.', }, contentBlocks: [ { id: nextBlockId(), type: 'FullWidth', slot: { type: 'text', content: 'Project overview text rendered inside a full-width row.', image: { url: '', alt: '' }, media: { type: 'youtube', url: '', poster: '' }, }, }, { id: nextBlockId(), type: 'TwoColumns', left: { type: 'image', content: {}, image: { url: 'https://picsum.photos/1200/900?random=11', alt: 'Left project visual' }, media: { type: 'youtube', url: '', poster: '' }, }, right: { type: 'image', content: {}, image: { url: 'https://picsum.photos/1200/900?random=12', alt: 'Right project visual' }, media: { type: 'youtube', url: '', poster: '' }, }, }, { id: nextBlockId(), type: 'FullWidth', slot: { type: 'video', content: {}, image: { url: '', alt: '' }, media: { type: 'youtube', url: 'https://www.youtube.com/watch?v=ysz5S6PUM-U', poster: '' }, }, }, ], }; export const createEmptyProjectSchema = () => ({ header: { headline: '', subline: '', }, thumbnailMedia: { type: 'image', url: '', poster: '', autoplay: false, loop: false, muted: false, }, heroMedia: { type: 'image', url: '', poster: '', }, metadata: { clientName: '', awarded: [], description: '', }, contentBlocks: [], }); export const createBlock = (type) => { const createSlot = (slotType = 'text') => ({ type: slotType, content: {}, image: { url: '', alt: {} }, media: { type: 'youtube', url: '', poster: '' }, }); if (type === 'TwoColumns') { return { id: nextBlockId(), type: 'TwoColumns', name: '', hidden: false, left: createSlot('image'), right: createSlot('image'), }; } // FullWidth (default) return { id: nextBlockId(), type: 'FullWidth', name: '', hidden: false, slot: createSlot('text'), }; }; const normalizeSlot = (slot, defaultLang = '') => { const candidate = slot && typeof slot === 'object' ? slot : {}; const type = SLOT_TYPES.includes(candidate.type) ? candidate.type : 'text'; const rawContent = candidate.content; let content = {}; if (rawContent && typeof rawContent === 'string') { content = defaultLang ? { [defaultLang]: rawContent } : { _: rawContent }; } else if (rawContent && typeof rawContent === 'object') { content = Object.fromEntries(Object.entries(rawContent).map(([k, v]) => [k, toString(v)])); } return { type, content, image: normalizeImage(candidate.image, defaultLang), media: normalizeMedia(candidate.media), }; }; const normalizeBlock = (block, index, defaultLang = '') => { const candidate = block && typeof block === 'object' ? block : {}; const type = candidate.type; const id = toString(candidate.id) || `block-${index}`; // --- Legacy migrations --- if (type === 'FullWidthText') { const rawContent = candidate.content; let content = {}; if (rawContent && typeof rawContent === 'string') { content = defaultLang ? { [defaultLang]: rawContent } : { _: rawContent }; } else if (rawContent && typeof rawContent === 'object') { content = Object.fromEntries(Object.entries(rawContent).map(([k, v]) => [k, toString(v)])); } return { id, type: 'FullWidth', slot: { type: 'text', content, image: normalizeImage(null, defaultLang), media: normalizeMedia(null) }, }; } if (type === 'FullWidthImage') { return { id, type: 'FullWidth', slot: { type: 'image', content: {}, image: normalizeImage(candidate.image, defaultLang), media: normalizeMedia(null) }, }; } if (type === 'Video') { return { id, type: 'FullWidth', slot: { type: 'video', content: {}, image: normalizeImage(null, defaultLang), media: normalizeMedia(candidate.media) }, }; } if (type === 'TwoColumnImages') { const images = Array.isArray(candidate.images) ? candidate.images.slice(0, 2) : []; return { id, type: 'TwoColumns', left: { type: 'image', content: {}, image: normalizeImage(images[0], defaultLang), media: normalizeMedia(null) }, right: { type: 'image', content: {}, image: normalizeImage(images[1], defaultLang), media: normalizeMedia(null) }, }; } // --- New types --- if (type === 'TwoColumns') { return { id, type: 'TwoColumns', name: toString(candidate.name) || '', hidden: candidate.hidden === true, left: normalizeSlot(candidate.left, defaultLang), right: normalizeSlot(candidate.right, defaultLang), }; } // FullWidth (default) return { id, type: 'FullWidth', name: toString(candidate.name) || '', hidden: candidate.hidden === true, slot: normalizeSlot(candidate.slot, defaultLang), }; }; export const normalizeProjectSchema = (project, defaultLang = '') => { const candidate = project && typeof project === 'object' ? project : {}; const base = createEmptyProjectSchema(); const contentBlocks = Array.isArray(candidate.contentBlocks) ? candidate.contentBlocks : Array.isArray(candidate.blocks) ? candidate.blocks : []; return { header: { headline: toString(candidate.header?.headline ?? candidate.project_title), subline: toString(candidate.header?.subline), }, thumbnailMedia: normalizeMedia(candidate.thumbnailMedia ?? candidate.thumbnail_media), heroMedia: normalizeMedia(candidate.heroMedia ?? candidate.hero_media), metadata: { clientName: toString(candidate.metadata?.clientName), awarded: normalizeAwards(candidate.metadata?.awarded), description: toString(candidate.metadata?.description), }, contentBlocks: contentBlocks.map((b, i) => normalizeBlock(b, i, defaultLang)), backgroundColor: toString(candidate.backgroundColor ?? base.backgroundColor), }; }; export const createProjectSchemaFromLegacy = (legacy) => { const candidate = legacy && typeof legacy === 'object' ? legacy : {}; const heroMedia = candidate.youtube ? { type: 'youtube', url: candidate.youtube } : candidate.video ? { type: 'video', url: candidate.video } : { type: 'image', url: candidate.pictureCatalogFull || candidate.pictureCover || '' }; const contentBlocks = []; const gallery = Array.isArray(candidate.gallery) ? candidate.gallery : []; if (gallery.length >= 2) { contentBlocks.push({ type: 'TwoColumnImages', images: [gallery[0], gallery[1]], }); } else if (candidate.pictureCover || candidate.pictureCatalog) { contentBlocks.push({ type: 'TwoColumnImages', images: [ { url: candidate.pictureCover || candidate.pictureCatalog || '', alt: '' }, { url: candidate.pictureCatalog || candidate.pictureCover || '', alt: '' }, ], }); } if (candidate.description) { contentBlocks.push({ type: 'FullWidthText', content: candidate.description, }); } if (candidate.pictureCatalogFull) { contentBlocks.push({ type: 'FullWidthImage', image: { url: candidate.pictureCatalogFull, alt: '' }, }); } if (gallery.length >= 4) { contentBlocks.push({ type: 'TwoColumnImages', images: [gallery[2], gallery[3]], }); } if (candidate.video) { contentBlocks.push({ type: 'Video', media: { type: 'video', url: candidate.video }, }); } return normalizeProjectSchema({ header: { headline: candidate.headline, }, heroMedia, metadata: { clientName: candidate.clientName, awarded: candidate.awarded, description: candidate.description, }, contentBlocks, }); }; export const buildInitialProject = (payload) => { const candidate = payload && typeof payload === 'object' ? payload : {}; const defaultLang = toString(candidate.defaultLanguage); const hasModernStructure = candidate.structure && typeof candidate.structure === 'object' && !Array.isArray(candidate.structure) && (candidate.structure.header || candidate.structure.heroMedia || candidate.structure.contentBlocks || candidate.structure.blocks); if (hasModernStructure) { return normalizeProjectSchema(candidate.structure, defaultLang); } if (candidate.legacy) { return createProjectSchemaFromLegacy(candidate.legacy); } if (candidate.header || candidate.heroMedia || candidate.contentBlocks) { return normalizeProjectSchema(candidate, defaultLang); } return normalizeProjectSchema(PROJECT_SCHEMA_EXAMPLE); }; export const serializeProjectSchema = (project) => JSON.stringify(normalizeProjectSchema(project), null, 2); export const getYouTubeEmbedUrl = (media) => { const obj = media && typeof media === 'object' ? media : { url: media }; const value = toString(obj.url).trim(); if (!value) { return ''; } try { const parsed = new URL(value); const autoplay = obj.autoplay === true; const loop = obj.loop === true; const muted = obj.muted === true; let embedUrl = ''; let videoId = ''; if (parsed.hostname.includes('youtu.be')) { videoId = parsed.pathname.replace('/', ''); } else { videoId = parsed.searchParams.get('v') || ''; } if (!videoId && parsed.hostname.includes('youtube.com') && parsed.pathname.startsWith('/embed/')) { videoId = parsed.pathname.split('/').pop() || ''; } if (!videoId) { return value; } embedUrl = `https://www.youtube.com/embed/${videoId}`; const embed = new URL(embedUrl); embed.searchParams.set('autoplay', autoplay ? '1' : '0'); embed.searchParams.set('mute', muted ? '1' : '0'); embed.searchParams.set('loop', loop ? '1' : '0'); if (loop) { embed.searchParams.set('playlist', videoId); } return embed.toString(); } catch (error) { return value; } }; export const getBunnyEmbedUrl = (media) => { const obj = media && typeof media === 'object' ? media : { url: media }; const value = toString(obj.url).trim(); if (!value) { return ''; } try { const parsed = new URL(value); // Accept player.mediadelivery.net URLs directly if (parsed.hostname === 'player.mediadelivery.net') { // Strip existing playback params so we control them from the schema ['autoplay', 'loop', 'muted', 'preload', 'responsive'].forEach(p => parsed.searchParams.delete(p)); parsed.searchParams.set('autoplay', obj.autoplay ? 'true' : 'false'); parsed.searchParams.set('loop', obj.loop ? 'true' : 'false'); parsed.searchParams.set('muted', obj.muted ? 'true' : 'false'); parsed.searchParams.set('preload', 'true'); parsed.searchParams.set('responsive', 'true'); return parsed.toString(); } return value; } catch (error) { return value; } }; export const getFrameIoEmbedUrl = (url) => { const value = toString(url).trim(); if (!value) { return ''; } try { const parsed = new URL(value); // Already a player embed URL — use as-is if (parsed.hostname === 'player.frame.io') { return value; } // Review / presentation share link: https://app.frame.io/reviews/{id} if (parsed.hostname === 'app.frame.io' && parsed.pathname.startsWith('/reviews/')) { return value; } // New Frame.io share links: https://next.frame.io/share/{id} if (parsed.hostname === 'next.frame.io') { return value; } // Short share link: https://f.io/{token} if (parsed.hostname === 'f.io') { return value; } return value; } catch (error) { return value; } }; export const blockTypeOptions = [ { type: 'FullWidth', label: 'Full Width' }, { type: 'TwoColumns', label: 'Two Columns' }, ]; export const mediaTypeOptions = MEDIA_TYPES;