497 lines
15 KiB
JavaScript
497 lines
15 KiB
JavaScript
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; |