Transform beforeafter block into generic image+text block

- Add toggle (isBeforeAfter) to switch between modes
- Mode simple: single image + text (2 columns layout)
- Mode comparison: before/after slider + text (2 columns layout)
- Remove "1/2, 1/2" layout from report blueprint (keep only "1/1")
- Update Vue component with 2-column grid preview
- Update PHP snippet to handle both modes
- Rename block to "Image avec texte"

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-02-09 18:20:29 +01:00
parent a765b5d235
commit 6251d8f09f
6 changed files with 244 additions and 133 deletions

View file

@ -44,7 +44,6 @@ tabs:
type: layout type: layout
layouts: layouts:
- "1/1" - "1/1"
- "1/2, 1/2"
fieldsets: fieldsets:
- text - text
- heading - heading

View file

@ -1,7 +1,19 @@
name: Comparaison Avant/Après name: Image avec texte
icon: images icon: images
preview: beforeafter preview: beforeafter
fields: fields:
isBeforeAfter:
label: Mode comparaison Avant/Après
type: toggle
default: false
image:
label: Image
type: files
multiple: false
layout: cards
query: page.images
when:
isBeforeAfter: false
imageBefore: imageBefore:
label: Image "Avant" label: Image "Avant"
type: files type: files
@ -9,8 +21,10 @@ fields:
layout: cards layout: cards
query: page.images query: page.images
uploads: false uploads: false
help: Image affichée à gauche / dessus help: Image affichée à gauche / dessous
width: 1/2 width: 1/2
when:
isBeforeAfter: true
imageAfter: imageAfter:
label: Image "Après" label: Image "Après"
type: files type: files
@ -18,9 +32,21 @@ fields:
layout: cards layout: cards
query: page.images query: page.images
uploads: false uploads: false
help: Image affichée à droite / dessous help: Image affichée à droite / dessus
width: 1/2 width: 1/2
when:
isBeforeAfter: true
caption: caption:
label: Légende label: Légende
type: text type: text
help: Légende commune aux deux images text:
label: Texte
type: writer
marks:
- bold
- italic
- link
nodes:
- bulletList
- orderedList
- paragraph

View file

