Refactor blocks architecture to modular approach

- Restore "1/2, 1/2" layout for flexible column combinations
- Simplify beforeafter block: remove toggle and text field, keep only image comparison
- Create new video block with URL support (YouTube/Vimeo/direct files)
- Create horizontal-gallery block for scrollable image galleries
- Add H4 heading level support
- All blocks now modular: combine with text blocks in 2-column layouts

Blocks available:
- Text, Heading (h2-h4), Image, Video
- Before/After comparison (no text)
- Horizontal gallery (with text below)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-02-09 18:59:08 +01:00
parent 6251d8f09f
commit 561932724b
23 changed files with 539 additions and 252 deletions

View file

@ -13,6 +13,9 @@ fields:
- value: h3
icon: h3
text: H3
- value: h4
icon: h4
text: H4
text:
label: field.blocks.heading.text
type: writer

View file

@ -44,11 +44,14 @@ tabs:
type: layout
layouts:
- "1/1"
- "1/2, 1/2"
fieldsets:
- text
- heading
- image
- video
- beforeafter
- horizontalgallery
- width: 1/6
fields:
emptyRight:

View file

@ -1,52 +1,23 @@
name: Image avec texte
name: Comparaison Avant/Après
icon: images
preview: beforeafter
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:
label: Image "Avant"
type: files
multiple: false
layout: cards
query: page.images
uploads: false
help: Image affichée à gauche / dessous
width: 1/2
when:
isBeforeAfter: true
imageAfter:
label: Image "Après"
type: files
multiple: false
layout: cards
query: page.images
uploads: false
help: Image affichée à droite / dessus
width: 1/2
when:
isBeforeAfter: true
caption:
label: Légende
type: text
text:
label: Texte
type: writer
marks:
- bold
- italic
- link
nodes:
- bulletList
- orderedList
- paragraph

View file

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

View file

@ -1 +1 @@
(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}})})();
(function(){"use strict";function f(a,e,r,t,n,o,u,p){var i=typeof a=="function"?a.options:a;return e&&(i.render=e,i.staticRenderFns=r,i._compiled=!0),i._scopeId="data-v-"+o,{exports:a,options:i}}const s={__name:"BeforeAfterBlock",props:{content:Object},setup(a){const e=a,r=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}),t=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});return{__sfc:!0,props:e,imageBeforeUrl:r,imageAfterUrl:t}}};var c=function(){var e=this,r=e._self._c,t=e._self._setupProxy;return r("div",{staticClass:"beforeafter-preview",on:{click:function(n){return e.$emit("open")}}},[t.imageBeforeUrl||t.imageAfterUrl?r("div",{staticClass:"beforeafter-preview__slider"},[t.imageAfterUrl?r("img",{staticClass:"beforeafter-preview__image beforeafter-preview__image--after",attrs:{src:t.imageAfterUrl,alt:"Après"}}):e._e(),t.imageBeforeUrl?r("img",{staticClass:"beforeafter-preview__image beforeafter-preview__image--before",attrs:{src:t.imageBeforeUrl,alt:"Avant"}}):e._e(),t.imageBeforeUrl&&t.imageAfterUrl?r("div",{staticClass:"beforeafter-preview__divider"}):e._e()]):e._e(),e.content.caption?r("p",{staticClass:"beforeafter-preview__caption"},[e._v(" "+e._s(e.content.caption)+" ")]):e._e(),!t.imageBeforeUrl&&!t.imageAfterUrl?r("div",{staticClass:"beforeafter-preview__empty"},[e._v(" Cliquer pour ajouter des images ")]):e._e()])},l=[],_=f(s,c,l,!1,null,"7994b7b1");const m=_.exports;window.panel.plugin("index/beforeafter",{blocks:{beforeafter:m}})})();

View file

