Initial commit: SejeteralO water tarification platform
Full-stack app for participatory water pricing using Bezier curves. - Backend: FastAPI + SQLAlchemy + SQLite with JWT auth - Frontend: Nuxt 4 + TypeScript with interactive SVG editor - Math engine: cubic Bezier tarification with Cardano solver - Admin: commune management, household import, vote monitoring, CMS - Citizen: interactive curve editor, vote submission - Docker-compose deployment ready Includes fixes for: - Impact table snake_case/camelCase property mismatch - CMS content backend API + frontend editor (was stub) - Admin route protection middleware - Public content display on commune page - Vote confirmation page link fix Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
317
frontend/app/pages/admin/communes/[slug]/content.vue
Normal file
317
frontend/app/pages/admin/communes/[slug]/content.vue
Normal file
@@ -0,0 +1,317 @@
|
||||
<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>
|
||||
<textarea
|
||||
v-model="editing.body_markdown"
|
||||
class="form-input content-textarea"
|
||||
rows="15"
|
||||
placeholder="Redigez votre contenu en Markdown..."
|
||||
></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)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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>')
|
||||
}
|
||||
</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;
|
||||
}
|
||||
|
||||
.content-textarea {
|
||||
font-family: 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.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 { margin: 0.5rem 0; padding-left: 1.5rem; }
|
||||
.preview-box a { color: var(--color-primary); }
|
||||
|
||||
.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>
|
||||
Reference in New Issue
Block a user