feat: integrate Kirby CMS data with Vue print editor

- Add JSON content representation template (recit.json.php)
- Create virtual /print page plugin for recit pages
- Add recit.php base template for content representation
- Create Pinia store for recit data management
- Add block components (text, heading, image, list, quote, video, map)
- Update PagedJsWrapper for dynamic content rendering with data-page-type
- Modify header.php to pass recit JSON URL via data attribute
- Update App.vue to load recit data on mount

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
isUnknown 2025-12-08 18:01:01 +01:00
parent 446b6cd9e7
commit 790eb7414e
17 changed files with 807 additions and 56 deletions

View file

@ -1,54 +1,194 @@
<template>
<section class="chapter">
<p>
Accumsan arcu tristique purus eros pellentesque rutrum hendrerit phasellus
euismod maximus rutrum vivamus dolor erat sollicitudin ut quam metus
gravida proin nisl lacus sed lacus. Morbi fusce eros euismod varius ex
ipsum erat lacus arcu nunc cursus a scelerisque tristique ipsum congue
adipiscing suspendisse facilisis dolor morbi nulla orci massa. Vivamus nec
nisl amet eros consectetur ut consectetur phasellus maecenas morbi felis
pellentesque pellentesque ipsum ut a arcu sem facilisis eros tempus eu
euismod sollicitudin. Nisl facilisis tempus tempus placerat lorem sed leo
sit a leo tempus amet tristique felis gravida morbi congue aliquam nunc
maximus ipsum ex nisl a. Leo felis leo gravida fusce lacus orci
condimentum morbi eros amet portaest sit quam a hendrerit fusce quam
tristique arcu id maximus nunc fusce suspendisse.
</p>
<p>
Accumsan arcu tristique purus eros pellentesque rutrum hendrerit phasellus
euismod maximus rutrum vivamus dolor erat sollicitudin ut quam metus
gravida proin nisl lacus sed lacus. Morbi fusce eros euismod varius ex
ipsum erat lacus arcu nunc cursus a scelerisque tristique ipsum congue
adipiscing suspendisse facilisis dolor morbi nulla orci massa. Vivamus nec
nisl amet eros consectetur ut consectetur phasellus maecenas morbi felis
pellentesque pellentesque ipsum ut a arcu sem facilisis eros tempus eu
euismod sollicitudin. Nisl facilisis tempus tempus placerat lorem sed leo
sit a leo tempus amet tristique felis gravida morbi congue aliquam nunc
maximus ipsum ex nisl a. Leo felis leo gravida fusce lacus orci
condimentum morbi eros amet portaest sit quam a hendrerit fusce quam
tristique arcu id maximus nunc fusce suspendisse.
</p>
<p>
Lorem ipsum dolor sit amet consectetur adipiscing elit. Duis nibh tortor
</p>
</section>
<!-- Fallback static content when no recit data -->
<template v-if="!hasRecitData">
<section class="chapter">
<p>
Accumsan arcu tristique purus eros pellentesque rutrum hendrerit
phasellus euismod maximus rutrum vivamus dolor erat sollicitudin ut quam
metus gravida proin nisl lacus sed lacus.
</p>
</section>
</template>
<section class="chapter">
<h2>Chapter 1</h2>
<p>Lorem ipsum dolor sit amet</p>
</section>
<!-- Dynamic content from recit -->
<template v-else>
<template v-for="item in flattenedContent" :key="item.id">
<!-- Récit (cover page) -->
<section
v-if="item.template === 'recit'"
class="recit-cover"
:data-page-type="item.template"
>
<img v-if="item.cover" :src="item.cover" class="cover-image" alt="" />
<h1>{{ item.title }}</h1>
<p v-if="item.author" class="author">{{ item.author }}</p>
<div v-if="item.introduction" class="introduction" v-html="item.introduction"></div>
</section>
<section class="chapter">
<h2 id="chapter-2">Chapter 2</h2>
<p>consectetur adipiscing elit</p>
</section>
<!-- Geoformat -->
<section
v-else-if="item.template === 'geoformat'"
class="geoformat"
:data-page-type="item.template"
>
<img v-if="item.cover" :src="item.cover" class="cover-image" alt="" />
<h2>{{ item.title }}</h2>
<p v-if="item.subtitle" class="subtitle">{{ item.subtitle }}</p>
<div v-if="item.tags && item.tags.length" class="tags">
<span v-for="tag in item.tags" :key="tag" class="tag">{{ tag }}</span>
</div>
<div v-if="item.text" class="chapeau" v-html="item.text"></div>
</section>
<section class="chapter">
<h2>Chapter 3</h2>
<p>Duis nibh tortor, pellentesque eu suscipit vel</p>
</section>
<!-- Chapitre -->
<section
v-else-if="item.template === 'chapitre'"
class="chapitre"
:data-page-type="item.template"
>
<h3>{{ item.title }}</h3>
<template v-if="item.blocks">
<component
v-for="block in visibleBlocks(item.blocks)"
:key="block.id"
:is="getBlockComponent(block.type)"
:content="block.content"
/>
</template>
</section>
<!-- Carte -->
<section
v-else-if="item.template === 'carte'"
class="carte"
:data-page-type="item.template"
>
<h2>{{ item.title }}</h2>
<div v-if="item.tags && item.tags.length" class="tags">
<span v-for="tag in item.tags" :key="tag" class="tag">{{ tag }}</span>
</div>
<div v-if="item.text" class="carte-content" v-html="item.text"></div>
</section>
</template>
</template>
</template>
<script setup></script>
<script setup>
import { computed } from 'vue';
import { useRecitStore } from '../stores/recit';
import {
TextBlock,
HeadingBlock,
ImageBlock,
ListBlock,
QuoteBlock,
VideoBlock,
MapBlock,
blockComponents
} from './blocks';
<style></style>
const recitStore = useRecitStore();
const hasRecitData = computed(() => recitStore.data !== null);
const flattenedContent = computed(() => recitStore.flattenedContent);
// Filter out hidden blocks
const visibleBlocks = (blocks) => {
return blocks.filter((block) => !block.isHidden);
};
// Get the component for a block type
const getBlockComponent = (type) => {
const components = {
text: TextBlock,
heading: HeadingBlock,
image: ImageBlock,
list: ListBlock,
quote: QuoteBlock,
video: VideoBlock,
map: MapBlock
};
return components[type] || TextBlock;
};
</script>
<style>
/* Base print styles for content sections */
.recit-cover,
.geoformat,
.chapitre,
.carte {
break-before: page;
}
.recit-cover .cover-image,
.geoformat .cover-image {
max-width: 100%;
height: auto;
}
.recit-cover h1 {
margin-top: 1rem;
}
.recit-cover .author {
font-style: italic;
color: #666;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0.5rem 0;
}
.tag {
background: #f0f0f0;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
}
/* Block styles */
.block-image img {
max-width: 100%;
height: auto;
}
.block-quote {
border-left: 3px solid #ccc;
padding-left: 1rem;
margin-left: 0;
font-style: italic;
}
.block-quote cite {
display: block;
margin-top: 0.5rem;
font-size: 0.875rem;
color: #666;
}
.block-video .video-embed {
position: relative;
padding-bottom: 56.25%;
height: 0;
}
.block-video .video-embed iframe,
.block-video .video-embed video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.block-map {
background: #f5f5f5;
padding: 2rem;
text-align: center;
border: 1px dashed #ccc;
}
</style>

