1918 lines
82 KiB
Vue
1918 lines
82 KiB
Vue
<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 (150–160 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> |