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

@ -7,6 +7,9 @@ columns:
fields:
type: fields
fields:
blueprint:
type: headline
label: Page "{{ page.intendedTemplate }}"
author:
label: Auteur·ice(s)
type: text
@ -31,6 +34,3 @@ columns:
files:
label: Fichiers
type: files

View file

@ -0,0 +1,37 @@
<?php
/**
* Virtual Print Page Plugin
*
* Crée une page virtuelle /print pour chaque récit
* Permet d'accéder à l'éditeur d'impression via /projet/recit/print
*/
use Kirby\Cms\Page;
use Kirby\Uuid\Uuid;
Kirby::plugin('geoproject/virtual-print-page', [
'routes' => [
[
'pattern' => '(:all)/print',
'action' => function ($parentPath) {
// Trouver la page parente (le récit)
$parent = page($parentPath);
if (!$parent || $parent->intendedTemplate()->name() !== 'recit') {
return $this->next();
}
// Créer la page virtuelle avec Page::factory()
return Page::factory([
'slug' => 'print',
'template' => 'print',
'parent' => $parent,
'content' => [
'title' => 'Impression - ' . $parent->title()->value(),
'uuid' => Uuid::generate()
]
]);
}
]
]
]);

View file

@ -26,5 +26,5 @@
<?php endif ?>
</head>
<body data-template="<?= $page->template() ?>">
<body data-template="<?= $page->template() ?>"<?php if (isset($recitJsonUrl)): ?> data-recit-url="<?= $recitJsonUrl ?>"<?php endif ?>>
<div id="app">

View file

@ -0,0 +1,17 @@
<?php
/**
* Template pour l'éditeur d'impression Vue.js
* Route: /projet/recit/print
*
* Ce template charge l'app Vue et lui passe l'URL JSON du récit parent
*/
// Récupérer le récit parent
$recit = $page->parent();
// Construire l'URL JSON du récit
$recitJsonUrl = $recit->url() . '.json';
?>
<?php snippet('header', ['recitJsonUrl' => $recitJsonUrl]) ?>
<?php snippet('footer') ?>

View file

