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

1918 lines
82 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { computed, onMounted, onUnmounted, provide, reactive, ref, watch } from 'vue';
import ImageDropZone from './components/ImageDropZone.vue';
import RichTextEditor from './components/RichTextEditor.vue';
import ProjectPageRenderer from './ProjectPageRenderer.vue';
import {
PROJECT_SCHEMA_EXAMPLE,
blockTypeOptions,
buildInitialProject,
createBlock,
mediaTypeOptions,
serializeProjectSchema,
} from './schema/projectSchema';
const props = defineProps({
initialProject: {
type: Object,
default: () => ({}),
},
fieldId: {
type: String,
default: 'project-structure-field',
},
uploadUrl: {
type: String,
default: null,
},
languages: {
type: Array,
default: () => [],
},
defaultLanguage: {
type: String,
default: '',
},
categories: {
type: Array,
default: () => [],
},
activeValue: {
default: 1,
},
});
const HERO_ID = '__hero__';
const project = ref(buildInitialProject(props.initialProject));
const selectedBlockId = ref(HERO_ID);
const isHeroSelected = computed(() => selectedBlockId.value === HERO_ID);
const showJson = ref(false);
const showSort = ref(false);
const showSettings = ref(false);
const showProjectSettings = ref(false);
const mobilePreview = ref(false);
// --- Picture upload drop zones ---
const coverInputRef = ref(null);
const ogInputRef = ref(null);
const coverPreview = ref(props.initialProject?.legacy?.pictureCover || null);
const ogPreview = ref(props.initialProject?.legacy?.pictureCatalog || null);
const coverDragOver = ref(false);
const ogDragOver = ref(false);
watch(
() => project.value.thumbnailMedia?.type,
(type) => {
if (!project.value.thumbnailMedia) {
project.value.thumbnailMedia = { type: 'image', url: '', poster: '', autoplay: false, loop: false, muted: false };
}
if (type === 'bunny') {
project.value.thumbnailMedia.autoplay = true;
project.value.thumbnailMedia.loop = true;
project.value.thumbnailMedia.muted = true;
return;
}
project.value.thumbnailMedia.url = '';
project.value.thumbnailMedia.poster = '';
project.value.thumbnailMedia.autoplay = false;
project.value.thumbnailMedia.loop = false;
project.value.thumbnailMedia.muted = false;
},
{ immediate: true },
);
const assignFileToInput = (inputEl, file, previewRef) => {
if (!file || !file.type.startsWith('image/')) return;
// Set file on the native input so it is included in form submission
const dt = new DataTransfer();
dt.items.add(file);
inputEl.files = dt.files;
// Show preview
if (previewRef.value && previewRef.value.startsWith('blob:')) {
URL.revokeObjectURL(previewRef.value);
}
previewRef.value = URL.createObjectURL(file);
};
const onCoverDrop = (e) => {
e.preventDefault(); coverDragOver.value = false;
assignFileToInput(coverInputRef.value, e.dataTransfer?.files?.[0], coverPreview);
};
const onOgDrop = (e) => {
e.preventDefault(); ogDragOver.value = false;
assignFileToInput(ogInputRef.value, e.dataTransfer?.files?.[0], ogPreview);
};
const onCoverChange = (e) => {
const f = e.target.files?.[0]; if (f) {
if (coverPreview.value?.startsWith('blob:')) URL.revokeObjectURL(coverPreview.value);
coverPreview.value = URL.createObjectURL(f);
}
};
const onOgChange = (e) => {
const f = e.target.files?.[0]; if (f) {
if (ogPreview.value?.startsWith('blob:')) URL.revokeObjectURL(ogPreview.value);
ogPreview.value = URL.createObjectURL(f);
}
};
const clearCover = (e) => { e.stopPropagation(); coverPreview.value = null; if (coverInputRef.value) coverInputRef.value.value = ''; };
const clearOg = (e) => { e.stopPropagation(); ogPreview.value = null; if (ogInputRef.value) ogInputRef.value.value = ''; };
// --- Active ---
const isActive = ref(!!Number(props.activeValue));
// --- Categories ---
const categoryChecked = reactive(
Object.fromEntries((props.categories || []).map((c) => [c.id, c.checked === true]))
);
const categoriesTouched = ref(false);
// --- Multi-language ---
const selectedLang = ref(props.defaultLanguage || props.languages[0]?.iso || '');
// Initialize allTranslations with an entry for every language
const initTranslations = () => {
const base = props.initialProject?.translations || {};
const out = {};
const langs = props.languages.length ? props.languages : (selectedLang.value ? [{ iso: selectedLang.value }] : []);
for (const lang of langs) {
out[lang.iso] = {
headline: '',
subline: '',
clientName: '',
year: '',
content: '',
description: '',
awarded: [],
pageTitle: '',
metaTitle: '',
metaDescription: '',
ogTitle: '',
ogDescription: '',
...( base[lang.iso] || {}),
};
}
return out;
};
const allTranslations = ref(initTranslations());
const currentTranslation = computed(() => {
if (!allTranslations.value[selectedLang.value]) {
allTranslations.value[selectedLang.value] = { headline: '', subline: '', clientName: '', year: '', content: '', description: '', awarded: [], pageTitle: '', metaTitle: '', metaDescription: '', ogTitle: '', ogDescription: '' };
}
return allTranslations.value[selectedLang.value];
});
const awardedText = computed({
get: () => (currentTranslation.value.awarded || []).join('\n'),
set: (value) => {
currentTranslation.value.awarded = value.split('\n').map((s) => s.trim()).filter(Boolean);
},
});
const previewProject = computed(() => {
const t = allTranslations.value[selectedLang.value] || {};
return {
...project.value,
// Keep hidden blocks visible in editor — they just won't appear on the frontend
contentBlocks: project.value.contentBlocks,
header: { headline: t.headline || '', subline: t.subline || '' },
metadata: {
clientName: t.clientName || '',
awarded: t.awarded?.length ? t.awarded : [],
description: t.description || '',
},
};
});
// ----------------------
const selectedBlock = computed(() => project.value.contentBlocks.find((block) => block.id === selectedBlockId.value) ?? null);
const selectedBlockIndex = computed(() => project.value.contentBlocks.findIndex((b) => b.id === selectedBlockId.value));
// Provide upload context so preview block components can accept drag-drop directly
provide('editorUploadUrl', computed(() => props.uploadUrl));
// Track pending uploads so the parent form can be blocked while images are in flight
const pendingUploads = ref(0);
provide('editorPendingUploads', pendingUploads);
provide('editorUpdateBlockImage', (blockId, field, index, url) => {
const block = project.value.contentBlocks.find((b) => b.id === blockId);
if (!block) return;
// Legacy
if (field === 'image') block.image.url = url;
else if (field === 'images') block.images[index].url = url;
else if (field === 'poster') block.media.poster = url;
// New slot-based
else if (field === 'slot') block.slot.image.url = url;
else if (field === 'left') block.left.image.url = url;
else if (field === 'right') block.right.image.url = url;
});
provide('editorUpdateHeroImage', (url) => {
project.value.heroMedia.url = url;
});
provide('editorUpdateMetadata', (field, value) => {
if (currentTranslation.value) {
currentTranslation.value[field] = value;
}
});
provide('editorUpdateHeader', (field, value) => {
if (currentTranslation.value) {
currentTranslation.value[field] = value;
}
});
provide('editorToggleBlockVisibility', (blockId) => {
const b = project.value.contentBlocks.find((bl) => bl.id === blockId);
if (b) b.hidden = !b.hidden;
});
provide('editorSwapBlockColumns', (blockId) => {
const b = project.value.contentBlocks.find((bl) => bl.id === blockId);
if (b && b.left !== undefined && b.right !== undefined) {
const tmp = b.left;
b.left = b.right;
b.right = tmp;
}
});
const scrollToBlock = (blockId) => {
// Use nextTick so the canvas has rendered if visibility just changed
const el = document.querySelector(`[data-block-id="${blockId}"]`);
if (!el) return;
// Ensure the scroll container honours smooth behaviour
document.documentElement.style.scrollBehavior = 'smooth';
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const serializedProject = computed(() => serializeProjectSchema(project.value));
// Dirty tracking — becomes true after the first user-driven change.
const isDirty = ref(false);
let _initialSerialized = null;
watch(
serializedProject,
(value, oldValue) => {
const field = document.getElementById(props.fieldId);
if (field) {
field.value = value;
}
// Capture baseline on first run (immediate); mark dirty on subsequent changes.
if (_initialSerialized === null) {
_initialSerialized = value;
} else if (value !== _initialSerialized) {
isDirty.value = true;
}
},
{ immediate: true }
);
// Also mark dirty when translation fields change (they are not in serializedProject).
watch(allTranslations, () => { isDirty.value = true; }, { deep: true });
// Block the parent <form> from submitting while any image upload is still in progress
let _parentForm = null;
let _formId = null;
const getFormSubmitButtons = () => {
const buttons = [];
if (_parentForm) {
_parentForm.querySelectorAll('[type="submit"]').forEach((el) => buttons.push(el));
}
if (_formId) {
document.querySelectorAll(`[type="submit"][form="${_formId}"]`).forEach((el) => {
if (!buttons.includes(el)) buttons.push(el);
});
}
return buttons;
};
const handleFormSubmit = (event) => {
// Check for pending uploads first
if (pendingUploads.value > 0) {
event.preventDefault();
event.stopImmediatePropagation();
if (window.toastr) {
toastr.error(
`${pendingUploads.value} image${pendingUploads.value > 1 ? 's are' : ' is'} still uploading. Please wait before saving.`,
'Upload in progress'
);
}
return;
}
// Secondary safety check: scan the serialized JSON for any blob: URLs that
// somehow slipped through (e.g. uploaded without an endpoint configured).
const json = serializedProject.value || '';
const blobMatches = json.match(/"blob:[^"]+"/g);
if (blobMatches) {
event.preventDefault();
event.stopImmediatePropagation();
if (window.toastr) {
toastr.error(
`${blobMatches.length} image${blobMatches.length > 1 ? 's have' : ' has'} not finished uploading to the server yet. Please wait and try again.`,
'Unsaved images detected'
);
}
}
};
watch(pendingUploads, (count) => {
const buttons = getFormSubmitButtons();
buttons.forEach((btn) => {
if (count > 0) {
btn.disabled = true;
btn.dataset.uploadsPending = '1';
} else {
btn.disabled = false;
delete btn.dataset.uploadsPending;
}
});
});
const handleBeforeUnload = (event) => {
if (!isDirty.value) return;
event.preventDefault();
// Required for legacy browser support; modern browsers show their own message.
event.returnValue = '';
};
const handleFormSave = () => {
// Allow navigation after a successful save by clearing the dirty flag.
if (pendingUploads.value === 0) {
isDirty.value = false;
}
};
onMounted(() => {
const field = document.getElementById(props.fieldId);
const form = field?.closest('form');
if (!form) return;
_parentForm = form;
_formId = form.id || null;
_parentForm.addEventListener('submit', handleFormSubmit);
_parentForm.addEventListener('submit', handleFormSave);
window.addEventListener('beforeunload', handleBeforeUnload);
});
onUnmounted(() => {
if (_parentForm) {
_parentForm.removeEventListener('submit', handleFormSubmit);
_parentForm.removeEventListener('submit', handleFormSave);
_parentForm = null;
}
_formId = null;
window.removeEventListener('beforeunload', handleBeforeUnload);
});
const SLOT_ICONS = {
image: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`,
text: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="17" y1="18" x2="3" y2="18"/></svg>`,
video: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>`,
};
const LAYOUT_ICONS = {
TwoColumns: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="9" height="18" rx="1"/><rect x="13" y="3" width="9" height="18" rx="1"/></svg>`,
FullWidth: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="18" rx="1"/></svg>`,
};
const blockLabel = (block) => {
const prefix = block.name ? `${block.name}` : '';
if (block.type === 'FullWidth') return `${prefix}Full Width · ${block.slot?.type ?? '?'}`;
if (block.type === 'TwoColumns') return `${prefix}Two Columns · ${block.left?.type ?? '?'} / ${block.right?.type ?? '?'}`;
return `${prefix}${block.type}`;
};
const selectBlock = (blockId) => {
selectedBlockId.value = blockId;
showSort.value = false;
showSettings.value = false;
showProjectSettings.value = false;
};
const addBlock = (type) => {
const block = createBlock(type);
project.value.contentBlocks.push(block);
selectedBlockId.value = block.id;
};
const removeSelectedBlock = () => {
if (!selectedBlock.value) {
return;
}
project.value.contentBlocks = project.value.contentBlocks.filter((block) => block.id !== selectedBlock.value.id);
selectedBlockId.value = project.value.contentBlocks[0]?.id ?? null;
};
const moveSelectedBlock = (direction) => {
if (!selectedBlock.value) {
return;
}
const currentIndex = project.value.contentBlocks.findIndex((block) => block.id === selectedBlock.value.id);
const nextIndex = currentIndex + direction;
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= project.value.contentBlocks.length) {
return;
}
const copy = [...project.value.contentBlocks];
const [item] = copy.splice(currentIndex, 1);
copy.splice(nextIndex, 0, item);
project.value.contentBlocks = copy;
};
const loadExampleSchema = () => {
project.value = buildInitialProject(PROJECT_SCHEMA_EXAMPLE);
selectedBlockId.value = project.value.contentBlocks[0]?.id ?? null;
};
// --- Drag-and-drop sorting ---
const dragSrcId = ref(null);
const dragOverId = ref(null);
const onDragStart = (event, blockId) => {
dragSrcId.value = blockId;
event.dataTransfer.effectAllowed = 'move';
};
const onDragOver = (event, blockId) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
dragOverId.value = blockId;
};
const onDragLeave = () => {
dragOverId.value = null;
};
const onDrop = (event, targetId) => {
event.preventDefault();
dragOverId.value = null;
if (!dragSrcId.value || dragSrcId.value === targetId) {
return;
}
const blocks = [...project.value.contentBlocks];
const srcIndex = blocks.findIndex((b) => b.id === dragSrcId.value);
const targetIndex = blocks.findIndex((b) => b.id === targetId);
if (srcIndex < 0 || targetIndex < 0) {
return;
}
const [item] = blocks.splice(srcIndex, 1);
blocks.splice(targetIndex, 0, item);
project.value.contentBlocks = blocks;
};
const onDragEnd = () => {
dragSrcId.value = null;
dragOverId.value = null;
};
// Provide block-sort handlers so ProjectBlockRenderer can wire them in the preview
provide('editorBlockDrag', { dragSrcId, dragOverId, onDragStart, onDragOver, onDragLeave, onDrop, onDragEnd });
</script>
<template>
<div class="project-editor">
<div v-if="pendingUploads > 0" class="project-editor__upload-notice">
<span class="project-editor__upload-spinner"></span>
Uploading {{ pendingUploads }} image{{ pendingUploads > 1 ? 's' : '' }} please wait before saving.
</div>
<aside class="project-editor__controls">
<!-- Hidden inputs: always in DOM for form submission regardless of selected block -->
<input type="hidden" name="active" :value="isActive ? '1' : '0'">
<input type="hidden" name="categories_touched" :value="categoriesTouched ? '1' : '0'">
<template v-for="cat in props.categories" :key="`category-hidden-${cat.id}`">
<input v-if="categoryChecked[cat.id]" type="hidden" name="categories[]" :value="cat.id">
</template>
<template v-for="(t, iso) in allTranslations" :key="iso">
<input type="hidden" :name="`prevod[${iso}][headline]`" :value="t.headline || ''">
<input type="hidden" :name="`prevod[${iso}][subline]`" :value="t.subline || ''">
<input type="hidden" :name="`prevod[${iso}][name]`" :value="t.clientName || ''">
<input type="hidden" :name="`prevod[${iso}][year]`" :value="t.year || ''">
<input type="hidden" :name="`prevod[${iso}][content]`" :value="t.content || ''">
<input type="hidden" :name="`prevod[${iso}][preview]`" :value="t.description || ''">
<input type="hidden" :name="`prevod[${iso}][tag1]`" :value="(t.awarded || [])[0] || ''">
<input type="hidden" :name="`prevod[${iso}][tag2]`" :value="(t.awarded || [])[1] || ''">
<input type="hidden" :name="`prevod[${iso}][tag3]`" :value="(t.awarded || [])[2] || ''">
<input type="hidden" :name="`prevod[${iso}][page_title]`" :value="t.pageTitle || ''">
<input type="hidden" :name="`prevod[${iso}][meta_title]`" :value="t.metaTitle || ''">
<input type="hidden" :name="`prevod[${iso}][meta_description]`" :value="t.metaDescription || ''">
<input type="hidden" :name="`prevod[${iso}][og_title]`" :value="t.ogTitle || ''">
<input type="hidden" :name="`prevod[${iso}][og_description]`" :value="t.ogDescription || ''">
</template>
</aside>
<div
class="project-editor__preview"
>
<div class="project-editor__preview-blocks">
<div class="project-editor__preview-blocks__bottom">
<div v-if="languages.length > 1" class="project-editor__lang-tabs project-editor__lang-tabs--preview">
<span class="project-editor__lang-label">Preview lang:</span>
<button
v-for="lang in languages"
:key="lang.iso"
type="button"
class="project-editor__lang-tab"
:class="{ 'project-editor__lang-tab--active': selectedLang === lang.iso }"
@click="selectedLang = lang.iso"
>{{ lang.iso.toUpperCase() }}</button>
</div>
<div class="project-editor__toolbar-actions">
<button
type="button"
class="project-editor__sort-btn"
:class="{ 'project-editor__sort-btn--active': showSort }"
@click="showSort = !showSort; showSettings = false; showProjectSettings = false"
title="Reorder blocks"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
Sort
</button>
<button
type="button"
class="project-editor__sort-btn"
:class="{ 'project-editor__sort-btn--active': showSettings }"
@click="showSettings = !showSettings; showSort = false; showProjectSettings = false"
title="SEO & meta"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
Meta
</button>
<button
type="button"
class="project-editor__sort-btn"
:class="{ 'project-editor__sort-btn--active': showProjectSettings }"
@click="showProjectSettings = !showProjectSettings; showSort = false; showSettings = false"
title="Categories & visibility"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg>
Settings
</button>
<button
type="button"
class="project-editor__sort-btn"
:class="{ 'project-editor__sort-btn--active': mobilePreview }"
@click="mobilePreview = !mobilePreview"
title="Toggle mobile preview"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>
Mobile
</button>
</div>
</div>
</div>
<div class="project-editor__renderer-wrap" :class="{ 'project-editor__renderer-wrap--mobile': mobilePreview }">
<ProjectPageRenderer
:project="previewProject"
:editable="true"
:selected-block-id="selectedBlockId"
:active-lang="selectedLang"
:mobile-preview="mobilePreview"
@select-block="selectBlock"
/>
</div>
<div class="project-editor__add-blocks">
<div class="project-editor__actions">
<button v-for="opt in blockTypeOptions" :key="opt.type" type="button" class="project-editor__action" @click="addBlock(opt.type)">
Add {{ opt.label }}
</button>
</div>
</div>
</div>
<!-- Right column: block editor panel -->
<aside class="project-editor__block-panel">
<!-- Sort panel -->
<div v-if="showSort" class="project-editor__panel">
<div class="project-editor__block-editor-header">
<span class="project-editor__label">Reorder blocks</span>
<button type="button" class="project-editor__ghost" @click="showSort = false"> Close</button>
</div>
<div class="project-editor__block-list">
<template v-for="(block, index) in project.contentBlocks" :key="block.id">
<div
draggable="true"
class="project-editor__block-item"
:class="{
'project-editor__block-item--active': block.id === selectedBlockId,
'project-editor__block-item--dragging': block.id === dragSrcId,
'project-editor__block-item--over': block.id === dragOverId,
'project-editor__block-item--hidden': block.hidden,
}"
@click="selectBlock(block.id); showSort = false; scrollToBlock(block.id)"
@mouseenter="scrollToBlock(block.id)"
@dragstart="onDragStart($event, block.id)"
@dragover="onDragOver($event, block.id)"
@dragleave="onDragLeave"
@drop="onDrop($event, block.id)"
@dragend="onDragEnd"
>
<button
type="button"
class="project-editor__visibility-btn"
:class="{ 'project-editor__visibility-btn--hidden': block.hidden }"
:title="block.hidden ? 'Section hidden — click to show' : 'Section visible — click to hide'"
@click.stop="block.hidden = !block.hidden"
>
<!-- Eye open -->
<svg v-if="!block.hidden" xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
<!-- Eye off -->
<svg v-else xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
</button>
<span class="project-editor__drag-handle" title="Drag to reorder"></span>
<span class="project-editor__block-item-body">
<span class="pe-block-num">{{ index + 1 }}</span>
<span class="pe-block-layout" v-html="LAYOUT_ICONS[block.type] || block.type" :title="block.type === 'TwoColumns' ? 'Two Columns' : 'Full Width'"></span>
<span class="pe-block-name" :class="{ 'pe-block-name--hidden': block.hidden }">{{ block.name || `Section ${index + 1}` }}</span>
<span class="pe-block-slots">
<template v-if="block.type === 'TwoColumns'">
<span v-html="SLOT_ICONS[block.left?.type] || block.left?.type || '?'" :title="block.left?.type"></span>
<span class="pe-slot-sep">/</span>
<span v-html="SLOT_ICONS[block.right?.type] || block.right?.type || '?'" :title="block.right?.type"></span>
</template>
<template v-else-if="block.type === 'FullWidth'">
<span v-html="SLOT_ICONS[block.slot?.type] || block.slot?.type || '?'" :title="block.slot?.type"></span>
</template>
</span>
</span>
</div>
</template>
</div>
</div>
<!-- Settings panel: publish status + thumbnail + categories -->
<div v-else-if="showProjectSettings" class="project-editor__panel pe-settings-panel">
<div class="project-editor__block-editor-header">
<span class="project-editor__label">Settings</span>
<button type="button" class="project-editor__ghost" @click="showProjectSettings = false"> Close</button>
</div>
<input v-if="props.categories.length" type="hidden" name="categories_present" value="1">
<!-- Publish status -->
<div class="pe-settings-card">
<div class="pe-settings-card__header">
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<span>Publish status</span>
</div>
<div class="pe-settings-card__body">
<div class="pe-active-row">
<div class="pe-active-row__info">
<span class="pe-active-row__label">Active</span>
<span class="pe-active-row__hint">Visible on the website when enabled</span>
</div>
<div class="custom-control custom-switch custom-switch-off-danger custom-switch-on-success">
<input
type="checkbox"
class="custom-control-input"
id="pe-active-slider"
v-model="isActive"
:true-value="true"
:false-value="false"
>
<label class="pointer custom-control-label" for="pe-active-slider"></label>
</div>
</div>
</div>
</div>
<!-- Thumbnail (catalog cover) -->
<div class="pe-settings-card">
<div class="pe-settings-card__header">
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
<span>Thumbnail</span>
</div>
<div class="pe-settings-card__body">
<p class="pe-settings-hint">Displayed on the Work listing page. Choose either an uploaded image or a Bunny video.</p>
<div class="pe-field">
<span class="pe-field-label">Thumbnail type</span>
<select v-model="project.thumbnailMedia.type" class="form-control form-control-sm">
<option value="image">Image upload</option>
<option value="bunny">Bunny video</option>
</select>
</div>
<template v-if="project.thumbnailMedia.type === 'image'">
<div
class="pe-drop-zone"
:class="{ 'pe-drop-zone--over': coverDragOver, 'pe-drop-zone--filled': !!coverPreview }"
@dragover.prevent="coverDragOver = true"
@dragleave="coverDragOver = false"
@drop="onCoverDrop"
@click="coverInputRef.click()"
>
<img v-if="coverPreview" :src="coverPreview" class="pe-drop-zone__preview" alt="">
<div v-if="coverPreview" class="pe-drop-zone__overlay">
<span>Drop or click to replace</span>
<button type="button" class="pe-drop-zone__clear" @click="clearCover" title="Remove"></button>
</div>
<div v-else class="pe-drop-zone__empty">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
<span>Drop or click to upload</span>
</div>
</div>
<input ref="coverInputRef" type="file" name="picture_cover" accept="image/*" class="pe-drop-zone__input" @change="onCoverChange">
</template>
<template v-else>
<div class="pe-field">
<span class="pe-field-label">Bunny embed URL</span>
<input
v-model="project.thumbnailMedia.url"
type="url"
class="form-control form-control-sm"
placeholder="https://player.mediadelivery.net/embed/{libraryId}/{videoId}"
>
</div>
<p class="pe-settings-hint mb-0">Bunny thumbnails autoplay, loop, and stay muted on the Work page.</p>
</template>
</div>
</div>
<!-- Categories -->
<div v-if="props.categories.length" class="pe-settings-card">
<div class="pe-settings-card__header">
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<span>Categories</span>
</div>
<div class="pe-settings-card__body">
<div class="project-editor__categories">
<label
v-for="cat in props.categories"
:key="cat.id"
class="project-editor__category"
:class="{ 'project-editor__category--active': categoryChecked[cat.id] }"
>
<input
type="checkbox"
:value="cat.id"
:checked="categoryChecked[cat.id]"
@change="categoriesTouched = true; categoryChecked[cat.id] = $event.target.checked"
>
<img v-if="cat.picture" :src="cat.picture" :alt="cat.title" class="project-editor__category-img">
<span v-else class="project-editor__category-icon">🌍</span>
<span>{{ cat.title }}</span>
</label>
</div>
</div>
</div>
</div>
<!-- Meta panel: SEO meta + social -->
<div v-else-if="showSettings" class="project-editor__panel pe-settings-panel">
<div class="project-editor__block-editor-header">
<span class="project-editor__label">Meta</span>
<button type="button" class="project-editor__ghost" @click="showSettings = false"> Close</button>
</div>
<!-- SEO card -->
<div class="pe-settings-card">
<div class="pe-settings-card__header">
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<span>Search engine (SEO)</span>
</div>
<div class="pe-settings-card__body">
<div class="pe-meta-field">
<div class="pe-meta-field__label">
<span>Page title{{ selectedLang ? ` (${selectedLang.toUpperCase()})` : '' }}</span>
</div>
<p class="pe-settings-hint">Shown in the browser tab. Falls back to Meta title if empty.</p>
<input v-model="currentTranslation.pageTitle" type="text" class="pe-meta-input">
</div>
<div class="pe-meta-field">
<div class="pe-meta-field__label">
<span>Meta title{{ selectedLang ? ` (${selectedLang.toUpperCase()})` : '' }}</span>
<span class="pe-char-count" :class="{ 'pe-char-count--warn': (currentTranslation.metaTitle || '').length > 60 }">{{ (currentTranslation.metaTitle || '').length }}/60</span>
</div>
<input v-model="currentTranslation.metaTitle" type="text" class="pe-meta-input" placeholder="Shown in search engine results">
</div>
<div class="pe-meta-field">
<div class="pe-meta-field__label">
<span>Meta description{{ selectedLang ? ` (${selectedLang.toUpperCase()})` : '' }}</span>
<span class="pe-char-count" :class="{ 'pe-char-count--warn': (currentTranslation.metaDescription || '').length > 160 }">{{ (currentTranslation.metaDescription || '').length }}/160</span>
</div>
<textarea v-model="currentTranslation.metaDescription" class="pe-meta-input pe-meta-textarea" rows="3" placeholder="Search engine snippet (150160 chars)"></textarea>
</div>
</div>
</div>
<!-- Open Graph / Social card -->
<div class="pe-settings-card">
<div class="pe-settings-card__header">
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
<span>Social sharing (Open Graph)</span>
</div>
<div class="pe-settings-card__body">
<p class="pe-settings-hint">Used by Facebook, LinkedIn, WhatsApp, and Twitter/X when this page is shared. Falls back to SEO fields if empty.</p>
<div class="pe-meta-field">
<div class="pe-meta-field__label">
<span>OG title{{ selectedLang ? ` (${selectedLang.toUpperCase()})` : '' }}</span>
</div>
<input v-model="currentTranslation.ogTitle" type="text" class="pe-meta-input" placeholder="Defaults to Meta title">
</div>
<div class="pe-meta-field">
<div class="pe-meta-field__label">
<span>OG description{{ selectedLang ? ` (${selectedLang.toUpperCase()})` : '' }}</span>
</div>
<textarea v-model="currentTranslation.ogDescription" class="pe-meta-input pe-meta-textarea" rows="3" placeholder="Defaults to Meta description"></textarea>
</div>
<div class="pe-meta-field">
<div class="pe-meta-field__label">
<span>OG image</span>
</div>
<p class="pe-settings-hint">Recommended 1200×630 px. Also used as Twitter/X card image.</p>
<div
class="pe-drop-zone"
:class="{ 'pe-drop-zone--over': ogDragOver, 'pe-drop-zone--filled': !!ogPreview }"
@dragover.prevent="ogDragOver = true"
@dragleave="ogDragOver = false"
@drop="onOgDrop"
@click="ogInputRef.click()"
>
<img v-if="ogPreview" :src="ogPreview" class="pe-drop-zone__preview" alt="">
<div v-if="ogPreview" class="pe-drop-zone__overlay">
<span>Drop or click to replace</span>
<button type="button" class="pe-drop-zone__clear" @click="clearOg" title="Remove"></button>
</div>
<div v-else class="pe-drop-zone__empty">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
<span>Drop or click to upload</span>
</div>
</div>
<input ref="ogInputRef" type="file" name="picture_catalog" accept="image/*" class="pe-drop-zone__input" @change="onOgChange">
</div>
</div>
</div>
</div>
<!-- Hero / Header editor -->
<div v-else-if="isHeroSelected" class="project-editor__panel">
<div class="project-editor__block-editor-header">
<span class="project-editor__label">Hero / Header</span>
</div>
<label>
<span class="pe-field-label"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4v16M18 4v16M6 12h12"/></svg> Headline{{ selectedLang ? ` (${selectedLang.toUpperCase()})` : '' }}</span>
<input v-model="currentTranslation.headline" type="text" class="form-control form-control-sm" title="Main title displayed in the hero section">
</label>
<label>
<span class="pe-field-label"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="21" y1="6" x2="3" y2="6"/><line x1="17" y1="12" x2="3" y2="12"/></svg> Subline{{ selectedLang ? ` (${selectedLang.toUpperCase()})` : '' }}</span>
<input v-model="currentTranslation.subline" type="text" class="form-control form-control-sm" title="Secondary line below the headline">
</label>
<label>
<span class="pe-field-label"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg> Hero media type</span>
<select v-model="project.heroMedia.type" class="form-control form-control-sm">
<option value="image">Image</option>
<option value="youtube">YouTube</option>
<option value="frameio">Frame.io</option>
<option value="bunny">Bunny (mediadelivery.net)</option>
<option value="video">Video file</option>
</select>
</label>
<template v-if="project.heroMedia.type === 'image'">
<span class="project-editor__field-label pe-field-label"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> Hero image</span>
<ImageDropZone
:model-value="project.heroMedia.url"
:upload-url="uploadUrl"
@update:model-value="project.heroMedia.url = $event"
/>
</template>
<label v-else>
<span class="pe-field-label"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg> Hero media URL</span>
<input v-model="project.heroMedia.url" type="url" class="form-control form-control-sm" placeholder="https://..." title="URL to the hero image, video, or embed">
</label>
<div v-if="project.heroMedia.type === 'bunny'" class="project-editor__checkboxes">
<label class="project-editor__checkbox">
<input type="checkbox" v-model="project.heroMedia.autoplay"> Autoplay
</label>
<label class="project-editor__checkbox">
<input type="checkbox" v-model="project.heroMedia.loop"> Loop
</label>
<label class="project-editor__checkbox">
<input type="checkbox" v-model="project.heroMedia.muted"> Muted
</label>
</div>
<label>
<span class="pe-field-label"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> Client name{{ selectedLang ? ` (${selectedLang.toUpperCase()})` : '' }}</span>
<input v-model="currentTranslation.clientName" type="text" class="form-control form-control-sm" title="Company or client this project was made for">
</label>
<label>
<span class="pe-field-label"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> Year</span>
<input v-model="currentTranslation.year" type="number" class="form-control form-control-sm" title="Year the project was completed">
</label>
<label>
<span class="pe-field-label"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg> Awarded lines</span>
<textarea v-model="awardedText" class="form-control form-control-sm" rows="4" title="One award per line (e.g. Cannes Lions 2024)"></textarea>
</label>
<span class="project-editor__field-label pe-field-label"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="17" y1="18" x2="3" y2="18"/></svg> Description{{ selectedLang ? ` (${selectedLang.toUpperCase()})` : '' }}</span>
<RichTextEditor
:model-value="currentTranslation.description || ''"
@update:model-value="currentTranslation.description = $event"
/>
</div>
<!-- Dynamic block editor -->
<div v-else-if="selectedBlock && !showSort && !showProjectSettings" class="project-editor__panel">
<div class="project-editor__block-editor-header">
<span class="project-editor__label">{{ selectedBlock.name || `Block ${selectedBlockIndex + 1}` }} {{ selectedBlock.type }}</span>
<button type="button" class="project-editor__danger" @click="removeSelectedBlock">Remove</button>
</div>
<label>
<span class="pe-field-label"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg> Block name</span>
<input v-model="selectedBlock.name" type="text" class="form-control form-control-sm" placeholder="Block name (optional)" title="Optional label shown in the sort list">
</label>
<!-- FullWidth editor -->
<template v-if="selectedBlock.type === 'FullWidth'">
<div class="pe-type-picker">
<button type="button" class="pe-type-btn" :class="{ 'pe-type-btn--active': selectedBlock.slot.type === 'text' }" @click="selectedBlock.slot.type = 'text'" title="Text">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="17" y1="18" x2="3" y2="18"/></svg>
Text
</button>
<button type="button" class="pe-type-btn" :class="{ 'pe-type-btn--active': selectedBlock.slot.type === 'image' }" @click="selectedBlock.slot.type = 'image'" title="Image">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
Image
</button>
<button type="button" class="pe-type-btn" :class="{ 'pe-type-btn--active': selectedBlock.slot.type === 'video' }" @click="selectedBlock.slot.type = 'video'" title="Video">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Video
</button>
</div>
<template v-if="selectedBlock.slot.type === 'text'">
<RichTextEditor
:model-value="selectedBlock.slot.content[selectedLang] || ''"
@update:model-value="selectedBlock.slot.content[selectedLang] = $event"
/>
</template>
<template v-else-if="selectedBlock.slot.type === 'image'">
<span class="project-editor__field-label pe-field-label"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> Image</span>
<ImageDropZone
:model-value="selectedBlock.slot.image.url"
:upload-url="uploadUrl"
@update:model-value="selectedBlock.slot.image.url = $event"
/>
<label>
<span class="pe-field-label"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg> Alt text{{ selectedLang ? ` (${selectedLang.toUpperCase()})` : '' }}</span>
<input
:value="selectedBlock.slot.image.alt[selectedLang] || ''"
type="text"
class="form-control form-control-sm"
title="Describe the image for screen readers and SEO"
@input="selectedBlock.slot.image.alt[selectedLang] = $event.target.value"
>
</label>
</template>
<template v-else-if="selectedBlock.slot.type === 'video'">
<label>
<span class="pe-field-label"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg> Video provider</span>
<select v-model="selectedBlock.slot.media.type" class="form-control form-control-sm">
<option value="youtube">YouTube</option>
<option value="frameio">Frame.io</option>
<option value="bunny">Bunny (mediadelivery.net)</option>
<option value="video">Video file</option>
</select>
</label>
<label>
<span class="pe-field-label"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
<span v-if="selectedBlock.slot.media.type === 'youtube'">YouTube URL</span>
<span v-else-if="selectedBlock.slot.media.type === 'frameio'">Frame.io review link</span>
<span v-else-if="selectedBlock.slot.media.type === 'bunny'">Bunny embed URL</span>
<span v-else>Video URL</span>
</span>
<input v-model="selectedBlock.slot.media.url" type="url" class="form-control form-control-sm"
:placeholder="selectedBlock.slot.media.type === 'frameio' ? 'https://app.frame.io/reviews/...' : (selectedBlock.slot.media.type === 'bunny' ? 'https://player.mediadelivery.net/embed/{libraryId}/{videoId}' : 'https://...')">
</label>
<div v-if="selectedBlock.slot.media.type === 'bunny'" class="project-editor__checkboxes">
<label class="project-editor__checkbox">
<input type="checkbox" v-model="selectedBlock.slot.media.autoplay"> Autoplay
</label>
<label class="project-editor__checkbox">
<input type="checkbox" v-model="selectedBlock.slot.media.loop"> Loop
</label>
<label class="project-editor__checkbox">
<input type="checkbox" v-model="selectedBlock.slot.media.muted"> Muted
</label>
</div>
<template v-if="selectedBlock.slot.media.type !== 'frameio'">
<span class="project-editor__field-label pe-field-label"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> Poster image (optional)</span>
<ImageDropZone
:model-value="selectedBlock.slot.media.poster"
:upload-url="uploadUrl"
label="Optional"
@update:model-value="selectedBlock.slot.media.poster = $event"
/>
</template>
</template>
</template>
<!-- TwoColumns editor -->
<template v-else-if="selectedBlock.type === 'TwoColumns'">
<template v-for="colKey in ['left', 'right']" :key="colKey">
<span class="project-editor__field-label project-editor__settings-section">{{ colKey === 'left' ? 'Left' : 'Right' }} column</span>
<div class="pe-type-picker">
<button type="button" class="pe-type-btn" :class="{ 'pe-type-btn--active': selectedBlock[colKey].type === 'text' }" @click="selectedBlock[colKey].type = 'text'" title="Text">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="17" y1="18" x2="3" y2="18"/></svg>
Text
</button>
<button type="button" class="pe-type-btn" :class="{ 'pe-type-btn--active': selectedBlock[colKey].type === 'image' }" @click="selectedBlock[colKey].type = 'image'" title="Image">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
Image
</button>
<button type="button" class="pe-type-btn" :class="{ 'pe-type-btn--active': selectedBlock[colKey].type === 'video' }" @click="selectedBlock[colKey].type = 'video'" title="Video">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Video
</button>
</div>
<template v-if="selectedBlock[colKey].type === 'text'">
<RichTextEditor
:model-value="selectedBlock[colKey].content[selectedLang] || ''"
@update:model-value="selectedBlock[colKey].content[selectedLang] = $event"
/>
</template>
<template v-else-if="selectedBlock[colKey].type === 'image'">
<ImageDropZone
:model-value="selectedBlock[colKey].image.url"
:upload-url="uploadUrl"
:label="colKey === 'left' ? 'Left column' : 'Right column'"
@update:model-value="selectedBlock[colKey].image.url = $event"
/>
<label>
<span class="pe-field-label"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg> Alt text{{ selectedLang ? ` (${selectedLang.toUpperCase()})` : '' }}</span>
<input
:value="selectedBlock[colKey].image.alt[selectedLang] || ''"
type="text"
class="form-control form-control-sm"
title="Describe the image for screen readers and SEO"
@input="selectedBlock[colKey].image.alt[selectedLang] = $event.target.value"
>
</label>
</template>
<template v-else-if="selectedBlock[colKey].type === 'video'">
<label>
<span class="pe-field-label"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg> Video provider</span>
<select v-model="selectedBlock[colKey].media.type" class="form-control form-control-sm">
<option value="youtube">YouTube</option>
<option value="frameio">Frame.io</option>
<option value="bunny">Bunny (mediadelivery.net)</option>
<option value="video">Video file</option>
</select>
</label>
<label>
<span class="pe-field-label"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
<span v-if="selectedBlock[colKey].media.type === 'youtube'">YouTube URL</span>
<span v-else-if="selectedBlock[colKey].media.type === 'frameio'">Frame.io review link</span>
<span v-else-if="selectedBlock[colKey].media.type === 'bunny'">Bunny embed URL</span>
<span v-else>Video URL</span>
</span>
<input v-model="selectedBlock[colKey].media.url" type="url" class="form-control form-control-sm" :placeholder="selectedBlock[colKey].media.type === 'bunny' ? 'https://player.mediadelivery.net/embed/{libraryId}/{videoId}' : 'https://...'">
</label>
<div v-if="selectedBlock[colKey].media.type === 'bunny'" class="project-editor__checkboxes">
<label class="project-editor__checkbox">
<input type="checkbox" v-model="selectedBlock[colKey].media.autoplay"> Autoplay
</label>
<label class="project-editor__checkbox">
<input type="checkbox" v-model="selectedBlock[colKey].media.loop"> Loop
</label>
<label class="project-editor__checkbox">
<input type="checkbox" v-model="selectedBlock[colKey].media.muted"> Muted
</label>
</div>
</template>
</template>
</template>
</div>
<div v-else class="project-editor__block-panel-empty">
<p>Select a block from the list to edit it.</p>
</div>
</aside>
</div>
</template>
<style scoped>
.project-editor {
display: grid;
gap: 1.5rem;
align-items: flex-start;
grid-template-columns: 1fr 26rem;
}
.project-editor__controls {
display: none;
}
.project-editor__upload-notice {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: .6rem;
background: #fefce8;
border: 1px solid #fde047;
border-radius: .5rem;
color: #713f12;
font-size: .875rem;
font-weight: 500;
padding: .6rem 1rem;
}
.project-editor__upload-spinner {
display: inline-block;
width: 1rem;
height: 1rem;
border: 2px solid #fde047;
border-top-color: #a16207;
border-radius: 50%;
animation: pe-spin .7s linear infinite;
flex-shrink: 0;
}
@keyframes pe-spin {
to { transform: rotate(360deg); }
}
/* .project-editor__controls is hidden (display:none set above) — only holds hidden inputs */
.project-editor__controls-sticky {
align-content: start;
display: grid;
gap: 1rem;
position: sticky;
top: 5rem;
}
.project-editor__panel {
background: #fff;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 1rem;
display: grid;
gap: 0.55rem;
padding: 1rem;
}
.project-editor__panel label {
color: #111827;
display: grid;
font-size: 0.82rem;
font-weight: 600;
gap: 0.2rem;
}
.project-editor__panel-header {
align-items: start;
display: flex;
gap: 0.75rem;
justify-content: space-between;
}
.project-editor__panel-header h3 {
color: #0f172a;
font-size: 1rem;
margin: 0.15rem 0 0;
}
.project-editor__actions,
.project-editor__inline-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.project-editor__block-panel {
align-content: start;
display: grid;
gap: 1rem;
position: sticky;
top: 5rem;
}
.project-editor__block-panel-empty {
align-items: center;
background: rgba(15, 23, 42, 0.02);
border: 1px dashed rgba(15, 23, 42, 0.12);
border-radius: 1rem;
color: #94a3b8;
display: flex;
font-size: 0.85rem;
justify-content: center;
min-height: 8rem;
padding: 1.5rem;
text-align: center;
}
.project-editor__block-panel-empty p {
margin: 0;
}
.project-editor__block-editor-header {
align-items: center;
display: flex;
justify-content: space-between;
}
.project-editor__checkboxes {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
}
.project-editor__checkbox {
align-items: center;
cursor: pointer;
display: flex !important;
flex-direction: row !important;
font-weight: 500 !important;
gap: 0.4rem;
}
.project-editor__categories {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.project-editor__category {
align-items: center !important;
border: 1px solid rgba(15, 23, 42, 0.12);
border-radius: 0.5rem;
cursor: pointer;
display: flex !important;
flex-direction: row !important;
flex-wrap: nowrap !important;
font-size: 0.85rem;
font-weight: 500;
gap: 0.5rem;
margin: 0;
padding: 0.35rem 0.6rem;
transition: border-color 0.15s, background 0.15s;
}
.project-editor__category input[type="checkbox"] {
accent-color: #0f766e;
cursor: pointer;
flex-shrink: 0;
height: 1rem;
margin: 0;
width: 1rem;
}
.project-editor__category--active {
background: #f0fdf4;
border-color: #0f766e;
}
.project-editor__category-img {
border-radius: 0.3rem;
flex-shrink: 0;
height: 1.25rem;
object-fit: cover;
width: 1.25rem;
}
.project-editor__category-icon {
flex-shrink: 0;
font-size: 1rem;
line-height: 1;
}
/* ── Settings panel cards ─────────────────────────────────────────── */
.pe-settings-panel {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.pe-settings-card {
border: 1px solid rgba(15, 23, 42, 0.1);
border-radius: 0.85rem;
overflow: hidden;
}
.pe-settings-card__header {
align-items: center;
background: rgba(15, 23, 42, 0.03);
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
color: #475569;
display: flex;
font-size: 0.72rem;
font-weight: 700;
gap: 0.4rem;
letter-spacing: 0.05em;
padding: 0.55rem 0.85rem;
text-transform: uppercase;
}
.pe-settings-card__body {
display: flex;
flex-direction: column;
gap: 0.65rem;
padding: 0.85rem;
}
.pe-settings-hint {
color: #64748b;
font-size: 0.75rem;
line-height: 1.5;
margin: 0;
}
.pe-active-row {
align-items: center;
display: flex;
gap: 0.75rem;
justify-content: space-between;
}
.pe-active-row__info {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.pe-active-row__label {
font-size: 0.85rem;
font-weight: 600;
}
.pe-active-row__hint {
color: #64748b;
font-size: 0.72rem;
}
/* ── Meta panel fields ────────────────────────────────────────────── */
.pe-meta-field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.pe-meta-field__label {
align-items: baseline;
display: flex;
font-size: 0.75rem;
font-weight: 600;
gap: 0.5rem;
justify-content: space-between;
}
.pe-char-count {
color: #94a3b8;
font-size: 0.68rem;
font-weight: 400;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.pe-char-count--warn {
color: #ef4444;
font-weight: 600;
}
.pe-meta-input {
background: #fff;
border: 1px solid rgba(15, 23, 42, 0.15);
border-radius: 0.45rem;
color: #0f172a;
font-size: 0.82rem;
line-height: 1.4;
padding: 0.4rem 0.6rem;
transition: border-color 0.15s, box-shadow 0.15s;
width: 100%;
}
.pe-meta-input:focus {
border-color: rgba(14, 116, 144, 0.5);
box-shadow: 0 0 0 3px rgba(14, 116, 144, 0.12);
outline: none;
}
.pe-meta-textarea {
resize: vertical;
}
.project-editor__action,
.project-editor__ghost,
.project-editor__danger {
border: 0;
border-radius: 999px;
cursor: pointer;
font-size: 0.82rem;
font-weight: 700;
padding: 0.55rem 0.9rem;
}
.project-editor__action,
.project-editor__ghost {
background: #eff6ff;
color: #1d4ed8;
}
.project-editor__danger {
background: #fee2e2;
color: #b91c1c;
}
.project-editor__block-list {
display: grid;
gap: 0.5rem;
}
.project-editor__block-item {
align-items: center;
background: #f8fafc;
border: 1px solid transparent;
border-radius: 0.9rem;
color: #0f172a;
cursor: pointer;
display: grid;
gap: 0 0.4rem;
grid-template-columns: auto 1.4rem 1fr;
padding: 0.55rem 0.95rem;
text-align: left;
transition: background 0.12s, border-color 0.12s, opacity 0.12s;
user-select: none;
}
.project-editor__block-item-body {
align-items: center;
display: flex;
gap: 0.35rem;
min-width: 0;
}
.pe-block-num {
color: #94a3b8;
font-size: 0.72rem;
font-weight: 700;
min-width: 1.2rem;
}
.pe-block-layout {
align-items: center;
color: #475569;
display: flex;
flex-shrink: 0;
}
.pe-block-name {
color: #0f172a;
font-size: 0.82rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pe-block-name--hidden {
background: #fee2e2;
border-radius: 0.25rem;
color: #b91c1c;
padding: 0.05em 0.35em;
}
.pe-block-slots {
align-items: center;
color: #94a3b8;
display: flex;
flex-shrink: 0;
gap: 0.15rem;
margin-left: auto;
}
.pe-block-slots span {
align-items: center;
display: flex;
}
.pe-slot-sep {
font-size: 0.65rem;
line-height: 1;
}
.project-editor__drag-handle {
align-self: center;
color: #94a3b8;
cursor: grab;
font-size: 0.85rem;
line-height: 1;
}
.project-editor__visibility-btn {
align-items: center;
background: none;
border: none;
border-radius: 0.35rem;
color: #94a3b8;
cursor: pointer;
display: flex;
flex-shrink: 0;
line-height: 1;
padding: 0.15rem;
transition: color 0.15s;
}
.project-editor__visibility-btn:hover {
color: #475569;
}
.project-editor__visibility-btn--hidden {
color: #ef4444;
}
.project-editor__visibility-btn--hidden:hover {
color: #b91c1c;
}
.project-editor__drag-handle:active {
cursor: grabbing;
}
.project-editor__block-item--active {
background: #ecfeff;
border-color: rgba(14, 116, 144, 0.25);
}
.project-editor__block-item:not(.project-editor__block-item--active):hover {
background: #f1f5f9;
border-color: rgba(15, 23, 42, 0.12);
}
.project-editor__block-item--dragging {
opacity: 0.4;
}
.project-editor__block-item--over {
background: #f0f9ff;
border-color: rgba(14, 116, 144, 0.5);
border-style: dashed;
}
.project-editor__block-item small,
.project-editor__hint {
color: #64748b;
}
.project-editor__preview {
background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(241, 245, 249, 0.96));
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 1rem;
display: grid;
flex: 1 1 0;
gap: 1rem;
min-width: 320px;
overflow: clip;
padding: 1rem;
transition: max-width 0.3s ease;
}
.project-editor__preview--mobile {
flex: 0 0 auto;
max-width: 390px;
min-width: 320px;
width: 390px;
}
.project-editor__renderer-wrap {
width: 100%;
}
.project-editor__renderer-wrap--mobile {
max-width: 390px;
margin-left: auto;
margin-right: auto;
transition: max-width 0.3s ease;
}
.project-editor__preview-blocks {
background: #fff;
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
display: grid;
gap: 0.75rem;
margin: -1rem -1rem 0;
padding: 0.9rem 1rem;
position: sticky;
top: 3.5rem;
z-index: 10;
}
.project-editor__preview-blocks h3 {
font-size: 1.05rem;
font-weight: 600;
margin: 0;
}
.project-editor__preview-blocks__bottom {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: space-between;
}
.project-editor__toolbar-actions {
display: flex;
gap: 0.4rem;
margin-left: auto;
}
.project-editor__add-blocks {
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(6px);
border-top: 1px solid rgba(15, 23, 42, 0.08);
bottom: 0;
margin: 0 -1rem -1rem;
padding: 0.75rem 1rem;
position: sticky;
z-index: 10;
}
.project-editor__lang-tabs {
display: flex;
gap: 0.25rem;
}
.project-editor__sort-btn {
align-items: center;
background: rgba(15, 23, 42, 0.06);
border: none;
border-radius: 0.5rem;
color: #475569;
cursor: pointer;
display: flex;
font-size: 0.78rem;
font-weight: 700;
gap: 0.35rem;
padding: 0.3rem 0.65rem;
transition: background 0.12s, color 0.12s;
}
.project-editor__sort-btn:hover {
background: rgba(15, 23, 42, 0.11);
color: #0f172a;
}
.project-editor__sort-btn--active {
background: #0f172a;
color: #fff;
}
.project-editor__lang-tabs--preview {
align-items: center;
gap: 0.35rem;
}
.project-editor__lang-label {
color: #64748b;
font-size: 0.78rem;
}
.project-editor__lang-tab {
background: rgba(15, 23, 42, 0.06);
border: none;
border-radius: 0.3rem;
color: #475569;
cursor: pointer;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
padding: 0.2rem 0.55rem;
}
.project-editor__lang-tab--active {
background: #0f172a;
color: #fff;
}
.project-editor__json {
font-family: Consolas, Monaco, monospace;
font-size: 0.82rem;
}
.project-editor__resize-handle {
align-items: center;
background: rgba(15, 23, 42, 0.06);
border-radius: 0.35rem;
color: #64748b;
cursor: col-resize;
display: flex;
font-size: 1rem;
justify-content: center;
padding: 0.15rem 0.4rem;
user-select: none;
transition: background 0.12s, color 0.12s;
}
.project-editor__resize-handle:hover {
background: rgba(15, 23, 42, 0.12);
color: #0f172a;
}
.project-editor__preview--resizing {
cursor: col-resize;
user-select: none;
}
.project-editor__field-label {
color: #374151;
font-size: 0.85rem;
font-weight: 600;
}
.project-editor__settings-section {
border-top: 1px solid rgba(15, 23, 42, 0.08);
display: block;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
padding-top: 0.5rem;
text-transform: uppercase;
}
.pe-field-label {
align-items: center;
display: flex;
gap: 0.3rem;
}
.pe-field-label svg {
color: #94a3b8;
flex-shrink: 0;
}
.pe-type-picker {
background: #f1f5f9;
border-radius: 0.75rem;
display: flex;
gap: 0.2rem;
padding: 0.2rem;
}
.pe-type-btn {
align-items: center;
background: transparent;
border: none;
border-radius: 0.55rem;
color: #64748b;
cursor: pointer;
display: flex;
flex: 1;
font-size: 0.78rem;
font-weight: 600;
gap: 0.3rem;
justify-content: center;
padding: 0.4rem 0.5rem;
transition: background 0.12s, color 0.12s, box-shadow 0.12s;
}
.pe-type-btn:hover:not(.pe-type-btn--active) {
background: rgba(255, 255, 255, 0.7);
color: #0f172a;
}
.pe-type-btn--active {
background: #fff;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.12);
color: #0f172a;
}
.project-editor__col-divider {
border-top: 1px solid rgba(15, 23, 42, 0.08);
margin-top: 0.25rem;
padding-top: 0.75rem;
}
@media (max-width: 1200px) {
.project-editor {
grid-template-columns: 1fr 22rem;
}
}
@media (max-width: 900px) {
.project-editor {
grid-template-columns: 1fr;
}
.project-editor__block-panel {
position: static;
}
}
/* ── Picture upload drop zones (Meta sidebar) ─────────────────────── */
.pe-picture-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.pe-drop-zone__input {
display: none;
}
.pe-drop-zone {
border: 2px dashed rgba(15, 23, 42, 0.18);
border-radius: 0.65rem;
cursor: pointer;
overflow: hidden;
position: relative;
transition: border-color 0.15s, background 0.15s;
user-select: none;
}
.pe-drop-zone--over {
background: #f0f9ff;
border-color: rgba(14, 116, 144, 0.55);
}
.pe-drop-zone--filled {
border-style: solid;
border-color: rgba(15, 23, 42, 0.12);
}
.pe-drop-zone__empty {
align-items: center;
color: #94a3b8;
display: flex;
flex-direction: column;
font-size: 0.72rem;
gap: 0.4rem;
justify-content: center;
min-height: 5rem;
padding: 1rem;
}
.pe-drop-zone__preview {
display: block;
height: auto;
max-height: 9rem;
object-fit: cover;
width: 100%;
}
.pe-drop-zone__overlay {
align-items: center;
background: rgba(15, 23, 42, 0.45);
bottom: 0;
color: #fff;
display: flex;
font-size: 0.72rem;
gap: 0.5rem;
justify-content: center;
left: 0;
opacity: 0;
padding: 0.35rem 0.5rem;
position: absolute;
right: 0;
top: 0;
transition: opacity 0.15s;
}
.pe-drop-zone:hover .pe-drop-zone__overlay,
.pe-drop-zone--over .pe-drop-zone__overlay {
opacity: 1;
}
.pe-drop-zone__clear {
background: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 50%;
color: #fff;
cursor: pointer;
font-size: 0.7rem;
line-height: 1;
padding: 0.2rem 0.35rem;
transition: background 0.15s;
}
.pe-drop-zone__clear:hover {
background: rgba(239, 68, 68, 0.75);
}
</style>