@ -1 +1 @@
.beforeafter-preview[data-v-92ee366b]{cursor:pointer;border-radius:var(--rounded);overflow:hidden;background:var(--color-background)}.beforeafter-preview__images[data-v-92ee366b]{position:relative;width:100%;height:200px;background:var(--color-gray-200)}.beforeafter-preview__image[data-v-92ee366b]{position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover}.beforeafter-preview__image--before[data-v-92ee366b]{clip-path:polygon(0 0,50% 0,50% 100%,0 100%)}.beforeafter-preview__divider[data-v-92ee366b]{position:absolute;left:50%;top:0;bottom:0;width:2px;background:#fff;box-shadow:0 0 8px #0000004d;transform:translate(-1px)}.beforeafter-preview__caption[data-v-92ee366b]{padding:.75rem;font-size:var(--text-sm);color:var(--color-gray-600);font-style:italic;background:var(--color-background);margin:0}.beforeafter-preview__empty[data-v-92ee366b]{padding:3rem 1rem;text-align:center;color:var(--color-gray-500);font-size:var(--text-sm)}.beforeafter-preview:hover .beforeafter-preview__images[data-v-92ee366b]{opacity:.9} .imagetext-preview[data-v-caec09ec]{cursor:pointer;border-radius:var(--rounded);overflow:hidden;background:var(--color-background);border:1px solid var(--color-gray-300)}.imagetext-preview__container[data-v-caec09ec]{display:grid;grid-template-columns:1fr 1fr;gap:1rem;padding:1rem}.imagetext-preview__media[data-v-caec09ec]{display:flex;flex-direction:column;gap:.5rem}.imagetext-preview__slider[data-v-caec09ec]{position:relative;width:100%;height:200px;background:var(--color-gray-200);border-radius:var(--rounded-sm);overflow:hidden}.imagetext-preview__image[data-v-caec09ec]{position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover}.imagetext-preview__image--before[data-v-caec09ec]{clip-path:polygon(0 0,50% 0,50% 100%,0 100%)}.imagetext-preview__divider[data-v-caec09ec]{position:absolute;left:50%;top:0;bottom:0;width:2px;background:#fff;box-shadow:0 0 8px #0000004d;transform:translate(-1px)}.imagetext-preview__single-image[data-v-caec09ec]{width:100%;height:200px;object-fit:cover;border-radius:var(--rounded-sm);background:var(--color-gray-200)}.imagetext-preview__empty-media[data-v-caec09ec]{width:100%;height:200px;display:flex;align-items:center;justify-content:center;background:var(--color-gray-200);border-radius:var(--rounded-sm);color:var(--color-gray-500);font-size:var(--text-sm)}.imagetext-preview__caption[data-v-caec09ec]{font-size:var(--text-sm);color:var(--color-gray-600);font-style:italic;margin:0;padding:0 .25rem}.imagetext-preview__text[data-v-caec09ec]{font-size:var(--text-sm);color:var(--color-gray-700);line-height:1.5}.imagetext-preview__empty-text[data-v-caec09ec]{color:var(--color-gray-500);font-style:italic}.imagetext-preview[data-v-caec09ec]:hover{border-color:var(--color-gray-400)}

View file

@ -1 +1 @@
(function(){"use strict";function f(n,e,t,r,s,a,o,p){var i=typeof n=="function"?n.options:n;return e&&(i.render=e,i.staticRenderFns=t,i._compiled=!0),i._scopeId="data-v-"+a,{exports:n,options:i}}const c={__name:"BeforeAfterBlock",props:{content:Object},setup(n){const e=n,t=Vue.computed(()=>{var o;if(!((o=e.content)!=null&&o.imagebefore)||!e.content.imagebefore.length)return null;const a=e.content.imagebefore[0];return(a==null?void 0:a.url)||null}),r=Vue.computed(()=>{var o;if(!((o=e.content)!=null&&o.imageafter)||!e.content.imageafter.length)return null;const a=e.content.imageafter[0];return(a==null?void 0:a.url)||null}),s=Vue.computed(()=>t.value||r.value);return{__sfc:!0,props:e,imageBeforeUrl:t,imageAfterUrl:r,hasImages:s}}};var _=function(){var e=this,t=e._self._c,r=e._self._setupProxy;return t("div",{staticClass:"beforeafter-preview",on:{click:function(s){return e.$emit("open")}}},[r.hasImages?t("div",{staticClass:"beforeafter-preview__images"},[r.imageAfterUrl?t("img",{staticClass:"beforeafter-preview__image beforeafter-preview__image--after",attrs:{src:r.imageAfterUrl,alt:"Après"}}):e._e(),r.imageBeforeUrl?t("img",{staticClass:"beforeafter-preview__image beforeafter-preview__image--before",attrs:{src:r.imageBeforeUrl,alt:"Avant"}}):e._e(),r.imageBeforeUrl&&r.imageAfterUrl?t("div",{staticClass:"beforeafter-preview__divider"}):e._e()]):e._e(),e.content.caption?t("p",{staticClass:"beforeafter-preview__caption"},[e._v(" "+e._s(e.content.caption)+" ")]):e._e(),r.hasImages?e._e():t("div",{staticClass:"beforeafter-preview__empty"},[e._v(" Cliquer pour ajouter des images ")])])},l=[],u=f(c,_,l,!1,null,"92ee366b");const m=u.exports;window.panel.plugin("index/beforeafter",{blocks:{beforeafter:m}})})(); (function(){"use strict";function m(a,e,t,i,s,c,r,n){var o=typeof a=="function"?a.options:a;return e&&(o.render=e,o.staticRenderFns=t,o._compiled=!0),o._scopeId="data-v-"+c,{exports:a,options:o}}const l={__name:"BeforeAfterBlock",props:{content:Object},setup(a){const e=a,t=Vue.computed(()=>{var r,n;return((r=e.content)==null?void 0:r.isbeforeafter)===!0||((n=e.content)==null?void 0:n.isbeforeafter)==="true"}),i=Vue.computed(()=>{var n;if(!((n=e.content)!=null&&n.image)||!e.content.image.length)return null;const r=e.content.image[0];return(r==null?void 0:r.url)||null}),s=Vue.computed(()=>{var n;if(!((n=e.content)!=null&&n.imagebefore)||!e.content.imagebefore.length)return null;const r=e.content.imagebefore[0];return(r==null?void 0:r.url)||null}),c=Vue.computed(()=>{var n;if(!((n=e.content)!=null&&n.imageafter)||!e.content.imageafter.length)return null;const r=e.content.imageafter[0];return(r==null?void 0:r.url)||null});return{__sfc:!0,props:e,isBeforeAfter:t,imageUrl:i,imageBeforeUrl:s,imageAfterUrl:c}}};var _=function(){var e=this,t=e._self._c,i=e._self._setupProxy;return t("div",{staticClass:"imagetext-preview",on:{click:function(s){return e.$emit("open")}}},[t("div",{staticClass:"imagetext-preview__container"},[t("div",{staticClass:"imagetext-preview__media"},[i.isBeforeAfter&&(i.imageBeforeUrl||i.imageAfterUrl)?t("div",{staticClass:"imagetext-preview__slider"},[i.imageAfterUrl?t("img",{staticClass:"imagetext-preview__image imagetext-preview__image--after",attrs:{src:i.imageAfterUrl,alt:"Après"}}):e._e(),i.imageBeforeUrl?t("img",{staticClass:"imagetext-preview__image imagetext-preview__image--before",attrs:{src:i.imageBeforeUrl,alt:"Avant"}}):e._e(),i.imageBeforeUrl&&i.imageAfterUrl?t("div",{staticClass:"imagetext-preview__divider"}):e._e()]):!i.isBeforeAfter&&i.imageUrl?t("img",{staticClass:"imagetext-preview__single-image",attrs:{src:i.imageUrl,alt:"Image"}}):t("div",{staticClass:"imagetext-preview__empty-media"},[e._v(" Aucune image ")]),e.content.caption?t("p",{staticClass:"imagetext-preview__caption"},[e._v(" "+e._s(e.content.caption)+" ")]):e._e()]),t("div",{staticClass:"imagetext-preview__text"},[e.content.text?t("div",{domProps:{innerHTML:e._s(e.content.text)}}):t("div",{staticClass:"imagetext-preview__empty-text"},[e._v(" Aucun texte ")])])])])},f=[],g=m(l,_,f,!1,null,"caec09ec");const u=g.exports;window.panel.plugin("index/beforeafter",{blocks:{beforeafter:u}})})();

View file

@ -1,5 +1,7 @@
<?php <?php
/** @var \Kirby\Cms\Block $block */ /** @var \Kirby\Cms\Block $block */
$isBeforeAfter = $block->isBeforeAfter()->toBool();
$image = $block->image()->toFile();
$imageBefore = $block->imageBefore()->toFile(); $imageBefore = $block->imageBefore()->toFile();
$imageAfter = $block->imageAfter()->toFile(); $imageAfter = $block->imageAfter()->toFile();
$caption = $block->caption()->value(); $caption = $block->caption()->value();
@ -9,91 +11,101 @@ $text = $block->text()->value();
<div class="subsection-w-media"> <div class="subsection-w-media">
<div class="media"> <div class="media">
<div class="container slider-before-after"> <?php if ($isBeforeAfter && ($imageBefore || $imageAfter)): ?>
<div class="image-container"> <!-- Mode avant/après : slider -->
<?php if ($imageBefore): ?> <div class="container slider-before-after">
<img class="image-before slider-image" <div class="image-container">
src="<?= $imageBefore->url() ?>" <?php if ($imageBefore): ?>
alt="<?= $imageBefore->alt()->or('Image avant')->esc() ?>" /> <img class="image-before slider-image"
<?php endif ?> src="<?= $imageBefore->url() ?>"
alt="<?= $imageBefore->alt()->or('Image avant')->esc() ?>" />
<?php endif ?>
<?php if ($imageAfter): ?> <?php if ($imageAfter): ?>
<img class="image-after slider-image" <img class="image-after slider-image"
src="<?= $imageAfter->url() ?>" src="<?= $imageAfter->url() ?>"
alt="<?= $imageAfter->alt()->or('Image après')->esc() ?>" /> alt="<?= $imageAfter->alt()->or('Image après')->esc() ?>" />
<?php endif ?> <?php endif ?>
</div> </div>
<input <input
type="range" type="range"
min="0" min="0"
max="100" max="100"
value="50" value="50"
aria-label="Pourcentage de la photo avant affichée" aria-label="Pourcentage de la photo avant affichée"
class="slider" class="slider"
/> />
<div class="slider-line" aria-hidden="true"></div> <div class="slider-line" aria-hidden="true"></div>
<div class="slider-button" aria-hidden="true"> <div class="slider-button" aria-hidden="true">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="30" width="30"
height="30" height="30"
fill="currentColor" fill="currentColor"
viewBox="0 0 256 256" viewBox="0 0 256 256"
> >
<rect width="256" height="256" fill="none"></rect> <rect width="256" height="256" fill="none"></rect>
<line <line
x1="128" x1="128"
y1="40" y1="40"
x2="128" x2="128"
y2="216" y2="216"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="16" stroke-width="16"
></line> ></line>
<line <line
x1="96" x1="96"
y1="128" y1="128"
x2="16" x2="16"
y2="128" y2="128"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="16" stroke-width="16"
></line> ></line>
<polyline <polyline
points="48 160 16 128 48 96" points="48 160 16 128 48 96"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="16" stroke-width="16"
></polyline> ></polyline>
<line <line
x1="160" x1="160"
y1="128" y1="128"
x2="240" x2="240"
y2="128" y2="128"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="16" stroke-width="16"
></line> ></line>
<polyline <polyline
points="208 96 240 128 208 160" points="208 96 240 128 208 160"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="16" stroke-width="16"
></polyline> ></polyline>
</svg> </svg>
</div>
</div> </div>
</div> <?php elseif (!$isBeforeAfter && $image): ?>
<!-- Mode simple : une seule image -->
<div class="container-figure fig-simple">
<figure>
<img src="<?= $image->url() ?>" alt="<?= $image->alt()->esc() ?>" />
</figure>
</div>
<?php endif ?>
<?php if ($caption): ?> <?php if ($caption): ?>
<p class="caption"><?= $caption ?></p> <p class="caption"><?= $caption ?></p>

View file

@ -1,27 +1,51 @@
<template> <template>
<div @click="$emit('open')" class="beforeafter-preview"> <div @click="$emit('open')" class="imagetext-preview">
<div v-if="hasImages" class="beforeafter-preview__images"> <div class="imagetext-preview__container">
<img <!-- Zone image / slider -->
v-if="imageAfterUrl" <div class="imagetext-preview__media">
:src="imageAfterUrl" <!-- Mode avant/après -->
class="beforeafter-preview__image beforeafter-preview__image--after" <div v-if="isBeforeAfter && (imageBeforeUrl || imageAfterUrl)" class="imagetext-preview__slider">
alt="Après" <img
/> v-if="imageAfterUrl"
<img :src="imageAfterUrl"
v-if="imageBeforeUrl" class="imagetext-preview__image imagetext-preview__image--after"
:src="imageBeforeUrl" alt="Après"
class="beforeafter-preview__image beforeafter-preview__image--before" />
alt="Avant" <img
/> v-if="imageBeforeUrl"
<div v-if="imageBeforeUrl && imageAfterUrl" class="beforeafter-preview__divider"></div> :src="imageBeforeUrl"
</div> class="imagetext-preview__image imagetext-preview__image--before"
alt="Avant"
/>
<div v-if="imageBeforeUrl && imageAfterUrl" class="imagetext-preview__divider"></div>
</div>
<p v-if="content.caption" class="beforeafter-preview__caption"> <!-- Mode simple -->
{{ content.caption }} <img
</p> v-else-if="!isBeforeAfter && imageUrl"
:src="imageUrl"
class="imagetext-preview__single-image"
alt="Image"
/>
<div v-if="!hasImages" class="beforeafter-preview__empty"> <!-- Placeholder si pas d'image -->
Cliquer pour ajouter des images <div v-else class="imagetext-preview__empty-media">
Aucune image
</div>
<!-- Légende -->
<p v-if="content.caption" class="imagetext-preview__caption">
{{ content.caption }}
</p>
</div>
<!-- Zone texte -->
<div class="imagetext-preview__text">
<div v-if="content.text" v-html="content.text"></div>
<div v-else class="imagetext-preview__empty-text">
Aucun texte
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@ -33,8 +57,22 @@ const props = defineProps({
content: Object content: Object
}); });
// Attention: les clés sont en minuscules dans Kirby
const isBeforeAfter = computed(() => {
return props.content?.isbeforeafter === true || props.content?.isbeforeafter === "true";
});
// Mode simple : une seule image
const imageUrl = computed(() => {
if (!props.content?.image || !props.content.image.length) {
return null;
}
const file = props.content.image[0];
return file?.url || null;
});
// Mode avant/après : deux images
const imageBeforeUrl = computed(() => { const imageBeforeUrl = computed(() => {
// Attention: la clé est en minuscules dans Kirby
if (!props.content?.imagebefore || !props.content.imagebefore.length) { if (!props.content?.imagebefore || !props.content.imagebefore.length) {
return null; return null;
} }
@ -43,35 +81,47 @@ const imageBeforeUrl = computed(() => {
}); });
const imageAfterUrl = computed(() => { const imageAfterUrl = computed(() => {
// Attention: la clé est en minuscules dans Kirby
if (!props.content?.imageafter || !props.content.imageafter.length) { if (!props.content?.imageafter || !props.content.imageafter.length) {
return null; return null;
} }
const file = props.content.imageafter[0]; const file = props.content.imageafter[0];
return file?.url || null; return file?.url || null;
}); });
const hasImages = computed(() => {
return imageBeforeUrl.value || imageAfterUrl.value;
});
</script> </script>
<style scoped> <style scoped>
.beforeafter-preview { .imagetext-preview {
cursor: pointer; cursor: pointer;
border-radius: var(--rounded); border-radius: var(--rounded);
overflow: hidden; overflow: hidden;
background: var(--color-background); background: var(--color-background);
border: 1px solid var(--color-gray-300);
} }
.beforeafter-preview__images { .imagetext-preview__container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
padding: 1rem;
}
/* Zone média (image ou slider) */
.imagetext-preview__media {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.imagetext-preview__slider {
position: relative; position: relative;
width: 100%; width: 100%;
height: 200px; height: 200px;
background: var(--color-gray-200); background: var(--color-gray-200);
border-radius: var(--rounded-sm);
overflow: hidden;
} }
.beforeafter-preview__image { .imagetext-preview__image {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
@ -80,11 +130,11 @@ const hasImages = computed(() => {
object-fit: cover; object-fit: cover;
} }
.beforeafter-preview__image--before { .imagetext-preview__image--before {
clip-path: polygon(0 0, 50% 0, 50% 100%, 0 100%); clip-path: polygon(0 0, 50% 0, 50% 100%, 0 100%);
} }
.beforeafter-preview__divider { .imagetext-preview__divider {
position: absolute; position: absolute;
left: 50%; left: 50%;
top: 0; top: 0;
@ -95,23 +145,47 @@ const hasImages = computed(() => {
transform: translateX(-1px); transform: translateX(-1px);
} }
.beforeafter-preview__caption { .imagetext-preview__single-image {
padding: 0.75rem; width: 100%;
font-size: var(--text-sm); height: 200px;
color: var(--color-gray-600); object-fit: cover;
font-style: italic; border-radius: var(--rounded-sm);
background: var(--color-background); background: var(--color-gray-200);
margin: 0;
} }
.beforeafter-preview__empty { .imagetext-preview__empty-media {
padding: 3rem 1rem; width: 100%;
text-align: center; height: 200px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-gray-200);
border-radius: var(--rounded-sm);
color: var(--color-gray-500); color: var(--color-gray-500);
font-size: var(--text-sm); font-size: var(--text-sm);
} }
.beforeafter-preview:hover .beforeafter-preview__images { .imagetext-preview__caption {
opacity: 0.9; font-size: var(--text-sm);
color: var(--color-gray-600);
font-style: italic;
margin: 0;
padding: 0 0.25rem;
}
/* Zone texte */
.imagetext-preview__text {
font-size: var(--text-sm);
color: var(--color-gray-700);
line-height: 1.5;
}
.imagetext-preview__empty-text {
color: var(--color-gray-500);
font-style: italic;
}
.imagetext-preview:hover {
border-color: var(--color-gray-400);
} }
</style> </style>