Groupes d'identités : modèle DB, router, store, UI cercles + Protocoles
- 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>
This commit is contained in:
@@ -4,6 +4,7 @@ import type { DecisionCreate } from '~/stores/decisions'
|
||||
const decisions = useDecisionsStore()
|
||||
const protocols = useProtocolsStore()
|
||||
const mandates = useMandatesStore()
|
||||
const groupsStore = useGroupsStore()
|
||||
const { $api } = useApi()
|
||||
|
||||
// ── Wizard steps ──────────────────────────────────────────────────────────────
|
||||
@@ -16,16 +17,57 @@ const isStructural = ref(false)
|
||||
const contextDescription = ref('')
|
||||
|
||||
// Cercles de personnes concernées
|
||||
const circle1 = ref('') // 1er cercle — nommés explicitement (requis hors mandat)
|
||||
const circle2 = ref('') // 2e cercle — optionnel
|
||||
const circle3Open = ref(true) // 3e cercle — toutes personnes se sentant concernées
|
||||
// Chaque cercle peut être en mode texte libre ou groupe prédéfini
|
||||
type CircleMode = 'text' | 'group'
|
||||
const circle1Mode = ref<CircleMode>('text')
|
||||
const circle1Text = ref('')
|
||||
const circle1GroupId = ref<string | null>(null)
|
||||
|
||||
const circle2Mode = ref<CircleMode>('text')
|
||||
const circle2Text = ref('')
|
||||
const circle2GroupId = ref<string | null>(null)
|
||||
|
||||
const circle3Open = ref(true)
|
||||
|
||||
// Résolution du cercle en liste de noms (pour le count et la sauvegarde)
|
||||
function circleNames(mode: CircleMode, text: string, groupId: string | null): string[] {
|
||||
if (mode === 'group' && groupId) {
|
||||
const g = groupsStore.list.find(g => g.id === groupId)
|
||||
return g ? [`[Groupe: ${g.name}]`] : []
|
||||
}
|
||||
return text.split(/[,;\n]/).map(s => s.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
// Affected count derivé des cercles
|
||||
const affectedCountFromCircles = computed(() => {
|
||||
const c1 = circle1.value.split(/[,;\n]/).map(s => s.trim()).filter(Boolean).length
|
||||
const c2 = circle2.value.split(/[,;\n]/).map(s => s.trim()).filter(Boolean).length
|
||||
const base = c1 + c2
|
||||
return base >= 2 ? base : (base === 1 ? 2 : null) // minimum 2
|
||||
const c1names = circleNames(circle1Mode.value, circle1Text.value, circle1GroupId.value)
|
||||
const c2names = circleNames(circle2Mode.value, circle2Text.value, circle2GroupId.value)
|
||||
|
||||
let count = 0
|
||||
if (circle1Mode.value === 'group' && circle1GroupId.value) {
|
||||
const g = groupsStore.list.find(g => g.id === circle1GroupId.value)
|
||||
count += g?.member_count ?? 1
|
||||
} else {
|
||||
count += c1names.length
|
||||
}
|
||||
if (circle2Mode.value === 'group' && circle2GroupId.value) {
|
||||
const g = groupsStore.list.find(g => g.id === circle2GroupId.value)
|
||||
count += g?.member_count ?? 0
|
||||
} else {
|
||||
count += c2names.length
|
||||
}
|
||||
return count >= 2 ? count : (count === 1 ? 2 : null)
|
||||
})
|
||||
|
||||
// Résumé lisible des cercles (pour la sauvegarde dans le contexte)
|
||||
const circlesSummary = computed(() => {
|
||||
const c1 = circle1Mode.value === 'group' && circle1GroupId.value
|
||||
? `Groupe: ${groupsStore.list.find(g => g.id === circle1GroupId.value)?.name ?? '?'}`
|
||||
: circle1Text.value.trim()
|
||||
const c2 = circle2Mode.value === 'group' && circle2GroupId.value
|
||||
? `Groupe: ${groupsStore.list.find(g => g.id === circle2GroupId.value)?.name ?? '?'}`
|
||||
: circle2Text.value.trim()
|
||||
return { c1, c2 }
|
||||
})
|
||||
|
||||
// ── AI conversation ───────────────────────────────────────────────────────────
|
||||
@@ -75,6 +117,7 @@ const activeMandates = computed(() =>
|
||||
|
||||
onMounted(() => {
|
||||
mandates.fetchAll()
|
||||
groupsStore.fetchAll()
|
||||
})
|
||||
|
||||
// ── Modality metadata ─────────────────────────────────────────────────────────
|
||||
@@ -112,9 +155,8 @@ function modalityMeta(slug: string) {
|
||||
const canQualify = computed(() => {
|
||||
if (withinMandate.value === null) return false
|
||||
if (withinMandate.value === false) {
|
||||
// Need at least one name in circle 1
|
||||
const c1 = circle1.value.trim()
|
||||
return c1.length > 0
|
||||
if (circle1Mode.value === 'text') return circle1Text.value.trim().length > 0
|
||||
return circle1GroupId.value !== null
|
||||
}
|
||||
return true
|
||||
})
|
||||
@@ -206,9 +248,10 @@ async function onSubmit() {
|
||||
if (!formData.value.title.trim()) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const { c1, c2 } = circlesSummary.value
|
||||
const circlesContext = [
|
||||
circle1.value.trim() ? `Cercle 1 (directement concernés) : ${circle1.value.trim()}` : '',
|
||||
circle2.value.trim() ? `Cercle 2 (indirectement concernés) : ${circle2.value.trim()}` : '',
|
||||
c1 ? `Cercle 1 (directement concernés) : ${c1}` : '',
|
||||
c2 ? `Cercle 2 (indirectement concernés) : ${c2}` : '',
|
||||
circle3Open.value ? 'Cercle 3 : ouvert à toute personne se sentant concernée.' : '',
|
||||
].filter(Boolean).join('\n')
|
||||
|
||||
@@ -326,13 +369,35 @@ const confidenceLabel: Record<string, string> = {
|
||||
<div class="circle-header">
|
||||
<span class="circle-badge circle-badge--1">1</span>
|
||||
<p class="qualify-label">Premier cercle — personnes directement concernées <span class="qualify-req">*</span></p>
|
||||
<div class="circle-mode-toggle">
|
||||
<button class="circle-mode-btn" :class="{ 'circle-mode-btn--active': circle1Mode === 'text' }" @click="circle1Mode = 'text'">
|
||||
<UIcon name="i-lucide-type" class="text-xs" /> Texte libre
|
||||
</button>
|
||||
<button class="circle-mode-btn" :class="{ 'circle-mode-btn--active': circle1Mode === 'group' }" @click="circle1Mode = 'group'">
|
||||
<UIcon name="i-lucide-users-round" class="text-xs" /> Groupe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="circle1"
|
||||
v-if="circle1Mode === 'text'"
|
||||
v-model="circle1Text"
|
||||
class="qualify-textarea"
|
||||
rows="2"
|
||||
lang="fr"
|
||||
spellcheck="true"
|
||||
placeholder="Alice, Bob, Charlie — une par ligne ou séparées par des virgules"
|
||||
/>
|
||||
<div v-else class="circle-group-select">
|
||||
<select v-model="circle1GroupId" class="qualify-select">
|
||||
<option :value="null">— Sélectionner un groupe —</option>
|
||||
<option v-for="g in groupsStore.list" :key="g.id" :value="g.id">
|
||||
{{ g.name }} ({{ g.member_count }} membre{{ g.member_count > 1 ? 's' : '' }})
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="groupsStore.list.length === 0" class="qualify-hint qualify-hint--warn">
|
||||
Aucun groupe défini. <NuxtLink to="/protocols" class="qualify-link">Créer un groupe</NuxtLink> dans Protocoles & Fonctionnement.
|
||||
</p>
|
||||
</div>
|
||||
<p class="qualify-hint">Personnes dont la décision modifie directement la situation.</p>
|
||||
</div>
|
||||
|
||||
@@ -341,13 +406,32 @@ const confidenceLabel: Record<string, string> = {
|
||||
<div class="circle-header">
|
||||
<span class="circle-badge circle-badge--2">2</span>
|
||||
<p class="qualify-label">Deuxième cercle <span class="qualify-optional">(optionnel)</span></p>
|
||||
<div class="circle-mode-toggle">
|
||||
<button class="circle-mode-btn" :class="{ 'circle-mode-btn--active': circle2Mode === 'text' }" @click="circle2Mode = 'text'">
|
||||
<UIcon name="i-lucide-type" class="text-xs" /> Texte libre
|
||||
</button>
|
||||
<button class="circle-mode-btn" :class="{ 'circle-mode-btn--active': circle2Mode === 'group' }" @click="circle2Mode = 'group'">
|
||||
<UIcon name="i-lucide-users-round" class="text-xs" /> Groupe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="circle2"
|
||||
v-if="circle2Mode === 'text'"
|
||||
v-model="circle2Text"
|
||||
class="qualify-textarea"
|
||||
rows="2"
|
||||
placeholder="Personnes impactées indirectement ou parties prenantes secondaires"
|
||||
lang="fr"
|
||||
spellcheck="true"
|
||||
placeholder="Personnes impactées indirectement ou parties prenantes de second niveau"
|
||||
/>
|
||||
<div v-else class="circle-group-select">
|
||||
<select v-model="circle2GroupId" class="qualify-select">
|
||||
<option :value="null">— Sélectionner un groupe —</option>
|
||||
<option v-for="g in groupsStore.list" :key="g.id" :value="g.id">
|
||||
{{ g.name }} ({{ g.member_count }} membre{{ g.member_count > 1 ? 's' : '' }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cercle 3 -->
|
||||
@@ -396,7 +480,7 @@ const confidenceLabel: Record<string, string> = {
|
||||
<!-- Contexte -->
|
||||
<div v-if="withinMandate !== null" class="qualify-section">
|
||||
<p class="qualify-label">Décrivez brièvement le contexte <span class="qualify-optional">(optionnel — enrichit les propositions de l'IA)</span></p>
|
||||
<textarea v-model="contextDescription" class="qualify-textarea" rows="3" placeholder="En quelques mots : de quoi s'agit-il ? Quels enjeux ? Quelle contrainte ?" />
|
||||
<textarea v-model="contextDescription" class="qualify-textarea" rows="3" lang="fr" spellcheck="true" placeholder="En quelques mots : de quoi s'agit-il ? Quels enjeux ? Quelle contrainte ?" />
|
||||
</div>
|
||||
|
||||
<p v-if="qualifyError" class="qualify-error">{{ qualifyError }}</p>
|
||||
@@ -535,11 +619,11 @@ const confidenceLabel: Record<string, string> = {
|
||||
<div v-if="withinMandate === false" class="qresult__circles">
|
||||
<p class="qresult__circles-title">Cercles concernés</p>
|
||||
<div class="qresult__circles-list">
|
||||
<span v-if="circle1.trim()" class="qresult__circle">
|
||||
<span class="circle-badge circle-badge--1 circle-badge--sm">1</span> {{ circle1.trim() }}
|
||||
<span v-if="circlesSummary.c1" class="qresult__circle">
|
||||
<span class="circle-badge circle-badge--1 circle-badge--sm">1</span> {{ circlesSummary.c1 }}
|
||||
</span>
|
||||
<span v-if="circle2.trim()" class="qresult__circle">
|
||||
<span class="circle-badge circle-badge--2 circle-badge--sm">2</span> {{ circle2.trim() }}
|
||||
<span v-if="circlesSummary.c2" class="qresult__circle">
|
||||
<span class="circle-badge circle-badge--2 circle-badge--sm">2</span> {{ circlesSummary.c2 }}
|
||||
</span>
|
||||
<span class="qresult__circle">
|
||||
<span class="circle-badge circle-badge--3 circle-badge--sm">3</span>
|
||||
@@ -1016,4 +1100,44 @@ const confidenceLabel: Record<string, string> = {
|
||||
.slide-down-enter-active, .slide-down-leave-active { transition: all 0.2s ease; overflow: hidden; }
|
||||
.slide-down-enter-from, .slide-down-leave-to { opacity: 0; max-height: 0; }
|
||||
.slide-down-enter-to, .slide-down-leave-from { max-height: 600px; }
|
||||
|
||||
/* Circle mode toggle */
|
||||
.circle-header { flex-wrap: wrap; }
|
||||
.circle-mode-toggle {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
margin-left: auto;
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 10px;
|
||||
padding: 0.1875rem;
|
||||
}
|
||||
.circle-mode-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-muted);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
.circle-mode-btn--active {
|
||||
background: var(--mood-accent);
|
||||
color: var(--mood-accent-text);
|
||||
}
|
||||
.circle-group-select { margin-bottom: 0.25rem; }
|
||||
.qualify-select {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--mood-text);
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 12px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.qualify-hint--warn { color: var(--mood-warning, var(--mood-muted)); }
|
||||
.qualify-link { color: var(--mood-accent); text-decoration: underline; }
|
||||
</style>
|
||||
|
||||
@@ -24,6 +24,7 @@ onMounted(async () => {
|
||||
await Promise.all([
|
||||
protocols.fetchProtocols(),
|
||||
protocols.fetchFormulas(),
|
||||
groupsStore.fetchAll(),
|
||||
])
|
||||
})
|
||||
|
||||
@@ -174,6 +175,73 @@ const operationalProtocols: OperationalProtocol[] = [
|
||||
},
|
||||
]
|
||||
|
||||
// ── 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 = [
|
||||
{
|
||||
@@ -330,6 +398,79 @@ const n8nWorkflows = [
|
||||
</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">
|
||||
@@ -506,6 +647,51 @@ const n8nWorkflows = [
|
||||
</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>
|
||||
@@ -1185,4 +1371,175 @@ const n8nWorkflows = [
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user