@ -1,121 +1,98 @@
<?php
/** @var \Kirby\Cms\Block $block */
$isBeforeAfter = $block->isBeforeAfter()->toBool();
$image = $block->image()->toFile();
$imageBefore = $block->imageBefore()->toFile();
$imageAfter = $block->imageAfter()->toFile();
$caption = $block->caption()->value();
$text = $block->text()->value();
?>
<div class="subsection-w-media">
<div class="media">
<?php if ($isBeforeAfter && ($imageBefore || $imageAfter)): ?>
<!-- Mode avant/après : slider -->
<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 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 if ($imageBefore || $imageAfter): ?>
<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 ($caption): ?>
<p class="caption"><?= $caption ?></p>
<?php if ($imageAfter): ?>
<img class="image-after slider-image"
src="<?= $imageAfter->url() ?>"
alt="<?= $imageAfter->alt()->or('Image après')->esc() ?>" />
<?php endif ?>
</div>
<?php if ($text): ?>
<div class="subsection-txt">
<?= $text ?>
<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>
<?php endif ?>
</div>
<?php if ($caption): ?>
<p class="caption"><?= $caption ?></p>
<?php endif ?>
<?php endif ?>

View file

@ -1,51 +1,27 @@
<template>
<div @click="$emit('open')" class="imagetext-preview">
<div class="imagetext-preview__container">
<!-- Zone image / slider -->
<div class="imagetext-preview__media">
<!-- Mode avant/après -->
<div v-if="isBeforeAfter && (imageBeforeUrl || imageAfterUrl)" class="imagetext-preview__slider">
<img
v-if="imageAfterUrl"
:src="imageAfterUrl"
class="imagetext-preview__image imagetext-preview__image--after"
alt="Après"
/>
<img
v-if="imageBeforeUrl"
:src="imageBeforeUrl"
class="imagetext-preview__image imagetext-preview__image--before"
alt="Avant"
/>
<div v-if="imageBeforeUrl && imageAfterUrl" class="imagetext-preview__divider"></div>
</div>
<div @click="$emit('open')" class="beforeafter-preview">
<div v-if="imageBeforeUrl || imageAfterUrl" class="beforeafter-preview__slider">
<img
v-if="imageAfterUrl"
:src="imageAfterUrl"
class="beforeafter-preview__image beforeafter-preview__image--after"
alt="Après"
/>
<img
v-if="imageBeforeUrl"
:src="imageBeforeUrl"
class="beforeafter-preview__image beforeafter-preview__image--before"
alt="Avant"
/>
<div v-if="imageBeforeUrl && imageAfterUrl" class="beforeafter-preview__divider"></div>
</div>
<!-- Mode simple -->
<img
v-else-if="!isBeforeAfter && imageUrl"
:src="imageUrl"
class="imagetext-preview__single-image"
alt="Image"
/>
<p v-if="content.caption" class="beforeafter-preview__caption">
{{ content.caption }}
</p>
<!-- Placeholder si pas d'image -->
<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 v-if="!imageBeforeUrl && !imageAfterUrl" class="beforeafter-preview__empty">
Cliquer pour ajouter des images
</div>
</div>
</template>
@ -57,21 +33,6 @@ const props = defineProps({
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(() => {
if (!props.content?.imagebefore || !props.content.imagebefore.length) {
return null;
@ -90,7 +51,7 @@ const imageAfterUrl = computed(() => {
</script>
<style scoped>
.imagetext-preview {
.beforeafter-preview {
cursor: pointer;
border-radius: var(--rounded);
overflow: hidden;
@ -98,30 +59,14 @@ const imageAfterUrl = computed(() => {
border: 1px solid var(--color-gray-300);
}
.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 {
.beforeafter-preview__slider {
position: relative;
width: 100%;
height: 200px;
background: var(--color-gray-200);
border-radius: var(--rounded-sm);
overflow: hidden;
}
.imagetext-preview__image {
.beforeafter-preview__image {
position: absolute;
top: 0;
left: 0;
@ -130,11 +75,11 @@ const imageAfterUrl = computed(() => {
object-fit: cover;
}
.imagetext-preview__image--before {
.beforeafter-preview__image--before {
clip-path: polygon(0 0, 50% 0, 50% 100%, 0 100%);
}
.imagetext-preview__divider {
.beforeafter-preview__divider {
position: absolute;
left: 50%;
top: 0;
@ -145,47 +90,24 @@ const imageAfterUrl = computed(() => {
transform: translateX(-1px);
}
.imagetext-preview__single-image {
width: 100%;
height: 200px;
object-fit: cover;
border-radius: var(--rounded-sm);
background: var(--color-gray-200);
}
.imagetext-preview__empty-media {
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 {
.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;
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 {
.beforeafter-preview__empty {
padding: 3rem 1rem;
text-align: center;
color: var(--color-gray-500);
font-style: italic;
font-size: var(--text-sm);
background: var(--color-gray-100);
}
.imagetext-preview:hover {
.beforeafter-preview:hover {
border-color: var(--color-gray-400);
}
</style>

View file

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

View file

@ -0,0 +1,22 @@
name: Galerie horizontale
icon: images
preview: horizontalgallery
fields:
images:
label: Images
type: files
multiple: true
layout: cards
query: page.images
help: Pensez à ajouter une légende à chaque image via son blueprint (champ Caption)
text:
label: Texte accompagnant
type: writer
marks:
- bold
- italic
- link
nodes:
- bulletList
- orderedList
- paragraph

View file

@ -0,0 +1 @@
.hgallery-preview[data-v-9cf511cf]{cursor:pointer;border-radius:var(--rounded);overflow:hidden;background:var(--color-background);border:1px solid var(--color-gray-300)}.hgallery-preview__container[data-v-9cf511cf]{display:flex;flex-direction:column;gap:1rem;padding:1rem}.hgallery-preview__gallery[data-v-9cf511cf]{width:100%}.hgallery-preview__scroll[data-v-9cf511cf]{display:flex;gap:1rem;overflow-x:auto;padding-bottom:.5rem;scroll-behavior:smooth}.hgallery-preview__scroll[data-v-9cf511cf]::-webkit-scrollbar{height:6px}.hgallery-preview__scroll[data-v-9cf511cf]::-webkit-scrollbar-track{background:var(--color-gray-200);border-radius:3px}.hgallery-preview__scroll[data-v-9cf511cf]::-webkit-scrollbar-thumb{background:var(--color-gray-400);border-radius:3px}.hgallery-preview__scroll[data-v-9cf511cf]::-webkit-scrollbar-thumb:hover{background:var(--color-gray-500)}.hgallery-preview__slide[data-v-9cf511cf]{flex-shrink:0;width:250px;display:flex;flex-direction:column;gap:.5rem}.hgallery-preview__slide img[data-v-9cf511cf]{width:100%;height:180px;object-fit:cover;border-radius:var(--rounded-sm);background:var(--color-gray-200)}.hgallery-preview__caption[data-v-9cf511cf]{font-size:var(--text-xs);color:var(--color-gray-600);font-style:italic;margin:0;line-height:1.4}.hgallery-preview__empty[data-v-9cf511cf]{padding:3rem 1rem;text-align:center;color:var(--color-gray-500);font-size:var(--text-sm);background:var(--color-gray-100);border-radius:var(--rounded-sm)}.hgallery-preview__text[data-v-9cf511cf]{font-size:var(--text-sm);color:var(--color-gray-700);line-height:1.5;padding-top:.5rem;border-top:1px solid var(--color-gray-200)}.hgallery-preview[data-v-9cf511cf]:hover{border-color:var(--color-gray-400)}

View file

@ -0,0 +1 @@
(function(){"use strict";function r(l,e,t,a,n,s,v,d){var i=typeof l=="function"?l.options:l;return e&&(i.render=e,i.staticRenderFns=t,i._compiled=!0),i._scopeId="data-v-"+s,{exports:l,options:i}}const o={__name:"HorizontalGalleryBlock",props:{content:Object},setup(l){const e=l,t=Vue.computed(()=>{var a;return!((a=e.content)!=null&&a.images)||!e.content.images.length?[]:e.content.images.map(n=>({url:n.url,caption:n.text||n.caption||null}))});return{__sfc:!0,props:e,images:t}}};var c=function(){var e=this,t=e._self._c,a=e._self._setupProxy;return t("div",{staticClass:"hgallery-preview",on:{click:function(n){return e.$emit("open")}}},[t("div",{staticClass:"hgallery-preview__container"},[t("div",{staticClass:"hgallery-preview__gallery"},[a.images.length>0?t("div",{staticClass:"hgallery-preview__scroll"},e._l(a.images,function(n,s){return t("div",{key:s,staticClass:"hgallery-preview__slide"},[t("img",{attrs:{src:n.url,alt:n.caption||"Image"}}),n.caption?t("p",{staticClass:"hgallery-preview__caption"},[e._v(" "+e._s(n.caption)+" ")]):e._e()])}),0):t("div",{staticClass:"hgallery-preview__empty"},[e._v(" Aucune image sélectionnée ")])]),e.content.text?t("div",{staticClass:"hgallery-preview__text"},[t("div",{domProps:{innerHTML:e._s(e.content.text)}})]):e._e()])])},_=[],p=r(o,c,_,!1,null,"9cf511cf");const u=p.exports;window.panel.plugin("index/horizontal-gallery",{blocks:{horizontalgallery:u}})})();

View file

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

View file

@ -0,0 +1,12 @@
{
"name": "horizontal-gallery-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,38 @@
<?php
/** @var \Kirby\Cms\Block $block */
$images = $block->images()->toFiles();
$text = $block->text()->value();
?>
<div class="subsection-w-hscroll">
<div class="horizontal-scroll-spacer"></div>
<div class="horizontal-scroll">
<div class="horizontal-scroll-wrapper">
<?php foreach ($images as $image): ?>
<div class="horizontal-scroll-slide">
<div class="horizontal-scroll-slide__inner">
<figure>
<img src="<?= $image->url() ?>" alt="<?= $image->alt()->esc() ?>" />
</figure>
<?php if ($image->caption()->isNotEmpty()): ?>
<p class="caption"><?= $image->caption()->html() ?></p>
<?php endif ?>
</div>
</div>
<?php endforeach ?>
</div>
<div class="horizontal-scroll-button-prev"></div>
<div class="horizontal-scroll-button-next"></div>
<div class="horizontal-scroll-pagination"></div>
</div>
<?php if ($text): ?>
<div class="subsection-txt">
<?= $text ?>
</div>
<?php endif ?>
</div>

View file

@ -0,0 +1,142 @@
<template>
<div @click="$emit('open')" class="hgallery-preview">
<div class="hgallery-preview__container">
<!-- Zone galerie horizontale -->
<div class="hgallery-preview__gallery">
<div v-if="images.length > 0" class="hgallery-preview__scroll">
<div
v-for="(image, index) in images"
:key="index"
class="hgallery-preview__slide"
>
<img :src="image.url" :alt="image.caption || 'Image'" />
<p v-if="image.caption" class="hgallery-preview__caption">
{{ image.caption }}
</p>
</div>
</div>
<div v-else class="hgallery-preview__empty">
Aucune image sélectionnée
</div>
</div>
<!-- Zone texte -->
<div v-if="content.text" class="hgallery-preview__text">
<div v-html="content.text"></div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
content: Object
});
const images = computed(() => {
if (!props.content?.images || !props.content.images.length) {
return [];
}
return props.content.images.map(file => ({
url: file.url,
caption: file.text || file.caption || null
}));
});
</script>
<style scoped>
.hgallery-preview {
cursor: pointer;
border-radius: var(--rounded);
overflow: hidden;
background: var(--color-background);
border: 1px solid var(--color-gray-300);
}
.hgallery-preview__container {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
/* Galerie horizontale scrollable */
.hgallery-preview__gallery {
width: 100%;
}
.hgallery-preview__scroll {
display: flex;
gap: 1rem;
overflow-x: auto;
padding-bottom: 0.5rem;
scroll-behavior: smooth;
}
.hgallery-preview__scroll::-webkit-scrollbar {
height: 6px;
}
.hgallery-preview__scroll::-webkit-scrollbar-track {
background: var(--color-gray-200);
border-radius: 3px;
}
.hgallery-preview__scroll::-webkit-scrollbar-thumb {
background: var(--color-gray-400);
border-radius: 3px;
}
.hgallery-preview__scroll::-webkit-scrollbar-thumb:hover {
background: var(--color-gray-500);
}
.hgallery-preview__slide {
flex-shrink: 0;
width: 250px;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.hgallery-preview__slide img {
width: 100%;
height: 180px;
object-fit: cover;
border-radius: var(--rounded-sm);
background: var(--color-gray-200);
}
.hgallery-preview__caption {
font-size: var(--text-xs);
color: var(--color-gray-600);
font-style: italic;
margin: 0;
line-height: 1.4;
}
.hgallery-preview__empty {
padding: 3rem 1rem;
text-align: center;
color: var(--color-gray-500);
font-size: var(--text-sm);
background: var(--color-gray-100);
border-radius: var(--rounded-sm);
}
/* Zone texte */
.hgallery-preview__text {
font-size: var(--text-sm);
color: var(--color-gray-700);
line-height: 1.5;
padding-top: 0.5rem;
border-top: 1px solid var(--color-gray-200);
}
.hgallery-preview:hover {
border-color: var(--color-gray-400);
}
</style>

View file

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

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

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

View file

@ -0,0 +1,11 @@
name: Vidéo
icon: video
preview: video
fields:
url:
label: URL de la vidéo
type: url
help: URL YouTube, Vimeo ou lien direct vers un fichier vidéo
caption:
label: Légende
type: text

View file

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

View file

@ -0,0 +1,12 @@
{
"name": "video-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,39 @@
<?php
/** @var \Kirby\Cms\Block $block */
$url = $block->url()->value();
$caption = $block->caption()->value();
// Fonction pour détecter le type de vidéo
function getVideoEmbedCode($url) {
// YouTube
if (preg_match('/youtube\.com\/watch\?v=([^&]+)/', $url, $matches) ||
preg_match('/youtu\.be\/([^?]+)/', $url, $matches)) {
$videoId = $matches[1];
return '<iframe src="https://www.youtube.com/embed/' . $videoId . '" frameborder="0" allowfullscreen></iframe>';
}
// Vimeo
if (preg_match('/vimeo\.com\/(\d+)/', $url, $matches)) {
$videoId = $matches[1];
return '<iframe src="https://player.vimeo.com/video/' . $videoId . '" frameborder="0" allowfullscreen></iframe>';
}
// Vidéo directe (mp4, webm, etc.)
if (preg_match('/\.(mp4|webm|ogg)$/i', $url)) {
return '<video controls><source src="' . $url . '" type="video/' . pathinfo($url, PATHINFO_EXTENSION) . '"></video>';
}
// Par défaut, iframe
return '<iframe src="' . $url . '" frameborder="0" allowfullscreen></iframe>';
}
?>
<?php if ($url): ?>
<div class="container-video">
<?= getVideoEmbedCode($url) ?>
</div>
<?php if ($caption): ?>
<p class="caption"><?= $caption ?></p>
<?php endif ?>
<?php endif ?>

View file

@ -0,0 +1,95 @@
<template>
<div @click="$emit('open')" class="video-preview">
<div v-if="content.url" class="video-preview__container">
<div class="video-preview__placeholder">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z"/>
</svg>
<p class="video-preview__url">{{ truncatedUrl }}</p>
</div>
</div>
<p v-if="content.caption" class="video-preview__caption">
{{ content.caption }}
</p>
<div v-if="!content.url" class="video-preview__empty">
Cliquer pour ajouter une URL de vidéo
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
content: Object
});
const truncatedUrl = computed(() => {
if (!props.content?.url) return '';
const url = props.content.url;
return url.length > 50 ? url.substring(0, 50) + '...' : url;
});
</script>
<style scoped>
.video-preview {
cursor: pointer;
border-radius: var(--rounded);
overflow: hidden;
background: var(--color-background);
border: 1px solid var(--color-gray-300);
}
.video-preview__container {
width: 100%;
height: 180px;
background: var(--color-gray-900);
display: flex;
align-items: center;
justify-content: center;
}
.video-preview__placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
color: var(--color-gray-400);
padding: 1rem;
}
.video-preview__placeholder svg {
opacity: 0.5;
}
.video-preview__url {
font-size: var(--text-xs);
color: var(--color-gray-500);
margin: 0;
text-align: center;
word-break: break-all;
}
.video-preview__caption {
padding: 0.75rem;
font-size: var(--text-sm);
color: var(--color-gray-600);
font-style: italic;
background: var(--color-background);
margin: 0;
}
.video-preview__empty {
padding: 3rem 1rem;
text-align: center;
color: var(--color-gray-500);
font-size: var(--text-sm);
background: var(--color-gray-100);
}
.video-preview:hover {
border-color: var(--color-gray-400);
}
</style>

View file

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