@tekkare/romulus 0.1.231 → 0.1.233

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.16.0"
6
6
  },
7
- "version": "0.1.230",
7
+ "version": "0.1.232",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "0.8.4",
10
10
  "unbuild": "2.0.0"
@@ -0,0 +1,79 @@
1
+ <!--
2
+ RmCopyCode - un identifiant (RPPS, FINESS, NCT, EUCT, PMID...) qu'on copie
3
+ d'un clic.
4
+
5
+ Ces codes sont ce que l'utilisateur recopie dans un autre outil : les
6
+ laisser en pastille grise au bout de la ligne de tags les noyait parmi les
7
+ statuts. Le composant les sort avec leur libelle (« RPPS · 1000... ») pour
8
+ qu'on sache quel code on lit sans connaitre son format.
9
+
10
+ Le clic ne remonte pas : posee dans une carte cliquable (RmResultCard), la
11
+ copie ouvrirait la fiche. Retour visuel obligatoire : coche verte deux
12
+ secondes, annoncee aux lecteurs d'ecran, sinon l'utilisateur reclique. Pas
13
+ de coche si la copie a echoue, elle mentirait.
14
+
15
+ Pas d'equivalent Auriga : nouveau composant.
16
+ -->
17
+ <script setup lang="ts">
18
+ import { computed, ref, onUnmounted } from 'vue'
19
+ import { rmCopyText } from '../utils/clipboard'
20
+
21
+ const props = withDefaults(defineProps<{
22
+ /** Le code copie, tel quel. */
23
+ value: string
24
+ /** Nom du code (« RPPS », « FINESS »). Affiche, jamais copie. */
25
+ label?: string
26
+ lang?: 'fr' | 'en'
27
+ }>(), {
28
+ label: '',
29
+ lang: 'fr',
30
+ })
31
+
32
+ const emit = defineEmits<{ copy: [value: string] }>()
33
+
34
+ const WORDS = {
35
+ fr: { copy: 'Copier', copied: 'Copié' },
36
+ en: { copy: 'Copy', copied: 'Copied' },
37
+ }
38
+ const t = computed(() => WORDS[props.lang] ?? WORDS.fr)
39
+
40
+ const copied = ref(false)
41
+ let timer: ReturnType<typeof setTimeout> | undefined
42
+
43
+ async function copy() {
44
+ if (!(await rmCopyText(props.value))) return
45
+ copied.value = true
46
+ clearTimeout(timer)
47
+ timer = setTimeout(() => (copied.value = false), 2000)
48
+ emit('copy', props.value)
49
+ }
50
+
51
+ onUnmounted(() => clearTimeout(timer))
52
+
53
+ const name = computed(() => [props.label, props.value].filter(Boolean).join(' '))
54
+ </script>
55
+
56
+ <template>
57
+ <button
58
+ type="button"
59
+ class="rm-copy-code"
60
+ :class="{ 'rm-copy-code--copied': copied }"
61
+ :aria-label="`${copied ? t.copied : t.copy} ${name}`"
62
+ :title="copied ? t.copied : t.copy"
63
+ @click.stop="copy"
64
+ >
65
+ <span v-if="label" class="rm-copy-code__label">{{ label }}</span>
66
+ <span class="rm-copy-code__value">{{ value }}</span>
67
+ <RmIcon
68
+ class="rm-copy-code__icon"
69
+ :name="copied ? 'Check' : 'Copy'"
70
+ :size="14"
71
+ :weight="copied ? 'bold' : 'regular'"
72
+ />
73
+ <span class="rm-copy-code__live" aria-live="polite">{{ copied ? t.copied : '' }}</span>
74
+ </button>
75
+ </template>
76
+
77
+ <style scoped>
78
+ .rm-copy-code{align-items:center;background:var(--bg-surface-2);border:1px solid var(--border-default);border-radius:var(--radius-sm);color:var(--text-primary);cursor:pointer;display:inline-flex;font-family:var(--font-sans);font-size:var(--font-13);gap:var(--space-8);line-height:1.4;max-width:100%;padding:var(--space-2) var(--space-8);transition:background var(--duration-fast) var(--ease-out),border-color var(--duration-fast) var(--ease-out),color var(--duration-fast) var(--ease-out)}.rm-copy-code:hover{background:var(--bg-hover);border-color:var(--color-violet-pale)}.rm-copy-code:focus-visible{outline:2px solid var(--color-primary);outline-offset:2px}.rm-copy-code__label{color:var(--text-muted);font-size:var(--font-11);font-weight:var(--weight-bold);letter-spacing:.08em;text-transform:uppercase}.rm-copy-code__value{font-family:Courier New,monospace;font-weight:var(--weight-bold);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rm-copy-code__icon{color:var(--text-muted);flex-shrink:0}.rm-copy-code--copied,.rm-copy-code--copied:hover{background:var(--color-success-bg);border-color:var(--color-success)}.rm-copy-code--copied .rm-copy-code__icon{color:var(--color-success)}.rm-copy-code__live{clip-path:inset(50%);height:1px;overflow:hidden;position:absolute;white-space:nowrap;width:1px}
79
+ </style>
@@ -0,0 +1,226 @@
1
+ <!--
2
+ RmProjectAddButton - le bouton « Projet » d'une carte ou d'un en-tete.
3
+
4
+ Il range l'element affiche dans le projet actif, ou l'en retire. Sans projet
5
+ actif, le premier clic ouvre le choix du projet : choisir est rare, ranger
6
+ l'est beaucoup moins, donc la question ne se pose qu'une fois (l'app garde
7
+ le projet actif d'un ecran a l'autre).
8
+
9
+ En selection multiple, il n'envoie rien : il coche, et c'est le panneau qui
10
+ envoie la pile. Le meme bouton porte les deux gestes, parce que c'est le
11
+ meme geste pour le lecteur.
12
+
13
+ Le composant ne connait ni l'API ni le projet actif : l'app lui dit l'etat
14
+ (`inProject`, `needsProject`, `selecting`...) et recoit des intentions
15
+ (`toggle`, `choose`, `create`, `unarchive`). `@click.stop` partout : une
16
+ carte entiere cliquable ouvrirait la fiche au lieu de ranger.
17
+
18
+ Une fiche deja retiree de ce projet y est archivee, et l'API refuse de la
19
+ remettre sans qu'on le demande : l'app passe `archived` a vrai, la question
20
+ se pose dans une modale centree, qui arrete le geste.
21
+ -->
22
+ <script setup lang="ts">
23
+ import { computed, ref } from 'vue'
24
+ import { rmProjectSummary, type RmProject } from '../utils/projects'
25
+
26
+ const props = withDefaults(
27
+ defineProps<{
28
+ /** Tous les documents de la carte sont dans le projet actif. */
29
+ inProject?: boolean
30
+ /** Aucun projet actif : le clic ouvre le choix. */
31
+ needsProject?: boolean
32
+ /** Selection multiple en cours : le clic coche. */
33
+ selecting?: boolean
34
+ selected?: boolean
35
+ /** Icone seule, pour une ligne de tableau ou une carte dense. */
36
+ compact?: boolean
37
+ loading?: boolean
38
+ /** Raison du dernier echec, en bulle. */
39
+ error?: string
40
+ /** Projets proposes au choix. */
41
+ projects?: RmProject[]
42
+ projectsLoading?: boolean
43
+ projectsFailed?: boolean
44
+ creating?: boolean
45
+ /** La fiche est archivee dans ce projet (v-model:archived). */
46
+ archived?: boolean
47
+ lang?: 'fr' | 'en'
48
+ }>(),
49
+ {
50
+ inProject: false,
51
+ needsProject: false,
52
+ selecting: false,
53
+ selected: false,
54
+ compact: false,
55
+ loading: false,
56
+ error: '',
57
+ projects: () => [],
58
+ projectsLoading: false,
59
+ projectsFailed: false,
60
+ creating: false,
61
+ archived: false,
62
+ lang: 'fr',
63
+ },
64
+ )
65
+
66
+ const emit = defineEmits<{
67
+ /** Ajouter ou retirer, selon `inProject`. */
68
+ 'toggle': []
69
+ /** Cocher ou decocher, en selection. */
70
+ 'select': []
71
+ /** Le choix s'ouvre : l'app charge la liste des projets. */
72
+ 'pick': []
73
+ /** Projet choisi : il devient l'actif et recoit l'element. */
74
+ 'choose': [projectId: string]
75
+ /** Nouveau projet nomme : a creer, rendre actif et remplir. */
76
+ 'create': [title: string]
77
+ 'unarchive': []
78
+ 'update:archived': [value: boolean]
79
+ }>()
80
+
81
+ const WORDS = {
82
+ fr: {
83
+ project: 'Projet', inProject: 'Dans le projet', add: 'Ajouter au projet', remove: 'Retirer du projet',
84
+ select: 'Sélectionner', selected: 'Sélectionné', addSel: 'Ajouter à la sélection', removeSel: 'Retirer de la sélection',
85
+ choose: 'Choisir un projet', addTo: 'Ajouter à un projet', loading: 'Chargement...',
86
+ failed: 'Les projets ne sont pas accessibles pour le moment.',
87
+ none: 'Aucun projet pour le moment. Nommez le premier ci-dessous.',
88
+ newName: 'Nom du nouveau projet', create: 'Créer',
89
+ archived: 'Fiche archivée', archivedText: 'Cette fiche a été retirée du projet auparavant. La remettre ?',
90
+ cancel: 'Annuler', unarchive: 'Désarchiver',
91
+ },
92
+ en: {
93
+ project: 'Project', inProject: 'In project', add: 'Add to project', remove: 'Remove from project',
94
+ select: 'Select', selected: 'Selected', addSel: 'Add to selection', removeSel: 'Remove from selection',
95
+ choose: 'Choose a project', addTo: 'Add to a project', loading: 'Loading...',
96
+ failed: 'Projects are unavailable right now.',
97
+ none: 'No project yet. Name the first one below.',
98
+ newName: 'New project name', create: 'Create',
99
+ archived: 'Archived record', archivedText: 'This record was removed from the project earlier. Bring it back?',
100
+ cancel: 'Cancel', unarchive: 'Unarchive',
101
+ },
102
+ }
103
+ const T = computed(() => WORDS[props.lang] ?? WORDS.fr)
104
+
105
+ const root = ref<HTMLElement | null>(null)
106
+ const picking = ref(false)
107
+ const draft = ref('')
108
+
109
+ /** Le bouton se distingue des qu'il dit « c'est deja pris en compte ». */
110
+ const highlighted = computed(() => (props.selecting ? props.selected : props.inProject))
111
+
112
+ const icon = computed(() => {
113
+ if (props.selecting) return props.selected ? 'CheckSquare' : 'Square'
114
+ return props.inProject ? 'FolderSimpleMinus' : 'FolderSimplePlus'
115
+ })
116
+
117
+ const ariaLabel = computed(() => {
118
+ if (props.selecting) return props.selected ? T.value.removeSel : T.value.addSel
119
+ return props.inProject ? T.value.remove : T.value.add
120
+ })
121
+
122
+ const label = computed(() => {
123
+ if (props.compact) return ''
124
+ if (props.selecting) return props.selected ? T.value.selected : T.value.select
125
+ return props.inProject ? T.value.inProject : T.value.project
126
+ })
127
+
128
+ const archivedOpen = computed({
129
+ get: () => props.archived,
130
+ set: (value: boolean) => emit('update:archived', value),
131
+ })
132
+
133
+ function onClick() {
134
+ if (props.loading) return
135
+ if (props.selecting) return emit('select')
136
+ if (props.needsProject) {
137
+ picking.value = true
138
+ emit('pick')
139
+ return
140
+ }
141
+ emit('toggle')
142
+ }
143
+
144
+ function choose(id: string) {
145
+ picking.value = false
146
+ emit('choose', id)
147
+ }
148
+
149
+ function create() {
150
+ const title = draft.value.trim()
151
+ if (!title || props.creating) return
152
+ emit('create', title)
153
+ draft.value = ''
154
+ picking.value = false
155
+ }
156
+ </script>
157
+
158
+ <template>
159
+ <div
160
+ ref="root"
161
+ class="rm-project-add"
162
+ :class="{ 'is-in': highlighted, 'is-compact': compact }"
163
+ @click.stop
164
+ >
165
+ <RmButton
166
+ :variant="highlighted ? 'secondary' : 'tertiary'"
167
+ size="md"
168
+ :prefix-icon="icon"
169
+ :icon-only="compact"
170
+ :aria-label="ariaLabel"
171
+ :aria-pressed="highlighted"
172
+ :loading="loading"
173
+ :title="error || ariaLabel"
174
+ @click="onClick"
175
+ >
176
+ {{ label }}
177
+ </RmButton>
178
+
179
+ <RmModal v-model="archivedOpen" size="sm" :label="T.archived">
180
+ <template #header>
181
+ <p class="rm-project-add__title">{{ T.archived }}</p>
182
+ </template>
183
+ <p class="rm-project-add__text">{{ T.archivedText }}</p>
184
+ <template #footer>
185
+ <RmButton variant="tertiary" @click="archivedOpen = false">{{ T.cancel }}</RmButton>
186
+ <RmButton variant="primary" :loading="loading" @click="emit('unarchive')">{{ T.unarchive }}</RmButton>
187
+ </template>
188
+ </RmModal>
189
+
190
+ <!-- Un panneau accroche au bouton plutot qu'une modale : designer le
191
+ projet se fait dans la foulee du clic, ca n'arrete pas la page. -->
192
+ <RmPopover v-model="picking" :anchor="root" :label="T.choose" width="280px">
193
+ <p class="rm-project-add__title">{{ T.addTo }}</p>
194
+ <div class="rm-project-add__list">
195
+ <p v-if="projectsLoading" class="rm-project-add__muted">{{ T.loading }}</p>
196
+ <p v-else-if="projectsFailed" class="rm-project-add__muted">{{ T.failed }}</p>
197
+ <template v-else>
198
+ <button
199
+ v-for="project in projects"
200
+ :key="project.id"
201
+ type="button"
202
+ class="rm-project-add__row"
203
+ @click="choose(project.id)"
204
+ >
205
+ <RmIcon name="FolderSimple" :size="16" />
206
+ <span class="rm-project-add__row-text">
207
+ <span class="rm-project-add__row-title">{{ project.title }}</span>
208
+ <!-- Qui, partage ou non, combien : de quoi choisir entre deux
209
+ projets du meme nom sans les ouvrir. -->
210
+ <span class="rm-project-add__row-meta">{{ rmProjectSummary(project, lang) }}</span>
211
+ </span>
212
+ </button>
213
+ <p v-if="!projects.length" class="rm-project-add__muted">{{ T.none }}</p>
214
+ </template>
215
+ <form class="rm-project-add__create" @submit.prevent="create">
216
+ <RmInput v-model="draft" :placeholder="T.newName" />
217
+ <RmButton type="submit" variant="primary" size="sm" :loading="creating">{{ T.create }}</RmButton>
218
+ </form>
219
+ </div>
220
+ </RmPopover>
221
+ </div>
222
+ </template>
223
+
224
+ <style scoped>
225
+ .rm-project-add{display:inline-flex}.rm-project-add :deep(.rm-button){color:var(--color-primary)}.rm-project-add.is-compact :deep(.rm-button){height:32px;width:32px}.rm-project-add :deep(.rm-button svg){height:20px;width:20px}.rm-project-add.is-in :deep(.rm-button){background:color-mix(in srgb,var(--color-success) 12%,transparent);border-color:color-mix(in srgb,var(--color-success) 45%,transparent);color:var(--color-success)}.rm-project-add__title{font-size:var(--font-13);font-weight:var(--weight-semibold);margin:0 0 var(--space-8)}.rm-project-add__text{font-size:var(--font-14);line-height:1.5;margin:0}.rm-project-add__list{display:flex;flex-direction:column;gap:4px}.rm-project-add__muted{color:var(--rm-shell-text-muted);font-size:13px;margin:0}.rm-project-add__row{align-items:center;background:none;border:1px solid transparent;border-radius:8px;color:inherit;cursor:pointer;display:flex;font:inherit;gap:8px;padding:8px 10px;text-align:left}.rm-project-add__row:focus-visible,.rm-project-add__row:hover{background:color-mix(in srgb,var(--color-primary) 6%,transparent);border-color:color-mix(in srgb,var(--color-primary) 30%,transparent)}.rm-project-add__row-text{display:flex;flex-direction:column;min-width:0}.rm-project-add__row-title{font-size:14px;font-weight:500}.rm-project-add__row-meta{color:var(--rm-shell-text-muted);font-size:12px}.rm-project-add__create{align-items:center;border-top:1px solid var(--rm-shell-border);display:flex;gap:8px;margin-top:12px;padding-top:12px}
226
+ </style>
@@ -0,0 +1,134 @@
1
+ <!--
2
+ RmProjectBanner - le bandeau du projet actif.
3
+
4
+ Choisir un projet change ce que font les boutons de toutes les pages : les
5
+ ajouts partent la-bas. Un etat pareil ne peut pas vivre dans un panneau
6
+ qu'on referme : il se dit en haut du contenu, sur chaque ecran, avec de quoi
7
+ en sortir d'un clic. Le cadre de l'app le pose sous son en-tete, hors de la
8
+ zone qui defile.
9
+
10
+ « Ajouter la page » range tout ce que l'ecran affiche : l'app compte les
11
+ cartes presentes (`addable`) et fait l'envoi. Une partie de la page peut
12
+ revenir archivee : l'app passe la liste dans `archived`, le bandeau nomme
13
+ les fiches et laisse choisir.
14
+ -->
15
+ <script setup lang="ts">
16
+ import { computed } from 'vue'
17
+ import { rmProjectCount, type RmProjectArchivedEntry } from '../utils/projects'
18
+
19
+ const props = withDefaults(
20
+ defineProps<{
21
+ title: string
22
+ /** Lien vers la page du projet. */
23
+ to?: string
24
+ /** Elements du projet que l'app sait afficher. */
25
+ count?: number
26
+ /** Elements de la selection en attente. Absent : pas de selection. */
27
+ selectedCount?: number | null
28
+ /** Cartes de l'ecran pas encore rangees. Zero : pas de bouton. */
29
+ addable?: number
30
+ adding?: boolean
31
+ /** Fiches de la page refusees parce qu'archivees. */
32
+ archived?: RmProjectArchivedEntry[]
33
+ unarchiving?: boolean
34
+ lang?: 'fr' | 'en'
35
+ }>(),
36
+ {
37
+ to: '',
38
+ count: 0,
39
+ selectedCount: null,
40
+ addable: 0,
41
+ adding: false,
42
+ archived: () => [],
43
+ unarchiving: false,
44
+ lang: 'fr',
45
+ },
46
+ )
47
+
48
+ const emit = defineEmits<{
49
+ 'add-page': []
50
+ 'open': []
51
+ /** Cesser de recevoir les ajouts, pas fermer le projet. */
52
+ 'leave': []
53
+ 'unarchive': []
54
+ 'dismiss-archived': []
55
+ }>()
56
+
57
+ const WORDS = {
58
+ fr: {
59
+ goTo: 'Mes ajouts vont dans', selected: 'sélectionnés', addPage: 'Ajouter la page', open: 'Ouvrir', leave: 'Quitter',
60
+ leaveLabel: 'Quitter ce projet', archived: 'Fiches archivées', cancel: 'Annuler',
61
+ archivedText: 'Ces fiches ont été retirées du projet auparavant. Les remettre ?',
62
+ unarchive: (n: number) => `Désarchiver les ${n}`,
63
+ },
64
+ en: {
65
+ goTo: 'Additions go to', selected: 'selected', addPage: 'Add the page', open: 'Open', leave: 'Leave',
66
+ leaveLabel: 'Leave this project', archived: 'Archived records', cancel: 'Cancel',
67
+ archivedText: 'These records were removed from the project earlier. Bring them back?',
68
+ unarchive: (n: number) => `Unarchive ${n}`,
69
+ },
70
+ }
71
+ const T = computed(() => WORDS[props.lang] ?? WORDS.fr)
72
+
73
+ const asking = computed({
74
+ get: () => props.archived.length > 0,
75
+ set: (value: boolean) => {
76
+ if (!value) emit('dismiss-archived')
77
+ },
78
+ })
79
+ </script>
80
+
81
+ <template>
82
+ <div class="rm-project-banner" role="status">
83
+ <span class="rm-project-banner__chip" aria-hidden="true">
84
+ <RmIcon name="FolderSimple" :size="14" />
85
+ </span>
86
+
87
+ <p class="rm-project-banner__text">
88
+ <span class="rm-project-banner__label">{{ T.goTo }}</span>
89
+ <NuxtLink v-if="to" :to="to" class="rm-project-banner__name">{{ title }}</NuxtLink>
90
+ <span v-else class="rm-project-banner__name">{{ title }}</span>
91
+ </p>
92
+
93
+ <RmTag variant="gray">{{ rmProjectCount(count, lang) }}</RmTag>
94
+ <RmTag v-if="selectedCount != null" variant="teal">{{ selectedCount }} {{ T.selected }}</RmTag>
95
+
96
+ <div class="rm-project-banner__actions">
97
+ <RmButton
98
+ v-if="addable"
99
+ variant="secondary"
100
+ size="sm"
101
+ prefix-icon="FolderSimplePlus"
102
+ :loading="adding"
103
+ @click="emit('add-page')"
104
+ >
105
+ {{ T.addPage }}
106
+ <RmTag variant="teal">{{ addable }}</RmTag>
107
+ </RmButton>
108
+ <RmButton variant="secondary" size="sm" prefix-icon="ArrowRight" @click="emit('open')">{{ T.open }}</RmButton>
109
+ <RmButton variant="tertiary" size="sm" prefix-icon="X" :aria-label="T.leaveLabel" @click="emit('leave')">
110
+ {{ T.leave }}
111
+ </RmButton>
112
+ </div>
113
+
114
+ <RmModal v-model="asking" size="sm" :label="T.archived">
115
+ <template #header>
116
+ <p class="rm-project-banner__modal-title">{{ T.archived }}</p>
117
+ </template>
118
+ <p class="rm-project-banner__modal-text">{{ T.archivedText }}</p>
119
+ <ul class="rm-project-banner__modal-list">
120
+ <li v-for="entry in archived" :key="entry.key">{{ entry.label }}</li>
121
+ </ul>
122
+ <template #footer>
123
+ <RmButton variant="tertiary" @click="asking = false">{{ T.cancel }}</RmButton>
124
+ <RmButton variant="primary" :loading="unarchiving" @click="emit('unarchive')">
125
+ {{ T.unarchive(archived.length) }}
126
+ </RmButton>
127
+ </template>
128
+ </RmModal>
129
+ </div>
130
+ </template>
131
+
132
+ <style scoped>
133
+ .rm-project-banner{align-items:center;background:color-mix(in srgb,var(--rm-shell-accent) 16%,var(--rm-shell-surface));border:1px solid color-mix(in srgb,var(--rm-shell-accent) 40%,transparent);border-left:4px solid var(--rm-shell-accent);border-radius:0 0 var(--radius-lg) var(--radius-lg);display:flex;gap:var(--space-12);padding:var(--space-12) var(--space-16)}.rm-project-banner__chip{align-items:center;background:var(--rm-shell-accent);border-radius:50%;color:#fff;display:inline-flex;flex:0 0 auto;height:26px;justify-content:center;width:26px}.rm-project-banner__text{align-items:baseline;display:flex;gap:var(--space-4);margin:0;min-width:0}.rm-project-banner__label{color:var(--rm-shell-text-dark);font-size:var(--font-13)}.rm-project-banner__name{color:var(--rm-shell-accent);font-size:var(--font-14);font-weight:var(--weight-bold);overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}a.rm-project-banner__name:hover{text-decoration:underline}.rm-project-banner__actions{display:flex;gap:var(--space-4);margin-left:auto}.rm-project-banner__modal-title{font-size:var(--font-14);font-weight:var(--weight-semibold);margin:0}.rm-project-banner__modal-text{font-size:var(--font-14);line-height:1.5;margin:0 0 var(--space-8)}.rm-project-banner__modal-list{display:flex;flex-direction:column;font-size:var(--font-13);gap:2px;margin:0;max-height:200px;overflow-y:auto;padding-left:var(--space-16)}@media (max-width:720px){.rm-project-banner{flex-wrap:wrap}.rm-project-banner__actions{margin-left:0}}
134
+ </style>
@@ -0,0 +1,110 @@
1
+ <!--
2
+ RmProjectComments - le fil d'une ressource rangee dans un projet.
3
+
4
+ Un projet se partage : ce qu'on a compris d'une fiche vaut pour l'equipe.
5
+ Le fil vit donc a cote de la fiche dans le projet. Une note du projet qui
6
+ cite la fiche s'y lit aussi, avec les passages qui la citent : ce qu'on
7
+ ecrit d'une fiche ailleurs ne doit pas rester invisible depuis elle.
8
+
9
+ L'app charge le fil, le trie, et publie : le tiroir rend `send` avec le
10
+ texte et se vide quand `sending` repasse a faux sans `error`.
11
+ -->
12
+ <script setup lang="ts">
13
+ import { computed, ref, watch } from 'vue'
14
+ import type { RmProjectCommentEntry } from '../utils/projects'
15
+
16
+ const props = withDefaults(
17
+ defineProps<{
18
+ modelValue: boolean
19
+ /** Titre de la ressource. */
20
+ title: string
21
+ entries: RmProjectCommentEntry[]
22
+ loading?: boolean
23
+ sending?: boolean
24
+ error?: string
25
+ lang?: 'fr' | 'en'
26
+ }>(),
27
+ { loading: false, sending: false, error: '', lang: 'fr' },
28
+ )
29
+
30
+ const emit = defineEmits<{
31
+ 'update:modelValue': [value: boolean]
32
+ 'send': [text: string]
33
+ 'open-note': [noteId: string]
34
+ }>()
35
+
36
+ const WORDS = {
37
+ fr: {
38
+ none: 'Aucun commentaire pour le moment.', cited: 'Citée dans la note', untitled: 'Sans titre',
39
+ private: '(privée, visible par vous seulement)', write: 'Écrire un commentaire', post: 'Publier',
40
+ },
41
+ en: {
42
+ none: 'No comment yet.', cited: 'Cited in the note', untitled: 'Untitled',
43
+ private: '(private, only you see it)', write: 'Write a comment', post: 'Post',
44
+ },
45
+ }
46
+ const T = computed(() => WORDS[props.lang] ?? WORDS.fr)
47
+
48
+ const open = computed({
49
+ get: () => props.modelValue,
50
+ set: (value: boolean) => emit('update:modelValue', value),
51
+ })
52
+
53
+ const draft = ref('')
54
+
55
+ watch(() => props.sending, (now, was) => {
56
+ if (was && !now && !props.error) draft.value = ''
57
+ })
58
+
59
+ function send() {
60
+ const text = draft.value.trim()
61
+ if (!text || props.sending) return
62
+ emit('send', text)
63
+ }
64
+ </script>
65
+
66
+ <template>
67
+ <RmDrawer v-model="open" size="md" :label="title">
68
+ <template #header>
69
+ <p class="rm-project-com__title">{{ title }}</p>
70
+ </template>
71
+
72
+ <div class="rm-project-com">
73
+ <RmLoadingState v-if="loading" spinner />
74
+ <p v-else-if="!entries.length" class="rm-project-com__muted">{{ T.none }}</p>
75
+ <ul v-else class="rm-project-com__list">
76
+ <li v-for="entry in entries" :key="entry.id">
77
+ <p class="rm-project-com__meta">
78
+ <span v-if="entry.author" class="rm-project-com__author">{{ entry.author }}</span>
79
+ <RmDate v-if="entry.date" :date="entry.date" :lang="lang" />
80
+ </p>
81
+ <template v-if="entry.note">
82
+ <p class="rm-project-com__cited">
83
+ {{ T.cited }}
84
+ <button type="button" class="rm-project-com__note" @click="emit('open-note', entry.note.id)">
85
+ {{ entry.note.icon || '📄' }} {{ entry.note.title || T.untitled }}
86
+ </button>
87
+ <span v-if="entry.note.private" class="rm-project-com__private">{{ T.private }}</span>
88
+ </p>
89
+ <blockquote v-for="(excerpt, index) in entry.note.excerpts ?? []" :key="index" class="rm-project-com__excerpt">
90
+ {{ excerpt }}
91
+ </blockquote>
92
+ </template>
93
+ <p v-else class="rm-project-com__text">{{ entry.text }}</p>
94
+ </li>
95
+ </ul>
96
+ </div>
97
+
98
+ <template #footer>
99
+ <form class="rm-project-com__form" @submit.prevent="send">
100
+ <RmTextarea v-model="draft" :rows="3" :placeholder="T.write" />
101
+ <RmButton type="submit" variant="primary" size="sm" :loading="sending">{{ T.post }}</RmButton>
102
+ <p v-if="error" class="rm-project-com__error">{{ error }}</p>
103
+ </form>
104
+ </template>
105
+ </RmDrawer>
106
+ </template>
107
+
108
+ <style scoped>
109
+ .rm-project-com{display:flex;flex-direction:column;gap:16px}.rm-project-com__title{font-size:15px;font-weight:600;margin:0}.rm-project-com__muted{color:var(--rm-shell-text-muted);font-size:13px;margin:0}.rm-project-com__list{display:flex;flex-direction:column;gap:14px;list-style:none;margin:0;padding:0}.rm-project-com__meta{color:var(--rm-shell-text-muted);display:flex;font-size:12px;gap:8px;margin:0 0 2px}.rm-project-com__author{font-weight:600}.rm-project-com__text{font-size:14px;line-height:1.5;margin:0;white-space:pre-wrap}.rm-project-com__cited{font-size:14px;line-height:1.5;margin:0 0 4px}.rm-project-com__note{background:none;border:0;color:var(--rm-shell-accent);cursor:pointer;font:inherit;font-weight:600;padding:0}.rm-project-com__note:hover{text-decoration:underline}.rm-project-com__private{color:var(--rm-shell-text-muted);font-size:12px}.rm-project-com__excerpt{border-left:2px solid var(--rm-shell-border,currentColor);color:var(--rm-shell-text-muted);font-size:13px;line-height:1.5;margin:4px 0 0;padding:2px 0 2px 10px;white-space:pre-wrap}.rm-project-com__form{align-items:flex-start;display:flex;flex-direction:column;gap:8px}.rm-project-com__form>:first-child{align-self:stretch}.rm-project-com__error{color:var(--color-danger);font-size:13px;margin:0}
110
+ </style>