Files
aritmija/resources/js/projects-renderer/schema/projectSchema.js
2026-05-13 17:11:09 +02:00

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;