- Systeme de themes adaptatifs : Peps (light chaud), Zen (light calme), Chagrine (dark violet), Grave (dark ambre) avec CSS custom properties - Dashboard d'accueil orienté onboarding avec cartes-portes et teaser boite a outils - SectionLayout reutilisable : liste + sidebar toolbox + status pills cliquables (En prepa / En vote / En vigueur / Clos) - ToolboxVignette : vignettes Contexte / Tutos / Choisir / Demarrer - Seed : Acte engagement certification + forgeron, Runtime Upgrade (decision on-chain), 3 modalites de vote (majoritaire, quadratique, permanent) - Backend adapte SQLite (Uuid portable, 204 fix, pool conditionnel) - Correction noms composants (pathPrefix: false), pinia/nuxt ^0.11 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
444 lines
12 KiB
Vue
444 lines
12 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* Documents de reference — page index.
|
|
*
|
|
* Utilise SectionLayout avec status filters, recherche, tri,
|
|
* et sidebar "Boite a outils" affichant les protocoles de vote.
|
|
*/
|
|
import type { DocumentCreate } from '~/stores/documents'
|
|
|
|
const documents = useDocumentsStore()
|
|
const protocols = useProtocolsStore()
|
|
const auth = useAuthStore()
|
|
|
|
const activeStatus = ref<string | null>(null)
|
|
const searchQuery = ref('')
|
|
const sortBy = ref<'date' | 'title' | 'status'>('date')
|
|
|
|
// New document modal state
|
|
const showNewDocModal = ref(false)
|
|
const newDoc = ref<DocumentCreate>({
|
|
slug: '',
|
|
title: '',
|
|
doc_type: 'licence',
|
|
description: null,
|
|
version: '1.0.0',
|
|
})
|
|
const creating = ref(false)
|
|
|
|
const newDocTypeOptions = [
|
|
{ label: 'Licence', value: 'licence' },
|
|
{ label: 'Engagement', value: 'engagement' },
|
|
{ label: 'Reglement', value: 'reglement' },
|
|
{ label: 'Constitution', value: 'constitution' },
|
|
]
|
|
|
|
const sortOptions = [
|
|
{ label: 'Date', value: 'date' },
|
|
{ label: 'Titre', value: 'title' },
|
|
{ label: 'Statut', value: 'status' },
|
|
]
|
|
|
|
onMounted(async () => {
|
|
await Promise.all([
|
|
documents.fetchAll(),
|
|
protocols.fetchProtocols(),
|
|
])
|
|
})
|
|
|
|
/** Status filter pills with counts. */
|
|
const statuses = computed(() => [
|
|
{ id: 'draft', label: 'En prepa', count: documents.list.filter(d => d.status === 'draft').length },
|
|
{ id: 'voting', label: 'En vote', count: documents.list.filter(d => d.status === 'voting').length },
|
|
{ id: 'active', label: 'En vigueur', count: documents.list.filter(d => d.status === 'active').length },
|
|
{ id: 'archived', label: 'Clos', count: documents.list.filter(d => d.status === 'archived').length },
|
|
])
|
|
|
|
/** Filtered and sorted documents. */
|
|
const filteredDocuments = computed(() => {
|
|
let list = [...documents.list]
|
|
|
|
// Filter by status
|
|
if (activeStatus.value) {
|
|
list = list.filter(d => d.status === activeStatus.value)
|
|
}
|
|
|
|
// Filter by search query (client-side)
|
|
if (searchQuery.value.trim()) {
|
|
const q = searchQuery.value.toLowerCase()
|
|
list = list.filter(d => d.title.toLowerCase().includes(q))
|
|
}
|
|
|
|
// Sort
|
|
switch (sortBy.value) {
|
|
case 'title':
|
|
list.sort((a, b) => a.title.localeCompare(b.title, 'fr'))
|
|
break
|
|
case 'status':
|
|
list.sort((a, b) => a.status.localeCompare(b.status))
|
|
break
|
|
case 'date':
|
|
default:
|
|
list.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
|
|
break
|
|
}
|
|
|
|
return list
|
|
})
|
|
|
|
/** Toolbox vignettes from protocols. */
|
|
const toolboxTitle = 'Modalites de vote'
|
|
|
|
const typeLabel = (docType: string): string => {
|
|
switch (docType) {
|
|
case 'licence': return 'Licence'
|
|
case 'engagement': return 'Engagement'
|
|
case 'reglement': return 'Reglement'
|
|
case 'constitution': return 'Constitution'
|
|
default: return docType
|
|
}
|
|
}
|
|
|
|
function formatDate(dateStr: string): string {
|
|
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
})
|
|
}
|
|
|
|
function generateSlug(title: string): string {
|
|
return title
|
|
.toLowerCase()
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.replace(/[^a-z0-9\s-]/g, '')
|
|
.replace(/\s+/g, '-')
|
|
.replace(/-+/g, '-')
|
|
.slice(0, 64)
|
|
}
|
|
|
|
watch(() => newDoc.value.title, (title) => {
|
|
if (title) {
|
|
newDoc.value.slug = generateSlug(title)
|
|
}
|
|
})
|
|
|
|
function openNewDocModal() {
|
|
newDoc.value = {
|
|
slug: '',
|
|
title: '',
|
|
doc_type: 'licence',
|
|
description: null,
|
|
version: '1.0.0',
|
|
}
|
|
showNewDocModal.value = true
|
|
}
|
|
|
|
async function createDocument() {
|
|
creating.value = true
|
|
try {
|
|
const doc = await documents.createDocument(newDoc.value)
|
|
showNewDocModal.value = false
|
|
if (doc) {
|
|
navigateTo(`/documents/${doc.slug}`)
|
|
}
|
|
}
|
|
catch {
|
|
// Error handled in store
|
|
}
|
|
finally {
|
|
creating.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<SectionLayout
|
|
title="Documents de reference"
|
|
subtitle="Textes fondateurs sous vote permanent de la communaute"
|
|
:statuses="statuses"
|
|
:active-status="activeStatus"
|
|
@update:active-status="activeStatus = $event"
|
|
>
|
|
<!-- Search / sort bar -->
|
|
<template #search>
|
|
<UInput
|
|
v-model="searchQuery"
|
|
placeholder="Rechercher un document..."
|
|
icon="i-lucide-search"
|
|
size="sm"
|
|
class="w-full sm:w-64"
|
|
/>
|
|
<USelect
|
|
v-model="sortBy"
|
|
:items="sortOptions"
|
|
size="sm"
|
|
class="w-36"
|
|
/>
|
|
<UButton
|
|
v-if="auth.isAuthenticated"
|
|
label="Nouveau"
|
|
icon="i-lucide-plus"
|
|
color="primary"
|
|
size="sm"
|
|
@click="openNewDocModal"
|
|
/>
|
|
</template>
|
|
|
|
<!-- Main content: document list -->
|
|
<template #default>
|
|
<!-- Error state -->
|
|
<div v-if="documents.error" class="flex items-center gap-3 p-4 rounded-lg" style="background: var(--mood-surface); border: 1px solid var(--mood-border);">
|
|
<UIcon name="i-lucide-alert-circle" class="text-xl" style="color: var(--mood-error);" />
|
|
<p style="color: var(--mood-text);">{{ documents.error }}</p>
|
|
</div>
|
|
|
|
<!-- Loading state -->
|
|
<div v-else-if="documents.loading" class="space-y-3">
|
|
<LoadingSkeleton v-for="i in 4" :key="i" :lines="2" card />
|
|
</div>
|
|
|
|
<!-- Empty state -->
|
|
<div
|
|
v-else-if="filteredDocuments.length === 0"
|
|
class="text-center py-12"
|
|
style="color: var(--mood-text-muted);"
|
|
>
|
|
<UIcon name="i-lucide-book-open" class="text-4xl mb-3 block mx-auto" />
|
|
<p>Aucun document trouve</p>
|
|
<p v-if="searchQuery || activeStatus" class="text-sm mt-1">
|
|
Essayez de modifier vos filtres
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Document cards -->
|
|
<div v-else class="space-y-3">
|
|
<div
|
|
v-for="doc in filteredDocuments"
|
|
:key="doc.id"
|
|
class="doc-card"
|
|
@click="navigateTo(`/documents/${doc.slug}`)"
|
|
>
|
|
<div class="doc-card__header">
|
|
<h3 class="doc-card__title">
|
|
{{ doc.title }}
|
|
</h3>
|
|
<StatusBadge :status="doc.status" type="document" />
|
|
</div>
|
|
|
|
<div class="doc-card__meta">
|
|
<UBadge variant="subtle" color="primary" size="xs">
|
|
{{ typeLabel(doc.doc_type) }}
|
|
</UBadge>
|
|
<span class="doc-card__version">v{{ doc.version }}</span>
|
|
<span class="doc-card__items">
|
|
<UIcon name="i-lucide-list" class="text-xs" />
|
|
{{ doc.items_count }} item{{ doc.items_count !== 1 ? 's' : '' }}
|
|
</span>
|
|
<span class="doc-card__date">
|
|
{{ formatDate(doc.updated_at) }}
|
|
</span>
|
|
</div>
|
|
|
|
<p v-if="doc.description" class="doc-card__description">
|
|
{{ doc.description }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Toolbox sidebar -->
|
|
<template #toolbox>
|
|
<div class="toolbox-section-title">
|
|
{{ toolboxTitle }}
|
|
</div>
|
|
<template v-if="protocols.protocols.length > 0">
|
|
<ToolboxVignette
|
|
v-for="protocol in protocols.protocols"
|
|
:key="protocol.id"
|
|
:title="protocol.name"
|
|
:description="protocol.description || undefined"
|
|
context-label="Documents"
|
|
:actions="[
|
|
{ label: 'Voir', icon: 'i-lucide-eye', to: `/protocols/${protocol.id}` },
|
|
]"
|
|
/>
|
|
</template>
|
|
<p v-else class="toolbox-empty-text">
|
|
Aucun protocole configure
|
|
</p>
|
|
</template>
|
|
</SectionLayout>
|
|
|
|
<!-- New document modal -->
|
|
<UModal v-model:open="showNewDocModal">
|
|
<template #content>
|
|
<div class="p-6 space-y-4">
|
|
<h3 class="text-lg font-semibold" style="color: var(--mood-text);">
|
|
Nouveau document de reference
|
|
</h3>
|
|
|
|
<div class="space-y-4">
|
|
<div class="space-y-2">
|
|
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
|
|
Titre
|
|
</label>
|
|
<UInput
|
|
v-model="newDoc.title"
|
|
placeholder="Ex: Licence G1"
|
|
class="w-full"
|
|
/>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
|
|
Slug (identifiant URL)
|
|
</label>
|
|
<UInput
|
|
v-model="newDoc.slug"
|
|
placeholder="Ex: licence-g1"
|
|
class="w-full font-mono text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
|
|
Type de document
|
|
</label>
|
|
<USelect
|
|
v-model="newDoc.doc_type"
|
|
:items="newDocTypeOptions"
|
|
class="w-full"
|
|
/>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
|
|
Version
|
|
</label>
|
|
<UInput
|
|
v-model="newDoc.version"
|
|
placeholder="1.0.0"
|
|
class="w-full font-mono text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
|
|
Description (optionnelle)
|
|
</label>
|
|
<UTextarea
|
|
v-model="newDoc.description"
|
|
:rows="3"
|
|
placeholder="Decrivez brievement ce document..."
|
|
class="w-full"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center justify-end gap-3 pt-2">
|
|
<UButton
|
|
label="Annuler"
|
|
variant="ghost"
|
|
color="neutral"
|
|
@click="showNewDocModal = false"
|
|
/>
|
|
<UButton
|
|
label="Creer le document"
|
|
icon="i-lucide-plus"
|
|
color="primary"
|
|
:loading="creating"
|
|
:disabled="!newDoc.title.trim() || !newDoc.slug.trim()"
|
|
@click="createDocument"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</UModal>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.doc-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
padding: 0.875rem 1rem;
|
|
background: var(--mood-surface);
|
|
border: 1px solid var(--mood-border);
|
|
border-radius: 0.5rem;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.doc-card:hover {
|
|
border-color: var(--mood-accent);
|
|
box-shadow: 0 2px 8px var(--mood-shadow);
|
|
}
|
|
|
|
.doc-card__header {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.doc-card__title {
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
color: var(--mood-text);
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.doc-card__meta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.doc-card__version {
|
|
font-size: 0.6875rem;
|
|
font-family: ui-monospace, SFMono-Regular, monospace;
|
|
color: var(--mood-text-muted);
|
|
}
|
|
|
|
.doc-card__items {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
font-size: 0.6875rem;
|
|
color: var(--mood-text-muted);
|
|
}
|
|
|
|
.doc-card__date {
|
|
font-size: 0.6875rem;
|
|
color: var(--mood-text-muted);
|
|
margin-left: auto;
|
|
}
|
|
|
|
.doc-card__description {
|
|
font-size: 0.75rem;
|
|
color: var(--mood-text-muted);
|
|
line-height: 1.4;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.toolbox-section-title {
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
color: var(--mood-text-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.toolbox-empty-text {
|
|
font-size: 0.75rem;
|
|
color: var(--mood-text-muted);
|
|
}
|
|
</style>
|