@ -0,0 +1,202 @@
<?php
/**
* Template JSON pour exposer les données d'un récit
* Accessible via /projet/recit.json ou /projet/recit?format=json
*/
header('Content-Type: application/json; charset=utf-8');
/**
* Résout les références de fichiers Kirby en URLs absolues
*/
function resolveFileUrl($field, $page) {
if (!$field || $field->isEmpty()) {
return null;
}
$file = $field->toFile();
if ($file) {
return $file->url();
}
return null;
}
/**
* Résout les images dans le contenu HTML
*/
function resolveImagesInHtml($html, $page) {
if (!$html) return $html;
// Remplacer les références file:// par les URLs réelles
return preg_replace_callback(
'/file:\/\/([a-z0-9]+)/i',
function($matches) use ($page) {
$uuid = $matches[1];
$file = $page->file("file://{$uuid}");
if (!$file) {
// Chercher dans les fichiers du site
$file = site()->file("file://{$uuid}");
}
return $file ? $file->url() : $matches[0];
},
$html
);
}
/**
* Parse les blocks Kirby en structure JSON
*/
function parseBlocks($blocksField, $page) {
if (!$blocksField || $blocksField->isEmpty()) {
return [];
}
$blocks = [];
foreach ($blocksField->toBlocks() as $block) {
$blockData = [
'id' => $block->id(),
'type' => $block->type(),
'isHidden' => $block->isHidden(),
];
switch ($block->type()) {
case 'text':
$blockData['content'] = [
'text' => resolveImagesInHtml($block->text()->value(), $page)
];
break;
case 'heading':
$blockData['content'] = [
'level' => $block->level()->value() ?? 'h2',
'text' => $block->text()->value()
];
break;
case 'image':
$image = $block->image()->toFile();
$blockData['content'] = [
'url' => $image ? $image->url() : null,
'alt' => $block->alt()->value() ?? '',
'caption' => $block->caption()->value() ?? '',
'width' => $block->width()->value() ?? '100%',
'position' => $block->position()->value() ?? 'auto'
];
break;
case 'list':
$blockData['content'] = [
'text' => $block->text()->value()
];
break;
case 'quote':
$blockData['content'] = [
'text' => $block->text()->value(),
'citation' => $block->citation()->value() ?? ''
];
break;
case 'video':
$blockData['content'] = [
'url' => $block->url()->value(),
'caption' => $block->caption()->value() ?? ''
];
break;
case 'map':
$blockData['content'] = [
'map' => $block->map()->value()
];
break;
default:
$blockData['content'] = $block->content()->toArray();
}
$blocks[] = $blockData;
}
return $blocks;
}
/**
* Parse un chapitre
*/
function parseChapitre($chapitre) {
return [
'id' => $chapitre->id(),
'uuid' => $chapitre->uuid()->toString(),
'template' => 'chapitre',
'title' => $chapitre->title()->value(),
'slug' => $chapitre->slug(),
'blocks' => parseBlocks($chapitre->text(), $chapitre)
];
}
/**
* Parse une carte
*/
function parseCarte($carte) {
return [
'id' => $carte->id(),
'uuid' => $carte->uuid()->toString(),
'template' => 'carte',
'title' => $carte->title()->value(),
'slug' => $carte->slug(),
'tags' => $carte->tags()->isNotEmpty() ? $carte->tags()->split() : [],
'text' => resolveImagesInHtml($carte->text()->value(), $carte)
];
}
/**
* Parse un geoformat
*/
function parseGeoformat($geoformat) {
$chapitres = [];
foreach ($geoformat->children()->listed() as $child) {
if ($child->intendedTemplate()->name() === 'chapitre') {
$chapitres[] = parseChapitre($child);
}
}
return [
'id' => $geoformat->id(),
'uuid' => $geoformat->uuid()->toString(),
'template' => 'geoformat',
'title' => $geoformat->title()->value(),
'slug' => $geoformat->slug(),
'subtitle' => $geoformat->subtitle()->value() ?? '',
'tags' => $geoformat->tags()->isNotEmpty() ? $geoformat->tags()->split() : [],
'cover' => resolveFileUrl($geoformat->cover(), $geoformat),
'text' => resolveImagesInHtml($geoformat->text()->value(), $geoformat),
'children' => $chapitres
];
}
// Construction de la réponse JSON
$data = [
'id' => $page->id(),
'uuid' => $page->uuid()->toString(),
'template' => 'recit',
'title' => $page->title()->value(),
'slug' => $page->slug(),
'author' => $page->author()->value() ?? '',
'cover' => resolveFileUrl($page->cover(), $page),
'introduction' => resolveImagesInHtml($page->introduction()->value(), $page),
'children' => []
];
// Parser les enfants (geoformats et cartes)
foreach ($page->children()->listed() as $child) {
$template = $child->intendedTemplate()->name();
if ($template === 'geoformat') {
$data['children'][] = parseGeoformat($child);
} elseif ($template === 'carte') {
$data['children'][] = parseCarte($child);
}
}
echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

View file

@ -0,0 +1,33 @@
<?php
/**
* Template pour afficher un récit
* Ce template est requis pour que recit.json.php fonctionne
*/
?>
<?php snippet('header') ?>
<article class="recit">
<h1><?= $page->title() ?></h1>
<?php if ($page->author()->isNotEmpty()): ?>
<p class="author"><?= $page->author() ?></p>
<?php endif ?>
<?php if ($page->cover()->isNotEmpty()): ?>
<figure class="cover">
<?php if ($cover = $page->cover()->toFile()): ?>
<img src="<?= $cover->url() ?>" alt="">
<?php endif ?>
</figure>
<?php endif ?>
<?php if ($page->introduction()->isNotEmpty()): ?>
<div class="introduction">
<?= $page->introduction() ?>
</div>
<?php endif ?>
<p><a href="<?= $page->url() ?>/print">Ouvrir l'éditeur d'impression</a></p>
</article>
<?php snippet('footer') ?>

