CMS editor: formatting toolbar with H1-H3, bold, italic, strikethrough, links, images, lists, blockquotes, code blocks, horizontal rules. Keyboard shortcuts (Ctrl+B/I/D/K). Improved markdown preview rendering. Import page: shows current data summary with year badge, stats grid, last import date. Year input for new imports. Preview with sample table. Backend: added data_year and data_imported_at fields to TariffParams, returned in stats endpoint. Import sets data_imported_at automatically. Seed sets data_year=2018. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
602 lines
18 KiB
Vue
602 lines
18 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<NuxtLink :to="`/admin/communes/${slug}`" style="color: var(--color-text-muted);">← {{ slug }}</NuxtLink>
|
|
<h1>Contenu CMS</h1>
|
|
</div>
|
|
|
|
<div v-if="error" class="alert alert-error">{{ error }}</div>
|
|
<div v-if="success" class="alert alert-success">{{ success }}</div>
|
|
|
|
<!-- Create new page -->
|
|
<div class="card" style="margin-bottom: 1.5rem;">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
|
<h3>Pages de contenu</h3>
|
|
<button class="btn btn-primary" @click="showCreate = !showCreate">
|
|
{{ showCreate ? 'Annuler' : 'Nouvelle page' }}
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="showCreate" style="margin-bottom: 1rem; padding: 1rem; background: var(--color-bg); border-radius: var(--radius);">
|
|
<div class="grid grid-2">
|
|
<div class="form-group">
|
|
<label>Slug (identifiant URL)</label>
|
|
<input v-model="newSlug" class="form-input" placeholder="ex: presentation" pattern="[a-z0-9-]+" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Titre</label>
|
|
<input v-model="newTitle" class="form-input" placeholder="ex: Presentation de la commune" />
|
|
</div>
|
|
</div>
|
|
<button class="btn btn-primary" @click="createPage" :disabled="!newSlug || !newTitle">
|
|
Creer la page
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Pages list -->
|
|
<div v-if="loading" style="text-align: center; padding: 1rem;">
|
|
<div class="spinner" style="margin: 0 auto;"></div>
|
|
</div>
|
|
|
|
<div v-else-if="pages.length === 0" class="alert alert-info">
|
|
Aucune page de contenu. Cliquez sur "Nouvelle page" pour en creer une.
|
|
</div>
|
|
|
|
<div v-else>
|
|
<div
|
|
v-for="page in pages" :key="page.slug"
|
|
class="page-item"
|
|
:class="{ active: editing?.slug === page.slug }"
|
|
@click="startEdit(page)"
|
|
>
|
|
<div>
|
|
<strong>{{ page.title }}</strong>
|
|
<span style="color: var(--color-text-muted); font-size: 0.8rem; margin-left: 0.5rem;">
|
|
/{{ page.slug }}
|
|
</span>
|
|
</div>
|
|
<div style="font-size: 0.75rem; color: var(--color-text-muted);">
|
|
{{ new Date(page.updated_at).toLocaleDateString('fr-FR') }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Editor -->
|
|
<div v-if="editing" class="card">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
|
<h3>{{ editing.title }} <span style="color: var(--color-text-muted); font-size: 0.8rem;">/{{ editing.slug }}</span></h3>
|
|
<div style="display: flex; gap: 0.5rem;">
|
|
<button class="btn btn-secondary" @click="previewMode = !previewMode">
|
|
{{ previewMode ? 'Editer' : 'Apercu' }}
|
|
</button>
|
|
<button class="btn btn-danger btn-sm" @click="confirmDelete">Supprimer</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Titre</label>
|
|
<input v-model="editing.title" class="form-input" />
|
|
</div>
|
|
|
|
<div v-if="!previewMode" class="form-group">
|
|
<label>Contenu (Markdown)</label>
|
|
|
|
<!-- Toolbar -->
|
|
<div class="md-toolbar">
|
|
<div class="md-toolbar-group">
|
|
<button type="button" class="md-btn" title="Titre 1" @click="insertBlock('# ')">H1</button>
|
|
<button type="button" class="md-btn" title="Titre 2" @click="insertBlock('## ')">H2</button>
|
|
<button type="button" class="md-btn" title="Titre 3" @click="insertBlock('### ')">H3</button>
|
|
</div>
|
|
<div class="md-toolbar-sep"></div>
|
|
<div class="md-toolbar-group">
|
|
<button type="button" class="md-btn" title="Gras (Ctrl+B)" @click="wrapSelection('**')"><b>G</b></button>
|
|
<button type="button" class="md-btn" title="Italique (Ctrl+I)" @click="wrapSelection('*')"><i>I</i></button>
|
|
<button type="button" class="md-btn" title="Barre (Ctrl+D)" @click="wrapSelection('~~')"><s>S</s></button>
|
|
</div>
|
|
<div class="md-toolbar-sep"></div>
|
|
<div class="md-toolbar-group">
|
|
<button type="button" class="md-btn" title="Lien" @click="insertLink">Lien</button>
|
|
<button type="button" class="md-btn" title="Image" @click="insertImage">Image</button>
|
|
</div>
|
|
<div class="md-toolbar-sep"></div>
|
|
<div class="md-toolbar-group">
|
|
<button type="button" class="md-btn" title="Liste a puces" @click="insertBlock('- ')">• Liste</button>
|
|
<button type="button" class="md-btn" title="Liste numerotee" @click="insertBlock('1. ')">1. Liste</button>
|
|
<button type="button" class="md-btn" title="Citation" @click="insertBlock('> ')">Citation</button>
|
|
</div>
|
|
<div class="md-toolbar-sep"></div>
|
|
<div class="md-toolbar-group">
|
|
<button type="button" class="md-btn" title="Code inline" @click="wrapSelection('`')">Code</button>
|
|
<button type="button" class="md-btn" title="Bloc de code" @click="insertCodeBlock">```</button>
|
|
<button type="button" class="md-btn" title="Ligne horizontale" @click="insertHR">—</button>
|
|
</div>
|
|
</div>
|
|
|
|
<textarea
|
|
ref="textareaRef"
|
|
v-model="editing.body_markdown"
|
|
class="form-input content-textarea"
|
|
rows="20"
|
|
placeholder="Redigez votre contenu en Markdown..."
|
|
@keydown="onKeydown"
|
|
></textarea>
|
|
</div>
|
|
|
|
<div v-else class="preview-box">
|
|
<div v-html="renderMarkdown(editing.body_markdown)"></div>
|
|
</div>
|
|
|
|
<div style="display: flex; gap: 0.5rem; margin-top: 1rem;">
|
|
<button class="btn btn-primary" @click="savePage" :disabled="saving">
|
|
{{ saving ? 'Enregistrement...' : 'Enregistrer' }}
|
|
</button>
|
|
<button class="btn btn-secondary" @click="editing = null">Fermer</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Delete confirmation modal -->
|
|
<div v-if="deleting" class="modal-overlay" @click.self="deleting = false">
|
|
<div class="card modal-content">
|
|
<h3>Supprimer cette page ?</h3>
|
|
<p style="margin: 1rem 0; color: var(--color-text-muted);">
|
|
Supprimer la page <strong>{{ editing?.title }}</strong> est irreversible.
|
|
</p>
|
|
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
|
|
<button class="btn btn-secondary" @click="deleting = false">Annuler</button>
|
|
<button class="btn btn-danger" @click="doDelete">Confirmer</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
definePageMeta({ middleware: 'admin' })
|
|
|
|
const route = useRoute()
|
|
const api = useApi()
|
|
const slug = route.params.slug as string
|
|
|
|
interface ContentPage {
|
|
slug: string
|
|
title: string
|
|
body_markdown: string
|
|
updated_at: string
|
|
}
|
|
|
|
const pages = ref<ContentPage[]>([])
|
|
const loading = ref(true)
|
|
const error = ref('')
|
|
const success = ref('')
|
|
const showCreate = ref(false)
|
|
const newSlug = ref('')
|
|
const newTitle = ref('')
|
|
const editing = ref<ContentPage | null>(null)
|
|
const previewMode = ref(false)
|
|
const saving = ref(false)
|
|
const deleting = ref(false)
|
|
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
|
|
|
onMounted(async () => {
|
|
await loadPages()
|
|
})
|
|
|
|
async function loadPages() {
|
|
loading.value = true
|
|
error.value = ''
|
|
try {
|
|
pages.value = await api.get<ContentPage[]>(`/communes/${slug}/content`)
|
|
} catch (e: any) {
|
|
error.value = e.message
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function createPage() {
|
|
error.value = ''; success.value = ''
|
|
try {
|
|
const page = await api.put<ContentPage>(
|
|
`/communes/${slug}/content/${newSlug.value}`,
|
|
{ title: newTitle.value, body_markdown: '' },
|
|
)
|
|
showCreate.value = false
|
|
newSlug.value = ''; newTitle.value = ''
|
|
await loadPages()
|
|
startEdit(page)
|
|
success.value = 'Page creee'
|
|
} catch (e: any) {
|
|
error.value = e.message
|
|
}
|
|
}
|
|
|
|
function startEdit(page: ContentPage) {
|
|
editing.value = { ...page }
|
|
previewMode.value = false
|
|
}
|
|
|
|
async function savePage() {
|
|
if (!editing.value) return
|
|
saving.value = true
|
|
error.value = ''; success.value = ''
|
|
try {
|
|
await api.put(
|
|
`/communes/${slug}/content/${editing.value.slug}`,
|
|
{ title: editing.value.title, body_markdown: editing.value.body_markdown },
|
|
)
|
|
success.value = 'Page enregistree'
|
|
await loadPages()
|
|
} catch (e: any) {
|
|
error.value = e.message
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
function confirmDelete() {
|
|
deleting.value = true
|
|
}
|
|
|
|
async function doDelete() {
|
|
if (!editing.value) return
|
|
error.value = ''; success.value = ''
|
|
try {
|
|
await api.delete(`/communes/${slug}/content/${editing.value.slug}`)
|
|
success.value = 'Page supprimee'
|
|
editing.value = null
|
|
deleting.value = false
|
|
await loadPages()
|
|
} catch (e: any) {
|
|
error.value = e.message
|
|
}
|
|
}
|
|
|
|
// ── Markdown toolbar helpers ──
|
|
|
|
function getTextarea(): HTMLTextAreaElement | null {
|
|
return textareaRef.value
|
|
}
|
|
|
|
function wrapSelection(marker: string) {
|
|
const ta = getTextarea()
|
|
if (!ta || !editing.value) return
|
|
const start = ta.selectionStart
|
|
const end = ta.selectionEnd
|
|
const text = editing.value.body_markdown
|
|
const selected = text.substring(start, end)
|
|
const placeholder = selected || 'texte'
|
|
const replacement = `${marker}${placeholder}${marker}`
|
|
|
|
editing.value.body_markdown = text.substring(0, start) + replacement + text.substring(end)
|
|
|
|
nextTick(() => {
|
|
ta.focus()
|
|
if (selected) {
|
|
ta.selectionStart = start
|
|
ta.selectionEnd = start + replacement.length
|
|
} else {
|
|
ta.selectionStart = start + marker.length
|
|
ta.selectionEnd = start + marker.length + placeholder.length
|
|
}
|
|
})
|
|
}
|
|
|
|
function insertBlock(prefix: string) {
|
|
const ta = getTextarea()
|
|
if (!ta || !editing.value) return
|
|
const start = ta.selectionStart
|
|
const text = editing.value.body_markdown
|
|
|
|
// Find the start of the current line
|
|
const lineStart = text.lastIndexOf('\n', start - 1) + 1
|
|
const before = text.substring(0, lineStart)
|
|
const after = text.substring(lineStart)
|
|
|
|
editing.value.body_markdown = before + prefix + after
|
|
|
|
nextTick(() => {
|
|
ta.focus()
|
|
ta.selectionStart = ta.selectionEnd = lineStart + prefix.length
|
|
})
|
|
}
|
|
|
|
function insertLink() {
|
|
const ta = getTextarea()
|
|
if (!ta || !editing.value) return
|
|
const start = ta.selectionStart
|
|
const end = ta.selectionEnd
|
|
const text = editing.value.body_markdown
|
|
const selected = text.substring(start, end)
|
|
const label = selected || 'titre du lien'
|
|
const replacement = `[${label}](url)`
|
|
|
|
editing.value.body_markdown = text.substring(0, start) + replacement + text.substring(end)
|
|
|
|
nextTick(() => {
|
|
ta.focus()
|
|
// Select "url" for quick editing
|
|
const urlStart = start + label.length + 3
|
|
ta.selectionStart = urlStart
|
|
ta.selectionEnd = urlStart + 3
|
|
})
|
|
}
|
|
|
|
function insertImage() {
|
|
const ta = getTextarea()
|
|
if (!ta || !editing.value) return
|
|
const start = ta.selectionStart
|
|
const end = ta.selectionEnd
|
|
const text = editing.value.body_markdown
|
|
const selected = text.substring(start, end)
|
|
const alt = selected || 'description'
|
|
const replacement = ``
|
|
|
|
editing.value.body_markdown = text.substring(0, start) + replacement + text.substring(end)
|
|
|
|
nextTick(() => {
|
|
ta.focus()
|
|
const urlStart = start + alt.length + 4
|
|
ta.selectionStart = urlStart
|
|
ta.selectionEnd = urlStart + 3
|
|
})
|
|
}
|
|
|
|
function insertCodeBlock() {
|
|
const ta = getTextarea()
|
|
if (!ta || !editing.value) return
|
|
const start = ta.selectionStart
|
|
const end = ta.selectionEnd
|
|
const text = editing.value.body_markdown
|
|
const selected = text.substring(start, end)
|
|
const code = selected || 'code'
|
|
const replacement = `\n\`\`\`\n${code}\n\`\`\`\n`
|
|
|
|
editing.value.body_markdown = text.substring(0, start) + replacement + text.substring(end)
|
|
|
|
nextTick(() => {
|
|
ta.focus()
|
|
ta.selectionStart = start + 4
|
|
ta.selectionEnd = start + 4 + code.length
|
|
})
|
|
}
|
|
|
|
function insertHR() {
|
|
const ta = getTextarea()
|
|
if (!ta || !editing.value) return
|
|
const start = ta.selectionStart
|
|
const text = editing.value.body_markdown
|
|
const replacement = '\n---\n'
|
|
|
|
editing.value.body_markdown = text.substring(0, start) + replacement + text.substring(start)
|
|
|
|
nextTick(() => {
|
|
ta.focus()
|
|
ta.selectionStart = ta.selectionEnd = start + replacement.length
|
|
})
|
|
}
|
|
|
|
function onKeydown(e: KeyboardEvent) {
|
|
if (!editing.value) return
|
|
// Ctrl+B = bold
|
|
if (e.ctrlKey && e.key === 'b') {
|
|
e.preventDefault()
|
|
wrapSelection('**')
|
|
}
|
|
// Ctrl+I = italic
|
|
if (e.ctrlKey && e.key === 'i') {
|
|
e.preventDefault()
|
|
wrapSelection('*')
|
|
}
|
|
// Ctrl+D = strikethrough
|
|
if (e.ctrlKey && e.key === 'd') {
|
|
e.preventDefault()
|
|
wrapSelection('~~')
|
|
}
|
|
// Ctrl+K = link
|
|
if (e.ctrlKey && e.key === 'k') {
|
|
e.preventDefault()
|
|
insertLink()
|
|
}
|
|
}
|
|
|
|
function renderMarkdown(md: string): string {
|
|
if (!md) return '<p style="color: var(--color-text-muted);">Aucun contenu.</p>'
|
|
let html = md
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
|
|
// Code blocks (before other transformations)
|
|
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) =>
|
|
`<pre><code>${code.trim()}</code></pre>`)
|
|
|
|
// Headings
|
|
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
|
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
|
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
|
|
|
// Horizontal rule
|
|
html = html.replace(/^---$/gm, '<hr />')
|
|
|
|
// Bold, italic, strikethrough, inline code
|
|
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
html = html.replace(/~~(.+?)~~/g, '<del>$1</del>')
|
|
html = html.replace(/`([^`]+)`/g, '<code class="inline">$1</code>')
|
|
|
|
// Images and links
|
|
html = html.replace(/!\[(.+?)\]\((.+?)\)/g, '<img src="$2" alt="$1" style="max-width:100%;" />')
|
|
html = html.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2" target="_blank">$1</a>')
|
|
|
|
// Blockquotes
|
|
html = html.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>')
|
|
|
|
// Unordered lists
|
|
html = html.replace(/^- (.+)$/gm, '<li>$1</li>')
|
|
// Ordered lists
|
|
html = html.replace(/^\d+\. (.+)$/gm, '<li class="ol">$1</li>')
|
|
|
|
// Wrap consecutive <li> in <ul> or <ol>
|
|
html = html.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>')
|
|
html = html.replace(/((?:<li class="ol">.*<\/li>\n?)+)/g, (_m, items) =>
|
|
'<ol>' + items.replace(/ class="ol"/g, '') + '</ol>')
|
|
|
|
// Paragraphs (lines not already wrapped)
|
|
html = html.replace(/\n\n/g, '</p><p>')
|
|
html = html.replace(/^(?!<[hupodbia\-lr/])(.+)$/gm, '<p>$1</p>')
|
|
|
|
return html
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.page-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 0.75rem;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius);
|
|
margin-bottom: 0.5rem;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.page-item:hover {
|
|
background: var(--color-bg);
|
|
}
|
|
|
|
.page-item.active {
|
|
border-color: var(--color-primary);
|
|
background: #eff6ff;
|
|
}
|
|
|
|
/* Markdown toolbar */
|
|
.md-toolbar {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 2px;
|
|
padding: 0.4rem;
|
|
background: var(--color-bg);
|
|
border: 1px solid var(--color-border);
|
|
border-bottom: none;
|
|
border-radius: var(--radius) var(--radius) 0 0;
|
|
}
|
|
|
|
.md-toolbar-group {
|
|
display: flex;
|
|
gap: 2px;
|
|
}
|
|
|
|
.md-toolbar-sep {
|
|
width: 1px;
|
|
background: var(--color-border);
|
|
margin: 0 4px;
|
|
}
|
|
|
|
.md-btn {
|
|
padding: 0.25rem 0.5rem;
|
|
font-size: 0.8rem;
|
|
background: transparent;
|
|
border: 1px solid transparent;
|
|
border-radius: 3px;
|
|
cursor: pointer;
|
|
color: var(--color-text);
|
|
white-space: nowrap;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.md-btn:hover {
|
|
background: var(--color-surface, #e5e7eb);
|
|
border-color: var(--color-border);
|
|
}
|
|
|
|
.md-btn:active {
|
|
background: var(--color-border);
|
|
}
|
|
|
|
.content-textarea {
|
|
font-family: 'Fira Code', 'Consolas', monospace;
|
|
font-size: 0.85rem;
|
|
line-height: 1.6;
|
|
resize: vertical;
|
|
border-radius: 0 0 var(--radius) var(--radius);
|
|
}
|
|
|
|
.preview-box {
|
|
padding: 1rem;
|
|
background: var(--color-bg);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius);
|
|
min-height: 200px;
|
|
line-height: 1.7;
|
|
}
|
|
|
|
.preview-box h1 { font-size: 1.5rem; margin: 1rem 0 0.5rem; }
|
|
.preview-box h2 { font-size: 1.25rem; margin: 0.75rem 0 0.5rem; }
|
|
.preview-box h3 { font-size: 1.1rem; margin: 0.5rem 0 0.25rem; }
|
|
.preview-box p { margin: 0.5rem 0; }
|
|
.preview-box ul, .preview-box ol { margin: 0.5rem 0; padding-left: 1.5rem; }
|
|
.preview-box a { color: var(--color-primary); }
|
|
.preview-box hr { border: none; border-top: 1px solid var(--color-border); margin: 1rem 0; }
|
|
.preview-box blockquote {
|
|
border-left: 3px solid var(--color-primary);
|
|
padding: 0.25rem 0.75rem;
|
|
margin: 0.5rem 0;
|
|
color: var(--color-text-muted);
|
|
background: rgba(0,0,0,0.02);
|
|
}
|
|
.preview-box pre {
|
|
background: #1e293b;
|
|
color: #e2e8f0;
|
|
padding: 0.75rem 1rem;
|
|
border-radius: var(--radius);
|
|
overflow-x: auto;
|
|
font-size: 0.85rem;
|
|
margin: 0.5rem 0;
|
|
}
|
|
.preview-box code.inline {
|
|
background: rgba(0,0,0,0.06);
|
|
padding: 0.1rem 0.35rem;
|
|
border-radius: 3px;
|
|
font-size: 0.85em;
|
|
}
|
|
.preview-box img { border-radius: var(--radius); margin: 0.5rem 0; }
|
|
.preview-box del { text-decoration: line-through; color: var(--color-text-muted); }
|
|
|
|
.btn-danger {
|
|
background: var(--color-danger);
|
|
color: white;
|
|
}
|
|
|
|
.btn-sm {
|
|
padding: 0.25rem 0.75rem;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.alert-success {
|
|
background: #dcfce7;
|
|
color: #166534;
|
|
padding: 0.75rem 1rem;
|
|
border-radius: var(--radius);
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.modal-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 100;
|
|
}
|
|
|
|
.modal-content {
|
|
max-width: 480px;
|
|
width: 90%;
|
|
}
|
|
</style>
|