Add before/after comparison block plugin

- Create custom block plugin with Vue 3 + Composition API
- Use kirbyup for building Vue SFC components
- Block allows selecting two images with slider comparison UI
- Preview shows images overlapped at 50% in the panel
- Add beforeafter block to report blueprint
- Update report template to use authors field
- Change text block heading level from 4 to 3

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-02-09 17:21:46 +01:00
parent 509eb0ddab
commit 3d182a233d
12 changed files with 313 additions and 9 deletions

View file

@ -6,5 +6,5 @@ fields:
text:
type: writer
headings:
- 4
- 3
placeholder: field.blocks.text.placeholder

View file

@ -8,19 +8,22 @@ tabs:
label: Contenu
icon: page
columns:
- width: 2/6
- width: 1/6
fields:
emptyLeft:
type: gap
- width: 4/6
fields:
created:
label: Première publication
type: date
display: DD / MM / YYYY
default: today
width: 1/4
- width: 4/6
fields:
width: 1/2
subtitle:
label: Sous-titre
type: text
width: 1/2
chapo:
label: Chapo
type: writer
@ -42,15 +45,21 @@ tabs:
layouts:
- "1/1"
- "1/2, 1/2"
- "1/3, 1/3, 1/3"
fieldsets:
- heading
- text
- image
- beforeafter
- width: 1/6
fields:
emptyRight:
type: gap
metadataTab:
label: Métadonnées
icon: table
fields:
authors:
label: Auteur(s)
type: tags
incidentDate:
label: Date de l'incident
type: date

2
site/plugins/beforeafter/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
*.log

View file

@ -0,0 +1,35 @@
name: Comparaison Avant/Après
icon: images
preview: beforeafter
fields:
imageBefore:
label: Image "Avant"
type: files
multiple: false
layout: cards
query: page.images
uploads: false
help: Image affichée à gauche / dessous
imageAfter:
label: Image "Après"
type: files
multiple: false
layout: cards
query: page.images
uploads: false
help: Image affichée à droite / dessus
caption:
label: Légende
type: text
help: Légende commune aux deux images
text:
label: Texte accompagnant
type: writer
marks:
- bold
- italic
- link
nodes:
- bulletList
- orderedList
- paragraph

View file

@ -0,0 +1 @@
.beforeafter-preview[data-v-2682dba7]{cursor:pointer;border-radius:var(--rounded);overflow:hidden;background:var(--color-background)}.beforeafter-preview__images[data-v-2682dba7]{position:relative;width:100%;height:200px;background:var(--color-gray-200)}.beforeafter-preview__image[data-v-2682dba7]{position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover}.beforeafter-preview__image--after[data-v-2682dba7]{clip-path:polygon(0 0,50% 0,50% 100%,0 100%)}.beforeafter-preview__divider[data-v-2682dba7]{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-2682dba7]{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-2682dba7]{padding:3rem 1rem;text-align:center;color:var(--color-gray-500);font-size:var(--text-sm)}.beforeafter-preview:hover .beforeafter-preview__images[data-v-2682dba7]{opacity:.9}

View file

