- Group + GroupMember : modèle SQLAlchemy + migration + router CRUD - /api/v1/groups : liste, création, suppression, membres (add/remove) - groups.ts : store Pinia (fetchAll, getGroup, create, remove, addMember, removeMember) - decisions/new.vue : cercles 1 & 2 en mode texte libre OU groupe prédéfini (affected_count calculé depuis le member_count du groupe) - protocols/index.vue : section Groupes avec expand/collapse, ajout/suppression membres - lang="fr" + spellcheck sur tous les textareas ; placeholder cercle 2 corrigé - n8n channels : prévu sprint futur (texte libre → webhook appel à contribution) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1546 lines
40 KiB
Vue
1546 lines
40 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* Protocoles & Fonctionnement — Boîte à outils de vote.
|
||
*
|
||
* Liste les protocoles de vote avec SectionLayout,
|
||
* sidebar n8n workflow + simulateur de formules.
|
||
*/
|
||
const protocols = useProtocolsStore()
|
||
const auth = useAuthStore()
|
||
|
||
const activeStatus = ref<string | null>(null)
|
||
const searchQuery = ref('')
|
||
const showCreateModal = ref(false)
|
||
const creating = ref(false)
|
||
|
||
const newProtocol = reactive({
|
||
name: '',
|
||
description: '',
|
||
vote_type: 'binary',
|
||
formula_config_id: '',
|
||
})
|
||
|
||
onMounted(async () => {
|
||
await Promise.all([
|
||
protocols.fetchProtocols(),
|
||
protocols.fetchFormulas(),
|
||
groupsStore.fetchAll(),
|
||
])
|
||
})
|
||
|
||
const voteTypeLabel = (voteType: string) => {
|
||
switch (voteType) {
|
||
case 'binary': return 'Binaire'
|
||
case 'nuanced': return 'Nuancé'
|
||
default: return voteType
|
||
}
|
||
}
|
||
|
||
const voteTypeOptions = [
|
||
{ label: 'Binaire (Pour/Contre)', value: 'binary' },
|
||
{ label: 'Nuancé (6 niveaux)', value: 'nuanced' },
|
||
]
|
||
|
||
const formulaOptions = computed(() => {
|
||
return protocols.formulas.map(f => ({
|
||
label: f.name,
|
||
value: f.id,
|
||
}))
|
||
})
|
||
|
||
/** Status pills. */
|
||
const statuses = computed(() => [
|
||
{
|
||
id: 'binary',
|
||
label: 'Binaire',
|
||
count: protocols.protocols.filter(p => p.vote_type === 'binary').length,
|
||
cssClass: 'status-vote',
|
||
},
|
||
{
|
||
id: 'nuanced',
|
||
label: 'Nuancé',
|
||
count: protocols.protocols.filter(p => p.vote_type === 'nuanced').length,
|
||
cssClass: 'status-prepa',
|
||
},
|
||
])
|
||
|
||
/** Filtered protocols. */
|
||
const filteredProtocols = computed(() => {
|
||
let list = [...protocols.protocols]
|
||
|
||
if (activeStatus.value) {
|
||
list = list.filter(p => p.vote_type === activeStatus.value)
|
||
}
|
||
|
||
if (searchQuery.value.trim()) {
|
||
const q = searchQuery.value.toLowerCase()
|
||
list = list.filter(p =>
|
||
p.name.toLowerCase().includes(q)
|
||
|| (p.description && p.description.toLowerCase().includes(q)),
|
||
)
|
||
}
|
||
|
||
return list
|
||
})
|
||
|
||
function openCreateModal() {
|
||
newProtocol.name = ''
|
||
newProtocol.description = ''
|
||
newProtocol.vote_type = 'binary'
|
||
newProtocol.formula_config_id = ''
|
||
showCreateModal.value = true
|
||
}
|
||
|
||
async function createProtocol() {
|
||
if (!newProtocol.name.trim() || !newProtocol.formula_config_id) return
|
||
|
||
creating.value = true
|
||
try {
|
||
await protocols.createProtocol({
|
||
name: newProtocol.name.trim(),
|
||
description: newProtocol.description.trim() || null,
|
||
vote_type: newProtocol.vote_type,
|
||
formula_config_id: newProtocol.formula_config_id,
|
||
})
|
||
showCreateModal.value = false
|
||
await protocols.fetchProtocols()
|
||
}
|
||
finally {
|
||
creating.value = false
|
||
}
|
||
}
|
||
|
||
/** Operational protocols (workflow templates). */
|
||
interface WorkflowStep {
|
||
label: string
|
||
actor: string
|
||
icon: string
|
||
type: string
|
||
}
|
||
|
||
interface LinkedRef {
|
||
label: string
|
||
icon: string
|
||
to: string
|
||
kind: 'document' | 'decision'
|
||
}
|
||
|
||
interface OperationalProtocol {
|
||
slug: string
|
||
name: string
|
||
description: string
|
||
category: string
|
||
icon: string
|
||
instancesLabel: string
|
||
linkedRefs: LinkedRef[]
|
||
steps: WorkflowStep[]
|
||
}
|
||
|
||
const operationalProtocols: OperationalProtocol[] = [
|
||
{
|
||
slug: 'embarquement-forgeron',
|
||
name: 'Embarquement Forgeron',
|
||
description: 'Processus complet d\'intégration d\'un nouveau forgeron dans le réseau Duniter. Parcours en 5 jalons de la candidature à la mise en ligne du nœud validateur.',
|
||
category: 'onboarding',
|
||
icon: 'i-lucide-hammer',
|
||
instancesLabel: '~10-50 / an',
|
||
linkedRefs: [
|
||
{ label: 'Acte d\'engagement forgeron', icon: 'i-lucide-book-open', to: '/documents/engagement-forgeron', kind: 'document' },
|
||
],
|
||
steps: [
|
||
{ label: 'Candidature', actor: 'Aspirant forgeron', icon: 'i-lucide-user-plus', type: 'checklist' },
|
||
{ label: 'Nœud miroir', actor: 'Candidat', icon: 'i-lucide-server', type: 'on_chain' },
|
||
{ label: 'Évaluation technique', actor: 'Certificateur', icon: 'i-lucide-clipboard-check', type: 'checklist' },
|
||
{ label: 'Certification Smith (×3)', actor: 'Certificateurs', icon: 'i-lucide-stamp', type: 'certification' },
|
||
{ label: 'Go online', actor: 'Candidat', icon: 'i-lucide-wifi', type: 'on_chain' },
|
||
],
|
||
},
|
||
{
|
||
slug: 'soumission-runtime-upgrade',
|
||
name: 'Soumission Runtime Upgrade',
|
||
description: 'Protocole de soumission d\'une mise à jour du runtime Duniter V2 on-chain. Chaque upgrade suit un parcours strict en 5 étapes, de la qualification technique au suivi post-déploiement.',
|
||
category: 'on-chain',
|
||
icon: 'i-lucide-cpu',
|
||
instancesLabel: '~2-6 / an',
|
||
linkedRefs: [
|
||
{ label: 'Décision Runtime Upgrade', icon: 'i-lucide-scale', to: '/decisions', kind: 'decision' },
|
||
],
|
||
steps: [
|
||
{ label: 'Qualification', actor: 'Proposant', icon: 'i-lucide-file-check', type: 'checklist' },
|
||
{ label: 'Revue technique', actor: 'Comité technique', icon: 'i-lucide-search', type: 'checklist' },
|
||
{ label: 'Vote communautaire', actor: 'Communauté WoT', icon: 'i-lucide-vote', type: 'on_chain' },
|
||
{ label: 'Exécution on-chain', actor: 'Proposant', icon: 'i-lucide-zap', type: 'on_chain' },
|
||
{ label: 'Suivi post-upgrade', actor: 'Forgerons', icon: 'i-lucide-activity', type: 'checklist' },
|
||
],
|
||
},
|
||
]
|
||
|
||
// ── Groups ─────────────────────────────────────────────────────────────────
|
||
const groupsStore = useGroupsStore()
|
||
const showGroupModal = ref(false)
|
||
const newGroupName = ref('')
|
||
const newGroupDesc = ref('')
|
||
const creatingGroup = ref(false)
|
||
const expandedGroupId = ref<string | null>(null)
|
||
const expandedGroupDetail = ref<import('~/stores/groups').Group | null>(null)
|
||
const loadingGroupDetail = ref(false)
|
||
const newMemberName = ref('')
|
||
const addingMember = ref(false)
|
||
|
||
async function openGroupModal() {
|
||
newGroupName.value = ''
|
||
newGroupDesc.value = ''
|
||
showGroupModal.value = true
|
||
}
|
||
|
||
async function createGroup() {
|
||
if (!newGroupName.value.trim()) return
|
||
creatingGroup.value = true
|
||
try {
|
||
await groupsStore.create({ name: newGroupName.value.trim(), description: newGroupDesc.value.trim() || null })
|
||
showGroupModal.value = false
|
||
} finally {
|
||
creatingGroup.value = false
|
||
}
|
||
}
|
||
|
||
async function toggleGroupDetail(groupId: string) {
|
||
if (expandedGroupId.value === groupId) {
|
||
expandedGroupId.value = null
|
||
expandedGroupDetail.value = null
|
||
return
|
||
}
|
||
expandedGroupId.value = groupId
|
||
loadingGroupDetail.value = true
|
||
expandedGroupDetail.value = await groupsStore.getGroup(groupId)
|
||
loadingGroupDetail.value = false
|
||
}
|
||
|
||
async function addMember(groupId: string) {
|
||
if (!newMemberName.value.trim()) return
|
||
addingMember.value = true
|
||
const member = await groupsStore.addMember(groupId, { display_name: newMemberName.value.trim() })
|
||
if (member && expandedGroupDetail.value) {
|
||
expandedGroupDetail.value.members.push(member)
|
||
newMemberName.value = ''
|
||
}
|
||
addingMember.value = false
|
||
}
|
||
|
||
async function removeMember(groupId: string, memberId: string) {
|
||
const ok = await groupsStore.removeMember(groupId, memberId)
|
||
if (ok && expandedGroupDetail.value) {
|
||
expandedGroupDetail.value.members = expandedGroupDetail.value.members.filter(m => m.id !== memberId)
|
||
}
|
||
}
|
||
|
||
async function deleteGroup(groupId: string) {
|
||
await groupsStore.remove(groupId)
|
||
if (expandedGroupId.value === groupId) {
|
||
expandedGroupId.value = null
|
||
expandedGroupDetail.value = null
|
||
}
|
||
}
|
||
|
||
/** n8n workflow demo items. */
|
||
const n8nWorkflows = [
|
||
{
|
||
name: 'Vote -> Notification',
|
||
description: 'Notifie les membres lorsqu\'un nouveau vote démarre ou se termine.',
|
||
icon: 'i-lucide-bell',
|
||
status: 'actif',
|
||
},
|
||
{
|
||
name: 'Seuil atteint -> Sanctuaire',
|
||
description: 'Archive automatiquement le document valide sur IPFS quand le seuil est atteint.',
|
||
icon: 'i-lucide-archive',
|
||
status: 'actif',
|
||
},
|
||
{
|
||
name: 'Décision → Étape suivante',
|
||
description: 'Avance automatiquement une décision à l\'étape suivante après validation.',
|
||
icon: 'i-lucide-git-branch',
|
||
status: 'demo',
|
||
},
|
||
{
|
||
name: 'Mandat expiré → Alerte',
|
||
description: 'Envoie une alerte 7 jours avant l\'expiration d\'un mandat.',
|
||
icon: 'i-lucide-alarm-clock',
|
||
status: 'demo',
|
||
},
|
||
]
|
||
</script>
|
||
|
||
<template>
|
||
<SectionLayout
|
||
title="Protocoles & Fonctionnement"
|
||
subtitle="Boîte à outils de vote, formules de seuil, workflows automatisés"
|
||
:statuses="statuses"
|
||
:active-status="activeStatus"
|
||
@update:active-status="activeStatus = $event"
|
||
>
|
||
<!-- Search slot -->
|
||
<template #search>
|
||
<input
|
||
v-model="searchQuery"
|
||
type="text"
|
||
class="proto-search"
|
||
placeholder="Rechercher un protocole..."
|
||
/>
|
||
<button
|
||
v-if="auth.isAuthenticated"
|
||
class="proto-add-btn"
|
||
@click="openCreateModal"
|
||
>
|
||
<UIcon name="i-lucide-plus" class="text-xs" />
|
||
<span>Nouveau</span>
|
||
</button>
|
||
</template>
|
||
|
||
<!-- Loading -->
|
||
<template v-if="protocols.loading">
|
||
<div class="proto-loading">
|
||
<LoadingSkeleton v-for="i in 3" :key="i" :lines="4" card />
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Error -->
|
||
<template v-else-if="protocols.error">
|
||
<div class="proto-error">
|
||
<UIcon name="i-lucide-alert-circle" class="text-lg" />
|
||
<span>{{ protocols.error }}</span>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Protocol list -->
|
||
<template v-else>
|
||
<div v-if="filteredProtocols.length === 0" class="proto-empty">
|
||
<UIcon name="i-lucide-settings" class="text-2xl" />
|
||
<p>Aucun protocole trouvé</p>
|
||
</div>
|
||
|
||
<div v-else class="proto-list">
|
||
<NuxtLink
|
||
v-for="protocol in filteredProtocols"
|
||
:key="protocol.id"
|
||
:to="`/protocols/${protocol.id}`"
|
||
class="proto-item"
|
||
>
|
||
<div class="proto-item__left">
|
||
<div class="proto-item__icon">
|
||
<UIcon
|
||
:name="protocol.vote_type === 'nuanced' ? 'i-lucide-bar-chart-3' : 'i-lucide-check-circle'"
|
||
class="text-lg"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="proto-item__body">
|
||
<div class="proto-item__head">
|
||
<h3 class="proto-item__name">{{ protocol.name }}</h3>
|
||
<span class="proto-item__type" :class="protocol.vote_type === 'nuanced' ? 'proto-item__type--nuanced' : ''">
|
||
{{ voteTypeLabel(protocol.vote_type) }}
|
||
</span>
|
||
</div>
|
||
<p v-if="protocol.description" class="proto-item__desc">
|
||
{{ protocol.description }}
|
||
</p>
|
||
<div class="proto-item__meta">
|
||
<span v-if="protocol.mode_params" class="proto-item__params">
|
||
{{ protocol.mode_params }}
|
||
</span>
|
||
<span class="proto-item__formula">
|
||
{{ protocol.formula_config.name }}
|
||
</span>
|
||
<span class="proto-item__detail">
|
||
{{ protocol.formula_config.duration_days }}j · {{ protocol.formula_config.majority_pct }}%
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="proto-item__arrow">
|
||
<UIcon name="i-lucide-chevron-right" class="text-sm" />
|
||
</div>
|
||
</NuxtLink>
|
||
</div>
|
||
|
||
<!-- Formulas table -->
|
||
<div class="proto-formulas">
|
||
<h3 class="proto-formulas__title">
|
||
<UIcon name="i-lucide-calculator" class="text-sm" />
|
||
Configurations de formule
|
||
<span class="proto-formulas__count">{{ protocols.formulas.length }}</span>
|
||
</h3>
|
||
<div class="proto-formulas__table-wrap">
|
||
<table class="proto-formulas__table">
|
||
<thead>
|
||
<tr>
|
||
<th>Nom</th>
|
||
<th>Durée</th>
|
||
<th>Majorité</th>
|
||
<th>B</th>
|
||
<th>G</th>
|
||
<th>Smith</th>
|
||
<th>TechComm</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="formula in protocols.formulas" :key="formula.id">
|
||
<td class="proto-formulas__name">{{ formula.name }}</td>
|
||
<td>{{ formula.duration_days }}j</td>
|
||
<td>{{ formula.majority_pct }}%</td>
|
||
<td>{{ formula.base_exponent }}</td>
|
||
<td>{{ formula.gradient_exponent }}</td>
|
||
<td>{{ formula.smith_exponent ?? '-' }}</td>
|
||
<td>{{ formula.techcomm_exponent ?? '-' }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Groups ─────────────────────────────────────────────────────────── -->
|
||
<div class="proto-groups">
|
||
<h3 class="proto-groups__title">
|
||
<UIcon name="i-lucide-users-round" class="text-sm" />
|
||
Groupes d'identités
|
||
<span class="proto-groups__count">{{ groupsStore.list.length }}</span>
|
||
<button v-if="auth.isAuthenticated" class="proto-groups__add-btn" @click="openGroupModal">
|
||
<UIcon name="i-lucide-plus" class="text-sm" />
|
||
Nouveau groupe
|
||
</button>
|
||
</h3>
|
||
|
||
<div v-if="groupsStore.list.length === 0" class="proto-groups__empty">
|
||
<UIcon name="i-lucide-users" class="text-lg" />
|
||
<span>Aucun groupe défini. Les groupes permettent de pré-sélectionner des cercles dans les décisions.</span>
|
||
</div>
|
||
|
||
<div v-else class="proto-groups__list">
|
||
<div v-for="g in groupsStore.list" :key="g.id" class="proto-groups__item">
|
||
<div class="proto-groups__item-head" @click="toggleGroupDetail(g.id)">
|
||
<div class="proto-groups__item-info">
|
||
<UIcon name="i-lucide-users" class="text-sm" />
|
||
<span class="proto-groups__item-name">{{ g.name }}</span>
|
||
<span class="proto-groups__item-count">{{ g.member_count }} membre{{ g.member_count > 1 ? 's' : '' }}</span>
|
||
</div>
|
||
<div class="proto-groups__item-actions">
|
||
<button v-if="auth.isAuthenticated" class="proto-groups__delete-btn" @click.stop="deleteGroup(g.id)">
|
||
<UIcon name="i-lucide-trash-2" class="text-xs" />
|
||
</button>
|
||
<UIcon :name="expandedGroupId === g.id ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" class="text-sm" />
|
||
</div>
|
||
</div>
|
||
|
||
<Transition name="slide-down">
|
||
<div v-if="expandedGroupId === g.id" class="proto-groups__detail">
|
||
<p v-if="g.description" class="proto-groups__detail-desc">{{ g.description }}</p>
|
||
<div v-if="loadingGroupDetail" class="proto-groups__members-loading">
|
||
<UIcon name="i-lucide-loader-2" class="animate-spin text-sm" />
|
||
</div>
|
||
<ul v-else-if="expandedGroupDetail" class="proto-groups__members">
|
||
<li v-for="m in expandedGroupDetail.members" :key="m.id" class="proto-groups__member">
|
||
<UIcon name="i-lucide-user" class="text-xs" />
|
||
<span>{{ m.display_name }}</span>
|
||
<button v-if="auth.isAuthenticated" class="proto-groups__member-remove" @click="removeMember(g.id, m.id)">
|
||
<UIcon name="i-lucide-x" class="text-xs" />
|
||
</button>
|
||
</li>
|
||
<li v-if="expandedGroupDetail.members.length === 0" class="proto-groups__member proto-groups__member--empty">
|
||
Aucun membre
|
||
</li>
|
||
</ul>
|
||
<div v-if="auth.isAuthenticated" class="proto-groups__add-member">
|
||
<input
|
||
v-model="newMemberName"
|
||
type="text"
|
||
lang="fr"
|
||
spellcheck="true"
|
||
class="proto-groups__member-input"
|
||
placeholder="Nom ou adresse Duniter"
|
||
@keydown.enter="addMember(g.id)"
|
||
/>
|
||
<button class="proto-groups__member-btn" :disabled="addingMember || !newMemberName.trim()" @click="addMember(g.id)">
|
||
<UIcon v-if="addingMember" name="i-lucide-loader-2" class="animate-spin text-xs" />
|
||
<UIcon v-else name="i-lucide-user-plus" class="text-xs" />
|
||
Ajouter
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</Transition>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Operational protocols (always visible, frontend-only data) -->
|
||
<div class="proto-ops">
|
||
<h3 class="proto-ops__title">
|
||
<UIcon name="i-lucide-git-branch" class="text-sm" />
|
||
Protocoles opérationnels
|
||
<span class="proto-ops__count">{{ operationalProtocols.length }}</span>
|
||
</h3>
|
||
|
||
<div
|
||
v-for="op in operationalProtocols"
|
||
:key="op.slug"
|
||
class="proto-ops__card"
|
||
>
|
||
<div class="proto-ops__card-head">
|
||
<div class="proto-ops__card-icon">
|
||
<UIcon :name="op.icon" class="text-lg" />
|
||
</div>
|
||
<div class="proto-ops__card-info">
|
||
<h4 class="proto-ops__card-name">{{ op.name }}</h4>
|
||
<p class="proto-ops__card-desc">{{ op.description }}</p>
|
||
<span class="proto-ops__card-meta">{{ op.instancesLabel }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Linked references -->
|
||
<div v-if="op.linkedRefs.length > 0" class="proto-ops__refs">
|
||
<NuxtLink
|
||
v-for="ref in op.linkedRefs"
|
||
:key="ref.to"
|
||
:to="ref.to"
|
||
class="proto-ops__ref"
|
||
:class="`proto-ops__ref--${ref.kind}`"
|
||
>
|
||
<UIcon :name="ref.icon" class="text-xs" />
|
||
<span>{{ ref.label }}</span>
|
||
<UIcon name="i-lucide-arrow-right" class="text-xs proto-ops__ref-arrow" />
|
||
</NuxtLink>
|
||
</div>
|
||
|
||
<!-- Step timeline -->
|
||
<div class="proto-ops__timeline">
|
||
<div
|
||
v-for="(step, idx) in op.steps"
|
||
:key="idx"
|
||
class="proto-ops__step"
|
||
>
|
||
<div class="proto-ops__step-dot" :class="`proto-ops__step-dot--${step.type}`">
|
||
<UIcon :name="step.icon" class="text-xs" />
|
||
</div>
|
||
<div class="proto-ops__step-body">
|
||
<span class="proto-ops__step-label">{{ step.label }}</span>
|
||
<span class="proto-ops__step-actor">{{ step.actor }}</span>
|
||
</div>
|
||
<div v-if="idx < op.steps.length - 1" class="proto-ops__step-line" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Toolbox sidebar -->
|
||
<template #toolbox>
|
||
<!-- Workflow milestones -->
|
||
<ToolboxSection title="Jalons de protocole" icon="i-lucide-git-branch">
|
||
<WorkflowMilestones />
|
||
</ToolboxSection>
|
||
|
||
<!-- Simulateur -->
|
||
<ToolboxVignette
|
||
title="Simulateur de formules"
|
||
:bullets="['WoT, Smith, TechComm', 'Paramètres en temps réel', 'Visualise les seuils']"
|
||
:actions="[
|
||
{ label: 'Ouvrir', icon: 'i-lucide-calculator', to: '/protocols/formulas', primary: true },
|
||
]"
|
||
/>
|
||
|
||
<!-- n8n Workflows -->
|
||
<ToolboxSection title="Automatisations" icon="i-lucide-workflow">
|
||
<div class="n8n-workflows">
|
||
<div
|
||
v-for="wf in n8nWorkflows"
|
||
:key="wf.name"
|
||
class="n8n-wf"
|
||
>
|
||
<div class="n8n-wf__icon">
|
||
<UIcon :name="wf.icon" class="text-xs" />
|
||
</div>
|
||
<div class="n8n-wf__body">
|
||
<div class="n8n-wf__head">
|
||
<span class="n8n-wf__name">{{ wf.name }}</span>
|
||
<span
|
||
class="n8n-wf__status"
|
||
:class="wf.status === 'actif' ? 'n8n-wf__status--active' : 'n8n-wf__status--demo'"
|
||
>
|
||
{{ wf.status }}
|
||
</span>
|
||
</div>
|
||
<p class="n8n-wf__desc">{{ wf.description }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</ToolboxSection>
|
||
|
||
<!-- Meta-gouvernance -->
|
||
<ToolboxVignette
|
||
title="Méta-gouvernance"
|
||
:bullets="['Les formules sont soumises au vote', 'Seuils modifiables collectivement', 'Transparence totale']"
|
||
:actions="[
|
||
{ label: 'Démarrer', icon: 'i-lucide-play', emit: 'meta', primary: true },
|
||
]"
|
||
/>
|
||
</template>
|
||
</SectionLayout>
|
||
|
||
<!-- Create protocol modal -->
|
||
<UModal v-model:open="showCreateModal">
|
||
<template #content>
|
||
<div class="proto-modal">
|
||
<h3 class="proto-modal__title">Nouveau protocole de vote</h3>
|
||
|
||
<div class="proto-modal__fields">
|
||
<div class="proto-modal__field">
|
||
<label class="proto-modal__label">Nom du protocole</label>
|
||
<input
|
||
v-model="newProtocol.name"
|
||
type="text"
|
||
class="proto-modal__input"
|
||
placeholder="Ex: Vote standard G1"
|
||
/>
|
||
</div>
|
||
|
||
<div class="proto-modal__field">
|
||
<label class="proto-modal__label">Description</label>
|
||
<textarea
|
||
v-model="newProtocol.description"
|
||
class="proto-modal__textarea"
|
||
placeholder="Description du protocole..."
|
||
rows="2"
|
||
/>
|
||
</div>
|
||
|
||
<div class="proto-modal__field">
|
||
<label class="proto-modal__label">Type de vote</label>
|
||
<USelect
|
||
v-model="newProtocol.vote_type"
|
||
:items="voteTypeOptions"
|
||
value-key="value"
|
||
/>
|
||
</div>
|
||
|
||
<div class="proto-modal__field">
|
||
<label class="proto-modal__label">Configuration de formule</label>
|
||
<USelect
|
||
v-model="newProtocol.formula_config_id"
|
||
:items="formulaOptions"
|
||
placeholder="Sélectionnez une formule..."
|
||
value-key="value"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="proto-modal__actions">
|
||
<button class="proto-modal__cancel" @click="showCreateModal = false">
|
||
Annuler
|
||
</button>
|
||
<button
|
||
class="proto-modal__submit"
|
||
:disabled="!newProtocol.name.trim() || !newProtocol.formula_config_id || creating"
|
||
@click="createProtocol"
|
||
>
|
||
<UIcon v-if="creating" name="i-lucide-loader-2" class="animate-spin text-xs" />
|
||
<span>Créer</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</UModal>
|
||
|
||
<!-- Create group modal -->
|
||
<UModal v-model:open="showGroupModal">
|
||
<template #content>
|
||
<div class="proto-modal">
|
||
<h3 class="proto-modal__title">Nouveau groupe d'identités</h3>
|
||
<div class="proto-modal__fields">
|
||
<div class="proto-modal__field">
|
||
<label class="proto-modal__label">Nom du groupe</label>
|
||
<input
|
||
v-model="newGroupName"
|
||
type="text"
|
||
lang="fr"
|
||
spellcheck="true"
|
||
class="proto-modal__input"
|
||
placeholder="Ex: Comité technique, Forgerons actifs…"
|
||
/>
|
||
</div>
|
||
<div class="proto-modal__field">
|
||
<label class="proto-modal__label">Description <span class="proto-modal__optional">(optionnel)</span></label>
|
||
<textarea
|
||
v-model="newGroupDesc"
|
||
class="proto-modal__textarea"
|
||
lang="fr"
|
||
spellcheck="true"
|
||
placeholder="Rôle ou périmètre de ce groupe…"
|
||
rows="2"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="proto-modal__actions">
|
||
<button class="proto-modal__cancel" @click="showGroupModal = false">Annuler</button>
|
||
<button
|
||
class="proto-modal__submit"
|
||
:disabled="!newGroupName.trim() || creatingGroup"
|
||
@click="createGroup"
|
||
>
|
||
<UIcon v-if="creatingGroup" name="i-lucide-loader-2" class="animate-spin" />
|
||
<UIcon v-else name="i-lucide-users-round" />
|
||
Créer le groupe
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</UModal>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* --- Search --- */
|
||
.proto-search {
|
||
flex: 1;
|
||
min-width: 10rem;
|
||
padding: 0.625rem 1rem;
|
||
font-size: 0.9375rem;
|
||
color: var(--mood-text);
|
||
background: var(--mood-accent-soft);
|
||
border-radius: 12px;
|
||
outline: none;
|
||
transition: box-shadow 0.15s ease;
|
||
}
|
||
.proto-search:focus {
|
||
box-shadow: 0 0 0 3px var(--mood-accent-soft);
|
||
}
|
||
.proto-search::placeholder {
|
||
color: var(--mood-text-muted);
|
||
opacity: 0.4;
|
||
}
|
||
|
||
.proto-add-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.375rem;
|
||
padding: 0.625rem 1.25rem;
|
||
font-size: 0.875rem;
|
||
font-weight: 700;
|
||
color: var(--mood-accent-text);
|
||
background: var(--mood-accent);
|
||
border-radius: 20px;
|
||
cursor: pointer;
|
||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||
}
|
||
.proto-add-btn:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 4px 12px var(--mood-shadow);
|
||
}
|
||
|
||
/* --- States --- */
|
||
.proto-loading { display: flex; flex-direction: column; gap: 0.75rem; }
|
||
.proto-error {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
padding: 1rem 1.25rem;
|
||
font-size: 0.9375rem;
|
||
font-weight: 600;
|
||
color: var(--mood-error);
|
||
background: rgba(196, 43, 43, 0.08);
|
||
border-radius: 16px;
|
||
}
|
||
.proto-empty {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
padding: 3rem;
|
||
text-align: center;
|
||
color: var(--mood-text-muted);
|
||
font-size: 1rem;
|
||
}
|
||
|
||
/* --- Protocol list --- */
|
||
.proto-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.proto-item {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 0.75rem;
|
||
padding: 1rem;
|
||
background: var(--mood-surface);
|
||
border-radius: 16px;
|
||
text-decoration: none;
|
||
cursor: pointer;
|
||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||
}
|
||
|
||
@media (min-width: 640px) {
|
||
.proto-item {
|
||
gap: 1rem;
|
||
padding: 1.25rem;
|
||
}
|
||
}
|
||
|
||
.proto-item:hover {
|
||
transform: translateY(-3px);
|
||
box-shadow: 0 8px 24px var(--mood-shadow);
|
||
}
|
||
|
||
.proto-item:active {
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.proto-item__left { flex-shrink: 0; }
|
||
|
||
.proto-item__icon {
|
||
width: 2.25rem;
|
||
height: 2.25rem;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 12px;
|
||
background: var(--mood-accent-soft);
|
||
color: var(--mood-accent);
|
||
font-size: 1rem;
|
||
}
|
||
|
||
@media (min-width: 640px) {
|
||
.proto-item__icon {
|
||
width: 2.75rem;
|
||
height: 2.75rem;
|
||
font-size: 1.25rem;
|
||
}
|
||
}
|
||
|
||
.proto-item__body {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.25rem;
|
||
}
|
||
|
||
@media (min-width: 640px) {
|
||
.proto-item__body {
|
||
gap: 0.375rem;
|
||
}
|
||
}
|
||
|
||
.proto-item__head {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 0.375rem;
|
||
}
|
||
|
||
@media (min-width: 640px) {
|
||
.proto-item__head {
|
||
gap: 0.625rem;
|
||
}
|
||
}
|
||
|
||
.proto-item__name {
|
||
font-size: 0.9375rem;
|
||
font-weight: 700;
|
||
color: var(--mood-text);
|
||
margin: 0;
|
||
}
|
||
|
||
@media (min-width: 640px) {
|
||
.proto-item__name {
|
||
font-size: 1.0625rem;
|
||
}
|
||
}
|
||
|
||
.proto-item__type {
|
||
font-size: 0.6875rem;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
padding: 3px 10px;
|
||
border-radius: 20px;
|
||
background: var(--mood-status-vote-bg);
|
||
color: var(--mood-status-vote);
|
||
}
|
||
.proto-item__type--nuanced {
|
||
background: var(--mood-status-prepa-bg);
|
||
color: var(--mood-status-prepa);
|
||
}
|
||
|
||
.proto-item__desc {
|
||
font-size: 0.875rem;
|
||
color: var(--mood-text-muted);
|
||
line-height: 1.5;
|
||
margin: 0;
|
||
}
|
||
|
||
.proto-item__meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.375rem;
|
||
margin-top: 0.125rem;
|
||
font-size: 0.75rem;
|
||
color: var(--mood-text-muted);
|
||
}
|
||
|
||
@media (min-width: 640px) {
|
||
.proto-item__meta {
|
||
gap: 0.5rem;
|
||
margin-top: 0.25rem;
|
||
font-size: 0.8125rem;
|
||
}
|
||
}
|
||
|
||
.proto-item__params {
|
||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||
font-weight: 700;
|
||
color: var(--mood-accent);
|
||
background: var(--mood-accent-soft);
|
||
padding: 2px 8px;
|
||
border-radius: 20px;
|
||
font-size: 0.75rem;
|
||
}
|
||
|
||
.proto-item__formula {
|
||
font-weight: 600;
|
||
}
|
||
|
||
.proto-item__detail {
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.proto-item__arrow {
|
||
flex-shrink: 0;
|
||
color: var(--mood-text-muted);
|
||
opacity: 0.3;
|
||
margin-top: 0.375rem;
|
||
transition: opacity 0.12s;
|
||
}
|
||
.proto-item:hover .proto-item__arrow {
|
||
opacity: 1;
|
||
color: var(--mood-accent);
|
||
}
|
||
|
||
/* --- Formulas --- */
|
||
.proto-formulas {
|
||
margin-top: 0.75rem;
|
||
}
|
||
|
||
@media (min-width: 640px) {
|
||
.proto-formulas {
|
||
margin-top: 1rem;
|
||
}
|
||
}
|
||
|
||
.proto-formulas__title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
font-size: 0.9375rem;
|
||
font-weight: 700;
|
||
color: var(--mood-text);
|
||
margin: 0 0 0.5rem;
|
||
}
|
||
|
||
@media (min-width: 640px) {
|
||
.proto-formulas__title {
|
||
font-size: 1rem;
|
||
margin: 0 0 0.75rem;
|
||
}
|
||
}
|
||
|
||
.proto-formulas__count {
|
||
font-size: 0.6875rem;
|
||
font-weight: 700;
|
||
background: var(--mood-accent-soft);
|
||
color: var(--mood-accent);
|
||
padding: 2px 8px;
|
||
border-radius: 20px;
|
||
}
|
||
|
||
.proto-formulas__table-wrap {
|
||
background: var(--mood-surface);
|
||
border-radius: 16px;
|
||
overflow-x: auto;
|
||
}
|
||
|
||
.proto-formulas__table {
|
||
width: 100%;
|
||
font-size: 0.875rem;
|
||
border-collapse: collapse;
|
||
}
|
||
|
||
.proto-formulas__table th {
|
||
text-align: left;
|
||
padding: 0.5rem 0.625rem;
|
||
font-weight: 700;
|
||
font-size: 0.6875rem;
|
||
color: var(--mood-text-muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
@media (min-width: 640px) {
|
||
.proto-formulas__table th {
|
||
padding: 0.75rem 1rem;
|
||
font-size: 0.75rem;
|
||
}
|
||
}
|
||
|
||
.proto-formulas__table td {
|
||
padding: 0.5rem 0.625rem;
|
||
color: var(--mood-text-muted);
|
||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||
font-size: 0.75rem;
|
||
}
|
||
|
||
@media (min-width: 640px) {
|
||
.proto-formulas__table td {
|
||
padding: 0.75rem 1rem;
|
||
font-size: 0.8125rem;
|
||
}
|
||
}
|
||
|
||
.proto-formulas__name {
|
||
font-weight: 700;
|
||
color: var(--mood-text) !important;
|
||
font-family: inherit !important;
|
||
}
|
||
|
||
.n8n-workflows {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.375rem;
|
||
margin-top: 0.25rem;
|
||
}
|
||
|
||
.n8n-wf {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
padding: 0.5rem;
|
||
border-radius: 10px;
|
||
transition: background 0.1s;
|
||
}
|
||
.n8n-wf:hover {
|
||
background: var(--mood-surface);
|
||
}
|
||
|
||
.n8n-wf__icon {
|
||
width: 1.75rem;
|
||
height: 1.75rem;
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 8px;
|
||
background: var(--mood-surface);
|
||
color: var(--mood-tertiary, var(--mood-accent));
|
||
}
|
||
|
||
.n8n-wf__body {
|
||
min-width: 0;
|
||
flex: 1;
|
||
}
|
||
|
||
.n8n-wf__head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.375rem;
|
||
}
|
||
|
||
.n8n-wf__name {
|
||
font-size: 0.75rem;
|
||
font-weight: 700;
|
||
color: var(--mood-text);
|
||
}
|
||
|
||
.n8n-wf__status {
|
||
font-size: 0.5625rem;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
padding: 2px 6px;
|
||
border-radius: 20px;
|
||
}
|
||
.n8n-wf__status--active {
|
||
background: var(--mood-status-vigueur-bg);
|
||
color: var(--mood-success);
|
||
}
|
||
.n8n-wf__status--demo {
|
||
background: var(--mood-surface);
|
||
color: var(--mood-text-muted);
|
||
}
|
||
|
||
.n8n-wf__desc {
|
||
font-size: 0.6875rem;
|
||
color: var(--mood-text-muted);
|
||
line-height: 1.4;
|
||
margin: 0;
|
||
}
|
||
|
||
/* --- Operational protocols --- */
|
||
.proto-ops {
|
||
margin-top: 1.5rem;
|
||
}
|
||
|
||
.proto-ops__title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
font-size: 0.9375rem;
|
||
font-weight: 700;
|
||
color: var(--mood-text);
|
||
margin: 0 0 0.75rem;
|
||
}
|
||
|
||
.proto-ops__count {
|
||
font-size: 0.6875rem;
|
||
font-weight: 700;
|
||
background: var(--mood-accent-soft);
|
||
color: var(--mood-accent);
|
||
padding: 2px 8px;
|
||
border-radius: 20px;
|
||
}
|
||
|
||
.proto-ops__card {
|
||
background: var(--mood-surface);
|
||
border-radius: 16px;
|
||
padding: 1.25rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.proto-ops__card-head {
|
||
display: flex;
|
||
gap: 0.75rem;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.proto-ops__card-icon {
|
||
width: 2.75rem;
|
||
height: 2.75rem;
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 12px;
|
||
background: var(--mood-accent-soft);
|
||
color: var(--mood-accent);
|
||
}
|
||
|
||
.proto-ops__card-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.proto-ops__card-name {
|
||
font-size: 1.0625rem;
|
||
font-weight: 800;
|
||
color: var(--mood-text);
|
||
margin: 0;
|
||
}
|
||
|
||
.proto-ops__card-desc {
|
||
font-size: 0.8125rem;
|
||
color: var(--mood-text-muted);
|
||
line-height: 1.4;
|
||
margin: 0.125rem 0 0;
|
||
}
|
||
|
||
.proto-ops__card-meta {
|
||
font-size: 0.6875rem;
|
||
font-weight: 600;
|
||
color: var(--mood-accent);
|
||
opacity: 0.7;
|
||
}
|
||
|
||
/* Linked references */
|
||
.proto-ops__refs {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.proto-ops__ref {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.375rem;
|
||
padding: 0.375rem 0.75rem;
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
border-radius: 20px;
|
||
text-decoration: none;
|
||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||
}
|
||
|
||
.proto-ops__ref:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 2px 8px var(--mood-shadow);
|
||
}
|
||
|
||
.proto-ops__ref--document {
|
||
background: color-mix(in srgb, var(--mood-accent) 12%, transparent);
|
||
color: var(--mood-accent);
|
||
}
|
||
|
||
.proto-ops__ref--decision {
|
||
background: color-mix(in srgb, var(--mood-secondary, var(--mood-accent)) 12%, transparent);
|
||
color: var(--mood-secondary, var(--mood-accent));
|
||
}
|
||
|
||
.proto-ops__ref-arrow {
|
||
opacity: 0.4;
|
||
transition: opacity 0.12s;
|
||
}
|
||
|
||
.proto-ops__ref:hover .proto-ops__ref-arrow {
|
||
opacity: 1;
|
||
}
|
||
|
||
/* Timeline */
|
||
.proto-ops__timeline {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0;
|
||
padding-left: 0.25rem;
|
||
}
|
||
|
||
.proto-ops__step {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.625rem;
|
||
position: relative;
|
||
padding: 0.375rem 0;
|
||
}
|
||
|
||
.proto-ops__step-dot {
|
||
width: 1.75rem;
|
||
height: 1.75rem;
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 50%;
|
||
background: var(--mood-accent-soft);
|
||
color: var(--mood-accent);
|
||
z-index: 1;
|
||
}
|
||
|
||
.proto-ops__step-dot--on_chain {
|
||
background: color-mix(in srgb, var(--mood-success) 15%, transparent);
|
||
color: var(--mood-success);
|
||
}
|
||
|
||
.proto-ops__step-dot--checklist {
|
||
background: color-mix(in srgb, var(--mood-warning) 15%, transparent);
|
||
color: var(--mood-warning);
|
||
}
|
||
|
||
.proto-ops__step-dot--certification {
|
||
background: color-mix(in srgb, var(--mood-secondary) 15%, transparent);
|
||
color: var(--mood-secondary, var(--mood-accent));
|
||
}
|
||
|
||
.proto-ops__step-body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.proto-ops__step-label {
|
||
font-size: 0.8125rem;
|
||
font-weight: 700;
|
||
color: var(--mood-text);
|
||
}
|
||
|
||
.proto-ops__step-actor {
|
||
font-size: 0.6875rem;
|
||
color: var(--mood-text-muted);
|
||
}
|
||
|
||
.proto-ops__step-line {
|
||
position: absolute;
|
||
left: calc(0.875rem - 1px);
|
||
top: calc(0.375rem + 1.75rem);
|
||
width: 2px;
|
||
height: calc(100% - 1.75rem + 0.375rem);
|
||
background: color-mix(in srgb, var(--mood-accent) 15%, transparent);
|
||
}
|
||
|
||
/* --- Modal --- */
|
||
.proto-modal {
|
||
padding: 1.25rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1.25rem;
|
||
}
|
||
|
||
@media (min-width: 640px) {
|
||
.proto-modal {
|
||
padding: 2rem;
|
||
gap: 1.5rem;
|
||
}
|
||
}
|
||
|
||
.proto-modal__title {
|
||
font-size: 1.125rem;
|
||
font-weight: 800;
|
||
color: var(--mood-text);
|
||
margin: 0;
|
||
}
|
||
|
||
@media (min-width: 640px) {
|
||
.proto-modal__title {
|
||
font-size: 1.375rem;
|
||
}
|
||
}
|
||
|
||
.proto-modal__fields {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.proto-modal__field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.375rem;
|
||
}
|
||
|
||
.proto-modal__label {
|
||
font-size: 0.8125rem;
|
||
font-weight: 700;
|
||
color: var(--mood-text-muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
|
||
.proto-modal__input,
|
||
.proto-modal__textarea {
|
||
width: 100%;
|
||
padding: 0.75rem 1rem;
|
||
font-size: 0.9375rem;
|
||
color: var(--mood-text);
|
||
background: var(--mood-accent-soft);
|
||
border-radius: 12px;
|
||
outline: none;
|
||
transition: box-shadow 0.15s ease;
|
||
font-family: inherit;
|
||
resize: vertical;
|
||
}
|
||
.proto-modal__input:focus,
|
||
.proto-modal__textarea:focus {
|
||
box-shadow: 0 0 0 3px var(--mood-accent-soft);
|
||
}
|
||
|
||
.proto-modal__actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.proto-modal__cancel {
|
||
padding: 0.625rem 1.25rem;
|
||
font-size: 0.875rem;
|
||
font-weight: 600;
|
||
color: var(--mood-text-muted);
|
||
background: none;
|
||
border-radius: 12px;
|
||
cursor: pointer;
|
||
}
|
||
.proto-modal__cancel:hover { color: var(--mood-text); background: var(--mood-accent-soft); }
|
||
|
||
.proto-modal__submit {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.375rem;
|
||
padding: 0.625rem 1.5rem;
|
||
font-size: 0.9375rem;
|
||
font-weight: 700;
|
||
color: var(--mood-accent-text);
|
||
background: var(--mood-accent);
|
||
border-radius: 16px;
|
||
cursor: pointer;
|
||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||
}
|
||
.proto-modal__submit:hover:not(:disabled) {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 4px 12px var(--mood-shadow);
|
||
}
|
||
.proto-modal__submit:disabled { opacity: 0.4; cursor: not-allowed; }
|
||
.proto-modal__optional { font-size: 0.8125rem; opacity: 0.55; font-weight: 400; }
|
||
|
||
/* --- Groups --- */
|
||
.proto-groups {
|
||
margin-top: 2rem;
|
||
}
|
||
.proto-groups__title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
font-size: 0.9375rem;
|
||
font-weight: 700;
|
||
color: var(--mood-text);
|
||
margin-bottom: 1rem;
|
||
}
|
||
.proto-groups__count {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-width: 1.375rem;
|
||
height: 1.375rem;
|
||
padding: 0 0.375rem;
|
||
font-size: 0.75rem;
|
||
font-weight: 700;
|
||
background: var(--mood-accent-soft);
|
||
color: var(--mood-accent);
|
||
border-radius: 20px;
|
||
}
|
||
.proto-groups__add-btn {
|
||
margin-left: auto;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.25rem;
|
||
font-size: 0.8125rem;
|
||
font-weight: 600;
|
||
color: var(--mood-accent);
|
||
background: var(--mood-accent-soft);
|
||
border-radius: 16px;
|
||
padding: 0.25rem 0.75rem;
|
||
cursor: pointer;
|
||
transition: transform 0.1s ease;
|
||
}
|
||
.proto-groups__add-btn:hover { transform: translateY(-1px); }
|
||
.proto-groups__empty {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.625rem;
|
||
padding: 1rem 1.25rem;
|
||
color: var(--mood-muted);
|
||
background: var(--mood-surface);
|
||
border-radius: 14px;
|
||
font-size: 0.875rem;
|
||
}
|
||
.proto-groups__list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.5rem;
|
||
}
|
||
.proto-groups__item {
|
||
background: var(--mood-surface);
|
||
border-radius: 14px;
|
||
overflow: hidden;
|
||
}
|
||
.proto-groups__item-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0.75rem 1rem;
|
||
cursor: pointer;
|
||
transition: background 0.12s ease;
|
||
}
|
||
.proto-groups__item-head:hover { background: var(--mood-hover); }
|
||
.proto-groups__item-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
color: var(--mood-text);
|
||
}
|
||
.proto-groups__item-name { font-weight: 600; font-size: 0.9375rem; }
|
||
.proto-groups__item-count {
|
||
font-size: 0.75rem;
|
||
color: var(--mood-muted);
|
||
background: var(--mood-accent-soft);
|
||
border-radius: 12px;
|
||
padding: 0.125rem 0.5rem;
|
||
}
|
||
.proto-groups__item-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
color: var(--mood-muted);
|
||
}
|
||
.proto-groups__delete-btn {
|
||
color: var(--mood-danger, #e53e3e);
|
||
opacity: 0.5;
|
||
cursor: pointer;
|
||
transition: opacity 0.1s;
|
||
}
|
||
.proto-groups__delete-btn:hover { opacity: 1; }
|
||
.proto-groups__detail {
|
||
padding: 0.75rem 1rem 1rem;
|
||
border-top: 1px solid var(--mood-border, rgba(0,0,0,0.06));
|
||
}
|
||
.proto-groups__detail-desc {
|
||
font-size: 0.875rem;
|
||
color: var(--mood-muted);
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
.proto-groups__members-loading {
|
||
display: flex;
|
||
justify-content: center;
|
||
padding: 0.5rem;
|
||
color: var(--mood-muted);
|
||
}
|
||
.proto-groups__members {
|
||
list-style: none;
|
||
margin: 0 0 0.75rem;
|
||
padding: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.25rem;
|
||
}
|
||
.proto-groups__member {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
font-size: 0.875rem;
|
||
color: var(--mood-text);
|
||
padding: 0.375rem 0.5rem;
|
||
background: var(--mood-bg);
|
||
border-radius: 8px;
|
||
}
|
||
.proto-groups__member--empty { color: var(--mood-muted); font-style: italic; }
|
||
.proto-groups__member-remove {
|
||
margin-left: auto;
|
||
color: var(--mood-danger, #e53e3e);
|
||
opacity: 0.4;
|
||
cursor: pointer;
|
||
transition: opacity 0.1s;
|
||
}
|
||
.proto-groups__member-remove:hover { opacity: 1; }
|
||
.proto-groups__add-member {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
align-items: center;
|
||
}
|
||
.proto-groups__member-input {
|
||
flex: 1;
|
||
padding: 0.4375rem 0.75rem;
|
||
font-size: 0.875rem;
|
||
color: var(--mood-text);
|
||
background: var(--mood-bg);
|
||
border-radius: 10px;
|
||
outline: none;
|
||
}
|
||
.proto-groups__member-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.25rem;
|
||
padding: 0.4375rem 0.875rem;
|
||
font-size: 0.8125rem;
|
||
font-weight: 600;
|
||
color: var(--mood-accent-text);
|
||
background: var(--mood-accent);
|
||
border-radius: 14px;
|
||
cursor: pointer;
|
||
transition: transform 0.1s ease;
|
||
white-space: nowrap;
|
||
}
|
||
.proto-groups__member-btn:hover:not(:disabled) { transform: translateY(-1px); }
|
||
.proto-groups__member-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||
</style>
|