View file

@ -0,0 +1,23 @@
<template>
<component :is="tag" class="block-heading">{{ content.text }}</component>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
content: {
type: Object,
required: true
}
});
const tag = computed(() => {
const level = props.content.level || 'h2';
// Ensure valid heading level
if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(level)) {
return level;
}
return 'h2';
});
</script>

View file

@ -0,0 +1,40 @@
<template>
<figure
class="block-image"
:style="figureStyle"
>
<img
v-if="content.url"
:src="content.url"
:alt="content.alt || ''"
/>
<figcaption v-if="content.caption">{{ content.caption }}</figcaption>
</figure>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
content: {
type: Object,
required: true
}
});
const figureStyle = computed(() => {
const styles = {};
if (props.content.width && props.content.width !== '100%') {
styles.width = props.content.width;
}
if (props.content.position === 'center') {
styles.margin = '0 auto';
} else if (props.content.position === 'right') {
styles.marginLeft = 'auto';
}
return styles;
});
</script>

View file

@ -0,0 +1,12 @@
<template>
<div class="block-list" v-html="content.text"></div>
</template>
<script setup>
defineProps({
content: {
type: Object,
required: true
}
});
</script>

View file

@ -0,0 +1,14 @@
<template>
<div class="block-map">
<p class="map-placeholder">[Carte: {{ content.map }}]</p>
</div>
</template>
<script setup>
defineProps({
content: {
type: Object,
required: true
}
});
</script>

View file

@ -0,0 +1,15 @@
<template>
<blockquote class="block-quote">
<div v-html="content.text"></div>
<cite v-if="content.citation">{{ content.citation }}</cite>
</blockquote>
</template>
<script setup>
defineProps({
content: {
type: Object,
required: true
}
});
</script>

View file

@ -0,0 +1,12 @@
<template>
<div class="block-text" v-html="content.text"></div>
</template>
<script setup>
defineProps({
content: {
type: Object,
required: true
}
});
</script>

View file

@ -0,0 +1,38 @@
<template>
<figure class="block-video">
<div class="video-embed" v-html="embedHtml"></div>
<figcaption v-if="content.caption">{{ content.caption }}</figcaption>
</figure>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
content: {
type: Object,
required: true
}
});
// Convert video URL to embed iframe
const embedHtml = computed(() => {
const url = props.content.url;
if (!url) return '';
// YouTube
const youtubeMatch = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([^&]+)/);
if (youtubeMatch) {
return `<iframe src="https://www.youtube.com/embed/${youtubeMatch[1]}" frameborder="0" allowfullscreen></iframe>`;
}
// Vimeo
const vimeoMatch = url.match(/vimeo\.com\/(\d+)/);
if (vimeoMatch) {
return `<iframe src="https://player.vimeo.com/video/${vimeoMatch[1]}" frameborder="0" allowfullscreen></iframe>`;
}
// Fallback: direct video tag
return `<video src="${url}" controls></video>`;
});
</script>

View file

@ -0,0 +1,18 @@
export { default as TextBlock } from './TextBlock.vue';
export { default as HeadingBlock } from './HeadingBlock.vue';
export { default as ImageBlock } from './ImageBlock.vue';
export { default as ListBlock } from './ListBlock.vue';
export { default as QuoteBlock } from './QuoteBlock.vue';
export { default as VideoBlock } from './VideoBlock.vue';
export { default as MapBlock } from './MapBlock.vue';
// Block type to component mapping
export const blockComponents = {
text: 'TextBlock',
heading: 'HeadingBlock',
image: 'ImageBlock',
list: 'ListBlock',
quote: 'QuoteBlock',
video: 'VideoBlock',
map: 'MapBlock'
};