View file

@ -6,9 +6,14 @@ import PagePopup from './components/PagePopup.vue';
import PreviewLoader from './components/PreviewLoader.vue';
import { onMounted, ref, watch, computed, provide } from 'vue';
import { useStylesheetStore } from './stores/stylesheet';
import { useRecitStore } from './stores/recit';
import Coloris from '@melloware/coloris';
const stylesheetStore = useStylesheetStore();
const recitStore = useRecitStore();
// Get recit URL from body data attribute (set by print.php template)
const recitUrl = document.body.dataset.recitUrl || null;
const previewFrame1 = ref(null);
const previewFrame2 = ref(null);
const elementPopup = ref(null);
@ -500,6 +505,16 @@ watch(
}
);
// Re-render when recit data changes
watch(
() => recitStore.data,
() => {
if (recitStore.data) {
renderPreview();
}
}
);
// Print the PagedJS content
const printPreview = async () => {
const frame = activeFrame.value;
@ -559,7 +574,15 @@ const printPreview = async () => {
}, 100);
};
onMounted(() => renderPreview(true));
onMounted(async () => {
// Load recit data if URL is provided (print mode)
if (recitUrl) {
await recitStore.loadRecit(recitUrl);
}
// Render preview after data is loaded
renderPreview(true);
});
</script>
<template>

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'
};

127
src/stores/recit.js Normal file
View file

@ -0,0 +1,127 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useRecitStore = defineStore('recit', () => {
const data = ref(null);
const loading = ref(false);
const error = ref(null);
// Computed properties
const title = computed(() => data.value?.title ?? '');
const author = computed(() => data.value?.author ?? '');
const cover = computed(() => data.value?.cover ?? null);
const introduction = computed(() => data.value?.introduction ?? '');
const children = computed(() => data.value?.children ?? []);
// Flatten all content for PagedJS rendering
const flattenedContent = computed(() => {
if (!data.value) return [];
const items = [];
// Add recit intro as first section
items.push({
id: data.value.id,
template: 'recit',
title: data.value.title,
author: data.value.author,
cover: data.value.cover,
introduction: data.value.introduction
});
// Recursively flatten children
const flattenChildren = (children, depth = 0) => {
for (const child of children) {
if (child.template === 'geoformat') {
// Add geoformat header
items.push({
id: child.id,
template: 'geoformat',
title: child.title,
subtitle: child.subtitle,
tags: child.tags,
cover: child.cover,
text: child.text
});
// Add geoformat chapters
if (child.children && child.children.length > 0) {
flattenChildren(child.children, depth + 1);
}
} else if (child.template === 'chapitre') {
items.push({
id: child.id,
template: 'chapitre',
title: child.title,
blocks: child.blocks
});
} else if (child.template === 'carte') {
items.push({
id: child.id,
template: 'carte',
title: child.title,
tags: child.tags,
text: child.text
});
}
}
};
flattenChildren(data.value.children);
return items;
});
// Load recit data from URL
const loadRecit = async (url) => {
if (!url) {
error.value = 'No recit URL provided';
return;
}
loading.value = true;
error.value = null;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
data.value = await response.json();
} catch (e) {
console.error('Error loading recit:', e);
error.value = e.message;
data.value = null;
} finally {
loading.value = false;
}
};
// Reset store
const reset = () => {
data.value = null;
loading.value = false;
error.value = null;
};
return {
// State
data,
loading,
error,
// Computed
title,
author,
cover,
introduction,
children,
flattenedContent,
// Actions
loadRecit,
reset
};
});