@ -0,0 +1 @@
(function(){"use strict";function f(a,e,t,r,s,n,o,p){var i=typeof a=="function"?a.options:a;return e&&(i.render=e,i.staticRenderFns=t,i._compiled=!0),i._scopeId="data-v-"+n,{exports:a,options:i}}const c={__name:"BeforeAfterBlock",props:{content:Object,endpoints:Object},setup(a){const e=a,t=Vue.computed(()=>{var o;if(!((o=e.content)!=null&&o.imagebefore)||!e.content.imagebefore.length)return null;const n=e.content.imagebefore[0];return(n==null?void 0:n.url)||null}),r=Vue.computed(()=>{var o;if(!((o=e.content)!=null&&o.imageafter)||!e.content.imageafter.length)return null;const n=e.content.imageafter[0];return(n==null?void 0:n.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.imageBeforeUrl?t("img",{staticClass:"beforeafter-preview__image beforeafter-preview__image--before",attrs:{src:r.imageBeforeUrl,alt:"Avant"}}):e._e(),r.imageAfterUrl?t("img",{staticClass:"beforeafter-preview__image beforeafter-preview__image--after",attrs:{src:r.imageAfterUrl,alt:"Après"}}):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,"2682dba7");const m=u.exports;window.panel.plugin("index/beforeafter",{blocks:{beforeafter:m}})})();

View file

@ -0,0 +1,10 @@
<?php
Kirby::plugin('index/beforeafter', [
'blueprints' => [
'blocks/beforeafter' => __DIR__ . '/blueprints/blocks/beforeafter.yml'
],
'snippets' => [
'blocks/beforeafter' => __DIR__ . '/snippets/blocks/beforeafter.php'
]
]);

View file

@ -0,0 +1,12 @@
{
"name": "beforeafter-block",
"version": "1.0.0",
"scripts": {
"dev": "npx -y kirbyup src/index.js --watch",
"build": "npx -y kirbyup src/index.js"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"vite": "^7.1.7"
}
}

View file

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

View file

@ -0,0 +1,118 @@
<template>
<div @click="$emit('open')" class="beforeafter-preview">
<div v-if="hasImages" class="beforeafter-preview__images">
<img
v-if="imageBeforeUrl"
:src="imageBeforeUrl"
class="beforeafter-preview__image beforeafter-preview__image--before"
alt="Avant"
/>
<img
v-if="imageAfterUrl"
:src="imageAfterUrl"
class="beforeafter-preview__image beforeafter-preview__image--after"
alt="Après"
/>
<div v-if="imageBeforeUrl && imageAfterUrl" class="beforeafter-preview__divider"></div>
</div>
<p v-if="content.caption" class="beforeafter-preview__caption">
{{ content.caption }}
</p>
<div v-if="!hasImages" class="beforeafter-preview__empty">
Cliquer pour ajouter des images
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
content: Object,
endpoints: Object
});
const imageBeforeUrl = computed(() => {
// Attention: la clé est en minuscules dans Kirby
if (!props.content?.imagebefore || !props.content.imagebefore.length) {
return null;
}
const file = props.content.imagebefore[0];
return file?.url || null;
});
const imageAfterUrl = computed(() => {
// Attention: la clé est en minuscules dans Kirby
if (!props.content?.imageafter || !props.content.imageafter.length) {
return null;
}
const file = props.content.imageafter[0];
return file?.url || null;
});
const hasImages = computed(() => {
return imageBeforeUrl.value || imageAfterUrl.value;
});
</script>
<style scoped>
.beforeafter-preview {
cursor: pointer;
border-radius: var(--rounded);
overflow: hidden;
background: var(--color-background);
}
.beforeafter-preview__images {
position: relative;
width: 100%;
height: 200px;
background: var(--color-gray-200);
}
.beforeafter-preview__image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.beforeafter-preview__image--after {
clip-path: polygon(0 0, 50% 0, 50% 100%, 0 100%);
}
.beforeafter-preview__divider {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 2px;
background: white;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.3);
transform: translateX(-1px);
}
.beforeafter-preview__caption {
padding: 0.75rem;
font-size: var(--text-sm);
color: var(--color-gray-600);
font-style: italic;
background: var(--color-background);
margin: 0;
}
.beforeafter-preview__empty {
padding: 3rem 1rem;
text-align: center;
color: var(--color-gray-500);
font-size: var(--text-sm);
}
.beforeafter-preview:hover .beforeafter-preview__images {
opacity: 0.9;
}
</style>

View file

@ -0,0 +1,7 @@
import BeforeAfterBlock from "./components/BeforeAfterBlock.vue";
window.panel.plugin("index/beforeafter", {
blocks: {
beforeafter: BeforeAfterBlock
}
});

View file

@ -29,7 +29,7 @@
<div class="panel__header">
<span class="icon"><?= svg('assets/icons/toc.svg') ?></span>
<span class="text">Table des matières</span>
<span class="icon close"><?= svg('assets/icons/close.svg') ?></span>
<span class="icon close"><?= svg('assets/icons/close.svg') ?></span>
</div>
<div class="panel__content">
@ -77,7 +77,7 @@
<dl class="report__dl">
<div class="dl__group">
<dt>Auteurs du rapport</dt>
<dd>INDEX Investigation</dd>
<dd><?= $page->authors() ?></dd>
</div>
<div class="dl__group">
<dt>Date du rapport</dt>