Add markdown editor toolbar and data year display on import page

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>
This commit is contained in:
Yvv
2026-02-21 18:05:18 +01:00
parent 1365f4c86c
commit 2af95ebcf1
6 changed files with 564 additions and 29 deletions

View File

@@ -81,11 +81,46 @@
<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('- ')">&#8226; 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">&#8212;</button>
</div>
</div>
<textarea
ref="textareaRef"
v-model="editing.body_markdown"
class="form-input content-textarea"
rows="15"
rows="20"
placeholder="Redigez votre contenu en Markdown..."
@keydown="onKeydown"
></textarea>
</div>
@@ -142,6 +177,7 @@ 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()
@@ -217,23 +253,201 @@ async function doDelete() {
}
}
// ── 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 = `![${alt}](url)`
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>'
// Simple markdown rendering (headings, bold, italic, links, paragraphs, lists)
return md
let html = md
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
.replace(/\n\n/g, '</p><p>')
.replace(/^(?!<[hulo])(.+)$/gm, '<p>$1</p>')
// 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(/^&gt; (.+)$/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>
@@ -259,11 +473,56 @@ function renderMarkdown(md: string): string {
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 {
@@ -279,8 +538,33 @@ function renderMarkdown(md: string): string {
.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 { margin: 0.5rem 0; padding-left: 1.5rem; }
.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);