Sprint 2 : moteur de documents + sanctuaire
Backend: - CRUD complet documents/items/versions (update, delete, accept, reject, reorder) - Service IPFS (upload/retrieve/pin via kubo HTTP API) - Service sanctuaire : pipeline SHA-256 + IPFS + on-chain (system.remark) - Verification integrite des entrees sanctuaire - Recherche par reference (document -> entrees sanctuaire) - Serialisation deterministe des documents pour archivage - 14 tests unitaires supplementaires (document service) Frontend: - 9 composants : StatusBadge, MarkdownRenderer, DiffView, ItemCard, ItemVersionDiff, DocumentList, SanctuaryEntry, IPFSLink, ChainAnchor - Page detail item avec historique des versions et diff - Page detail sanctuaire avec verification integrite - Modal de creation de document + proposition de version - Archivage document vers sanctuaire depuis la page detail Documentation: - API reference mise a jour (9 nouveaux endpoints) - Guides utilisateur documents et sanctuaire enrichis Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
51
frontend/app/components/common/DiffView.vue
Normal file
51
frontend/app/components/common/DiffView.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
diff: string
|
||||
}>()
|
||||
|
||||
interface DiffLine {
|
||||
text: string
|
||||
type: 'added' | 'removed' | 'header' | 'context'
|
||||
}
|
||||
|
||||
const parsedLines = computed((): DiffLine[] => {
|
||||
if (!props.diff) return []
|
||||
|
||||
return props.diff.split('\n').map((line) => {
|
||||
if (line.startsWith('@@')) {
|
||||
return { text: line, type: 'header' as const }
|
||||
}
|
||||
if (line.startsWith('+')) {
|
||||
return { text: line, type: 'added' as const }
|
||||
}
|
||||
if (line.startsWith('-')) {
|
||||
return { text: line, type: 'removed' as const }
|
||||
}
|
||||
return { text: line, type: 'context' as const }
|
||||
})
|
||||
})
|
||||
|
||||
function lineClass(type: DiffLine['type']): string {
|
||||
switch (type) {
|
||||
case 'added':
|
||||
return 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
||||
case 'removed':
|
||||
return 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-300'
|
||||
case 'header':
|
||||
return 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 font-semibold'
|
||||
default:
|
||||
return 'text-gray-700 dark:text-gray-300'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<pre class="text-xs font-mono leading-relaxed overflow-x-auto"><template
|
||||
v-for="(line, index) in parsedLines"
|
||||
:key="index"
|
||||
><div
|
||||
:class="['px-4 py-0.5', lineClass(line.type)]"
|
||||
>{{ line.text }}</div></template></pre>
|
||||
</div>
|
||||
</template>
|
||||
67
frontend/app/components/common/MarkdownRenderer.vue
Normal file
67
frontend/app/components/common/MarkdownRenderer.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
content: string
|
||||
}>()
|
||||
|
||||
/**
|
||||
* Basic markdown rendering: bold, line breaks, unordered lists.
|
||||
* Returns sanitized HTML string for v-html usage.
|
||||
*/
|
||||
const renderedHtml = computed(() => {
|
||||
if (!props.content) return ''
|
||||
|
||||
let html = props.content
|
||||
|
||||
// Escape HTML entities to prevent XSS
|
||||
html = html
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
// Convert **bold** to <strong>
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
|
||||
// Convert *italic* to <em>
|
||||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
|
||||
// Process lines for list items
|
||||
const lines = html.split('\n')
|
||||
const result: string[] = []
|
||||
let inList = false
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
|
||||
if (trimmed.startsWith('- ')) {
|
||||
if (!inList) {
|
||||
result.push('<ul class="list-disc list-inside space-y-1 my-2">')
|
||||
inList = true
|
||||
}
|
||||
result.push(`<li>${trimmed.slice(2)}</li>`)
|
||||
} else {
|
||||
if (inList) {
|
||||
result.push('</ul>')
|
||||
inList = false
|
||||
}
|
||||
if (trimmed === '') {
|
||||
result.push('<br>')
|
||||
} else {
|
||||
result.push(`<p class="my-1">${line}</p>`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (inList) {
|
||||
result.push('</ul>')
|
||||
}
|
||||
|
||||
return result.join('\n')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="prose prose-sm dark:prose-invert max-w-none text-gray-700 dark:text-gray-300 leading-relaxed"
|
||||
v-html="renderedHtml"
|
||||
/>
|
||||
</template>
|
||||
61
frontend/app/components/common/StatusBadge.vue
Normal file
61
frontend/app/components/common/StatusBadge.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
status: string
|
||||
type?: 'document' | 'decision' | 'mandate' | 'version' | 'vote'
|
||||
}>()
|
||||
|
||||
const statusConfig: Record<string, Record<string, { color: string; label: string }>> = {
|
||||
document: {
|
||||
draft: { color: 'warning', label: 'Brouillon' },
|
||||
active: { color: 'success', label: 'Actif' },
|
||||
archived: { color: 'neutral', label: 'Archive' },
|
||||
},
|
||||
version: {
|
||||
proposed: { color: 'info', label: 'Propose' },
|
||||
voting: { color: 'warning', label: 'En vote' },
|
||||
accepted: { color: 'success', label: 'Accepte' },
|
||||
rejected: { color: 'error', label: 'Rejete' },
|
||||
},
|
||||
decision: {
|
||||
draft: { color: 'warning', label: 'Brouillon' },
|
||||
qualification: { color: 'info', label: 'Qualification' },
|
||||
review: { color: 'info', label: 'Revue' },
|
||||
voting: { color: 'primary', label: 'En vote' },
|
||||
executed: { color: 'success', label: 'Execute' },
|
||||
closed: { color: 'neutral', label: 'Clos' },
|
||||
},
|
||||
mandate: {
|
||||
draft: { color: 'warning', label: 'Brouillon' },
|
||||
candidacy: { color: 'info', label: 'Candidature' },
|
||||
voting: { color: 'primary', label: 'En vote' },
|
||||
active: { color: 'success', label: 'Actif' },
|
||||
reporting: { color: 'info', label: 'Rapport' },
|
||||
completed: { color: 'neutral', label: 'Termine' },
|
||||
revoked: { color: 'error', label: 'Revoque' },
|
||||
},
|
||||
vote: {
|
||||
open: { color: 'success', label: 'Ouvert' },
|
||||
closed: { color: 'warning', label: 'Ferme' },
|
||||
tallied: { color: 'neutral', label: 'Depouille' },
|
||||
},
|
||||
}
|
||||
|
||||
const resolved = computed(() => {
|
||||
const typeKey = props.type || 'document'
|
||||
const typeMap = statusConfig[typeKey]
|
||||
if (typeMap && typeMap[props.status]) {
|
||||
return typeMap[props.status]
|
||||
}
|
||||
return { color: 'neutral', label: props.status }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UBadge
|
||||
:color="(resolved.color as any)"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
{{ resolved.label }}
|
||||
</UBadge>
|
||||
</template>
|
||||
88
frontend/app/components/documents/DocumentList.vue
Normal file
88
frontend/app/components/documents/DocumentList.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import type { Document } from '~/stores/documents'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
documents: Document[]
|
||||
loading?: boolean
|
||||
}>(), {
|
||||
loading: false,
|
||||
})
|
||||
|
||||
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',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<USkeleton v-for="i in 6" :key="i" class="h-48 w-full" />
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<UCard v-else-if="documents.length === 0">
|
||||
<div class="text-center py-8">
|
||||
<UIcon name="i-lucide-book-open" class="text-4xl text-gray-400 mb-3" />
|
||||
<p class="text-gray-500">Aucun document de reference pour le moment</p>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Document grid -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<UCard
|
||||
v-for="doc in documents"
|
||||
:key="doc.id"
|
||||
class="cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
|
||||
@click="navigateTo(`/documents/${doc.slug}`)"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white text-sm leading-tight">
|
||||
{{ doc.title }}
|
||||
</h3>
|
||||
<CommonStatusBadge :status="doc.status" type="document" />
|
||||
</div>
|
||||
|
||||
<!-- Type + Version -->
|
||||
<div class="flex items-center gap-2">
|
||||
<UBadge variant="subtle" color="primary" size="xs">
|
||||
{{ typeLabel(doc.doc_type) }}
|
||||
</UBadge>
|
||||
<span class="text-xs text-gray-500 font-mono">v{{ doc.version }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p
|
||||
v-if="doc.description"
|
||||
class="text-xs text-gray-600 dark:text-gray-400 line-clamp-2"
|
||||
>
|
||||
{{ doc.description }}
|
||||
</p>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-800">
|
||||
<div class="flex items-center gap-1 text-xs text-gray-500">
|
||||
<UIcon name="i-lucide-list" class="text-sm" />
|
||||
<span>{{ doc.items_count }} items</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">{{ formatDate(doc.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
89
frontend/app/components/documents/ItemCard.vue
Normal file
89
frontend/app/components/documents/ItemCard.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import type { DocumentItem } from '~/stores/documents'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
item: DocumentItem
|
||||
documentSlug: string
|
||||
showActions?: boolean
|
||||
}>(), {
|
||||
showActions: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
propose: [item: DocumentItem]
|
||||
}>()
|
||||
|
||||
const itemTypeLabel = (itemType: string): string => {
|
||||
switch (itemType) {
|
||||
case 'clause': return 'Clause'
|
||||
case 'rule': return 'Regle'
|
||||
case 'verification': return 'Verification'
|
||||
case 'preamble': return 'Preambule'
|
||||
case 'section': return 'Section'
|
||||
default: return itemType
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToItem() {
|
||||
navigateTo(`/documents/${props.documentSlug}/items/${props.item.id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard
|
||||
class="cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
|
||||
@click="navigateToItem"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<!-- Item header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<UBadge variant="solid" color="primary" size="xs">
|
||||
{{ item.position }}
|
||||
</UBadge>
|
||||
<span v-if="item.title" class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
<UBadge variant="subtle" color="neutral" size="xs">
|
||||
{{ itemTypeLabel(item.item_type) }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UBadge
|
||||
v-if="item.voting_protocol_id"
|
||||
color="info"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
Sous vote
|
||||
</UBadge>
|
||||
<UBadge
|
||||
v-else
|
||||
color="neutral"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
Pas de vote
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item text -->
|
||||
<div class="pl-2">
|
||||
<CommonMarkdownRenderer :content="item.current_text" />
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div v-if="showActions" class="flex justify-end pt-2 border-t border-gray-100 dark:border-gray-800">
|
||||
<UButton
|
||||
label="Proposer une modification"
|
||||
icon="i-lucide-pen-line"
|
||||
variant="soft"
|
||||
color="primary"
|
||||
size="xs"
|
||||
@click.stop="emit('propose', item)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
95
frontend/app/components/documents/ItemVersionDiff.vue
Normal file
95
frontend/app/components/documents/ItemVersionDiff.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import type { ItemVersion } from '~/stores/documents'
|
||||
|
||||
const props = defineProps<{
|
||||
version: ItemVersion
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
accept: [versionId: string]
|
||||
reject: [versionId: string]
|
||||
}>()
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function truncateAddress(address: string | null): string {
|
||||
if (!address) return 'Inconnu'
|
||||
if (address.length <= 16) return address
|
||||
return address.slice(0, 8) + '...' + address.slice(-6)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard>
|
||||
<div class="space-y-4">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<CommonStatusBadge :status="version.status" type="version" />
|
||||
<span class="text-sm text-gray-500">
|
||||
Propose par
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 font-mono text-xs">
|
||||
{{ truncateAddress(version.proposed_by) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">
|
||||
{{ formatDate(version.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Rationale -->
|
||||
<div v-if="version.rationale" class="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-3">
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase mb-1">Justification</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ version.rationale }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Diff view -->
|
||||
<div v-if="version.diff">
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase mb-2">Modifications</p>
|
||||
<CommonDiffView :diff="version.diff" />
|
||||
</div>
|
||||
|
||||
<!-- Proposed text (fallback if no diff) -->
|
||||
<div v-else-if="version.proposed_text">
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase mb-2">Texte propose</p>
|
||||
<div class="bg-green-50 dark:bg-green-900/10 border border-green-200 dark:border-green-800 rounded-lg p-3">
|
||||
<CommonMarkdownRenderer :content="version.proposed_text" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions for authenticated users -->
|
||||
<div
|
||||
v-if="auth.isAuthenticated && version.status === 'proposed'"
|
||||
class="flex items-center gap-3 pt-2 border-t border-gray-100 dark:border-gray-800"
|
||||
>
|
||||
<UButton
|
||||
label="Accepter"
|
||||
icon="i-lucide-check"
|
||||
color="success"
|
||||
variant="soft"
|
||||
size="xs"
|
||||
@click="emit('accept', version.id)"
|
||||
/>
|
||||
<UButton
|
||||
label="Rejeter"
|
||||
icon="i-lucide-x"
|
||||
color="error"
|
||||
variant="soft"
|
||||
size="xs"
|
||||
@click="emit('reject', version.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
33
frontend/app/components/sanctuary/ChainAnchor.vue
Normal file
33
frontend/app/components/sanctuary/ChainAnchor.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
txHash: string | null
|
||||
block: number | null
|
||||
}>()
|
||||
|
||||
const truncatedHash = computed(() => {
|
||||
if (!props.txHash) return null
|
||||
if (props.txHash.length <= 20) return props.txHash
|
||||
return props.txHash.slice(0, 10) + '...' + props.txHash.slice(-6)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="txHash">
|
||||
<div class="inline-flex items-center gap-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<UIcon name="i-lucide-link" class="text-sm text-gray-500" />
|
||||
<span class="font-mono text-xs text-gray-700 dark:text-gray-300">
|
||||
{{ truncatedHash }}
|
||||
</span>
|
||||
</div>
|
||||
<UBadge v-if="block" color="neutral" variant="subtle" size="xs">
|
||||
Bloc #{{ block.toLocaleString('fr-FR') }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<UBadge color="warning" variant="subtle" size="xs">
|
||||
Non ancre
|
||||
</UBadge>
|
||||
</template>
|
||||
</template>
|
||||
38
frontend/app/components/sanctuary/IPFSLink.vue
Normal file
38
frontend/app/components/sanctuary/IPFSLink.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
cid: string | null
|
||||
}>()
|
||||
|
||||
const IPFS_GATEWAY = 'https://ipfs.io/ipfs/'
|
||||
|
||||
const truncatedCid = computed(() => {
|
||||
if (!props.cid) return null
|
||||
if (props.cid.length <= 20) return props.cid
|
||||
return props.cid.slice(0, 12) + '...' + props.cid.slice(-6)
|
||||
})
|
||||
|
||||
const gatewayUrl = computed(() => {
|
||||
if (!props.cid) return null
|
||||
return `${IPFS_GATEWAY}${props.cid}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="cid">
|
||||
<a
|
||||
:href="gatewayUrl!"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 font-mono text-xs text-primary hover:underline"
|
||||
>
|
||||
<UIcon name="i-lucide-hard-drive" class="text-sm" />
|
||||
<span>{{ truncatedCid }}</span>
|
||||
<UIcon name="i-lucide-external-link" class="text-sm" />
|
||||
</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
<UBadge color="neutral" variant="subtle" size="xs">
|
||||
Non disponible
|
||||
</UBadge>
|
||||
</template>
|
||||
</template>
|
||||
171
frontend/app/components/sanctuary/SanctuaryEntry.vue
Normal file
171
frontend/app/components/sanctuary/SanctuaryEntry.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<script setup lang="ts">
|
||||
export interface SanctuaryEntryOut {
|
||||
id: string
|
||||
entry_type: string
|
||||
reference_id: string
|
||||
title: string | null
|
||||
content_hash: string
|
||||
ipfs_cid: string | null
|
||||
chain_tx_hash: string | null
|
||||
chain_block: number | null
|
||||
metadata_json: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
entry: SanctuaryEntryOut
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
verify: [id: string]
|
||||
}>()
|
||||
|
||||
const typeLabel = (entryType: string): string => {
|
||||
switch (entryType) {
|
||||
case 'document': return 'Document'
|
||||
case 'decision': return 'Decision'
|
||||
case 'vote_result': return 'Resultat de vote'
|
||||
default: return entryType
|
||||
}
|
||||
}
|
||||
|
||||
const typeColor = (entryType: string): string => {
|
||||
switch (entryType) {
|
||||
case 'document': return 'primary'
|
||||
case 'decision': return 'success'
|
||||
case 'vote_result': return 'info'
|
||||
default: return 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function truncateHash(hash: string | null, length: number = 16): string {
|
||||
if (!hash) return '-'
|
||||
if (hash.length <= length * 2) return hash
|
||||
return hash.slice(0, length) + '...' + hash.slice(-8)
|
||||
}
|
||||
|
||||
const copied = ref(false)
|
||||
|
||||
async function copyHash() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.entry.content_hash)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
} catch {
|
||||
// Clipboard API not available
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard
|
||||
class="cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
|
||||
@click="navigateTo(`/sanctuary/${entry.id}`)"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<!-- Entry header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<UIcon name="i-lucide-shield-check" class="text-xl text-primary" />
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">
|
||||
{{ entry.title || 'Entree sans titre' }}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ formatDate(entry.created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<UBadge :color="(typeColor(entry.entry_type) as any)" variant="subtle" size="xs">
|
||||
{{ typeLabel(entry.entry_type) }}
|
||||
</UBadge>
|
||||
</div>
|
||||
|
||||
<!-- Hashes and anchors -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<!-- Content hash -->
|
||||
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-lucide-hash" class="text-gray-400 text-sm" />
|
||||
<span class="text-xs font-semibold text-gray-500 uppercase">SHA-256</span>
|
||||
</div>
|
||||
<UButton
|
||||
:icon="copied ? 'i-lucide-check' : 'i-lucide-copy'"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="xs"
|
||||
class="p-0"
|
||||
@click.stop="copyHash"
|
||||
/>
|
||||
</div>
|
||||
<p class="font-mono text-xs text-gray-700 dark:text-gray-300 break-all">
|
||||
{{ truncateHash(entry.content_hash) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- IPFS CID -->
|
||||
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<UIcon name="i-lucide-hard-drive" class="text-gray-400 text-sm" />
|
||||
<span class="text-xs font-semibold text-gray-500 uppercase">IPFS CID</span>
|
||||
</div>
|
||||
<div @click.stop>
|
||||
<SanctuaryIPFSLink :cid="entry.ipfs_cid" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chain anchor -->
|
||||
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<UIcon name="i-lucide-link" class="text-gray-400 text-sm" />
|
||||
<span class="text-xs font-semibold text-gray-500 uppercase">On-chain</span>
|
||||
</div>
|
||||
<SanctuaryChainAnchor :tx-hash="entry.chain_tx_hash" :block="entry.chain_block" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Verification status indicators -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4 text-xs">
|
||||
<div class="flex items-center gap-1">
|
||||
<UIcon
|
||||
:name="entry.ipfs_cid ? 'i-lucide-check-circle' : 'i-lucide-clock'"
|
||||
:class="entry.ipfs_cid ? 'text-green-500' : 'text-gray-400'"
|
||||
/>
|
||||
<span :class="entry.ipfs_cid ? 'text-green-600' : 'text-gray-400'">
|
||||
IPFS {{ entry.ipfs_cid ? 'epingle' : 'en attente' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<UIcon
|
||||
:name="entry.chain_tx_hash ? 'i-lucide-check-circle' : 'i-lucide-clock'"
|
||||
:class="entry.chain_tx_hash ? 'text-green-500' : 'text-gray-400'"
|
||||
/>
|
||||
<span :class="entry.chain_tx_hash ? 'text-green-600' : 'text-gray-400'">
|
||||
Chain {{ entry.chain_tx_hash ? 'ancre' : 'en attente' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<UButton
|
||||
label="Verifier"
|
||||
icon="i-lucide-shield-check"
|
||||
variant="soft"
|
||||
color="primary"
|
||||
size="xs"
|
||||
@click.stop="emit('verify', entry.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
Reference in New Issue
Block a user