@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.
@@ -0,0 +1,193 @@
1
+ <!--
2
+ RmProjectPanel - le panneau « Projets » du rail.
3
+
4
+ Il repond a une question : ou partent mes ajouts ? Le projet actif s'y dit
5
+ en tete, avec son compte. Chaque carte porte les deux gestes separement,
6
+ ecrits en toutes lettres : y envoyer ses ajouts, ou ouvrir le projet. Les
7
+ confondre obligeait a rendre un projet actif pour aller le lire.
8
+
9
+ On peut y creer un projet d'un nom : il devient l'actif sans quitter la
10
+ page (l'app le fait a la reception de `create`). Le bouton de creation peut
11
+ etre remplace par celui de l'app (slot `new`), qui proposerait une creation
12
+ assistee. Renommer et supprimer restent sur la page du projet.
13
+
14
+ La recherche n'apparait qu'a partir de cinq projets : en dessous, la liste
15
+ se lit d'un coup d'oeil et le champ coute une rangee pour rien.
16
+ -->
17
+ <script setup lang="ts">
18
+ import { computed, ref } from 'vue'
19
+ import { rmProjectCount, rmProjectGroups, rmProjectSummary, type RmProject } from '../utils/projects'
20
+
21
+ const props = withDefaults(
22
+ defineProps<{
23
+ projects: RmProject[]
24
+ activeId?: string | null
25
+ /** Elements du projet actif. */
26
+ activeCount?: number
27
+ loading?: boolean
28
+ failed?: boolean
29
+ /** Creation en cours. */
30
+ creating?: boolean
31
+ /** Lien « Tous mes projets ». Vide : pas de lien. */
32
+ allTo?: string
33
+ lang?: 'fr' | 'en'
34
+ }>(),
35
+ { activeId: null, activeCount: 0, loading: false, failed: false, creating: false, allTo: '', lang: 'fr' },
36
+ )
37
+
38
+ const emit = defineEmits<{
39
+ activate: [projectId: string]
40
+ open: [projectId: string]
41
+ pin: [projectId: string]
42
+ create: [title: string]
43
+ }>()
44
+
45
+ const WORDS = {
46
+ fr: {
47
+ goTo: 'Mes ajouts vont dans', name: 'Nom du projet', placeholder: 'Veille oncologie', required: 'Un nom est requis',
48
+ create: 'Créer', cancel: 'Annuler', loading: 'Chargement...', failed: 'Les projets ne sont pas accessibles pour le moment.',
49
+ search: 'Rechercher un projet', new: 'Nouveau projet', created: 'Créé le', active: 'Projet actif',
50
+ activate: 'Activer ce projet', open: 'Ouvrir', noMatch: 'Aucun projet ne correspond.', none: 'Aucun projet pour le moment.',
51
+ all: 'Tous mes projets', pin: 'Épingler en haut', unpin: 'Désépingler',
52
+ },
53
+ en: {
54
+ goTo: 'Additions go to', name: 'Project name', placeholder: 'Oncology watch', required: 'A name is required',
55
+ create: 'Create', cancel: 'Cancel', loading: 'Loading...', failed: 'Projects are unavailable right now.',
56
+ search: 'Search a project', new: 'New project', created: 'Created the', active: 'Active project',
57
+ activate: 'Activate this project', open: 'Open', noMatch: 'No project matches.', none: 'No project yet.',
58
+ all: 'All my projects', pin: 'Pin to the top', unpin: 'Unpin',
59
+ },
60
+ }
61
+ const T = computed(() => WORDS[props.lang] ?? WORDS.fr)
62
+
63
+ const active = computed(() => props.projects.find(p => p.id === props.activeId) ?? null)
64
+
65
+ const search = ref('')
66
+ const shown = computed(() => {
67
+ const needle = search.value.trim().toLowerCase()
68
+ return needle ? props.projects.filter(p => p.title.toLowerCase().includes(needle)) : props.projects
69
+ })
70
+ const groups = computed(() => rmProjectGroups(shown.value, props.lang))
71
+
72
+ const formOpen = ref(false)
73
+ const draft = ref('')
74
+ const missing = ref(false)
75
+
76
+ function openForm() {
77
+ formOpen.value = true
78
+ }
79
+
80
+ function cancel() {
81
+ formOpen.value = false
82
+ draft.value = ''
83
+ missing.value = false
84
+ }
85
+
86
+ function submit() {
87
+ const title = draft.value.trim()
88
+ if (!title) {
89
+ missing.value = true
90
+ return
91
+ }
92
+ emit('create', title)
93
+ cancel()
94
+ }
95
+
96
+ defineExpose({ openForm })
97
+ </script>
98
+
99
+ <template>
100
+ <div class="rm-project-panel">
101
+ <div v-if="active" class="rm-project-panel__head">
102
+ <p class="rm-project-panel__label">{{ T.goTo }}</p>
103
+ <p class="rm-project-panel__active">{{ active.title }}</p>
104
+ <p class="rm-project-panel__muted">{{ rmProjectCount(activeCount, lang) }}</p>
105
+ </div>
106
+
107
+ <form v-if="formOpen" class="rm-project-panel__create" @submit.prevent="submit">
108
+ <RmInput v-model="draft" :label="T.name" :placeholder="T.placeholder" :error="missing ? T.required : ''" />
109
+ <div class="rm-project-panel__actions">
110
+ <RmButton type="submit" variant="primary" size="sm" :loading="creating">{{ T.create }}</RmButton>
111
+ <RmButton variant="tertiary" size="sm" @click="cancel">{{ T.cancel }}</RmButton>
112
+ </div>
113
+ </form>
114
+
115
+ <div class="rm-project-panel__list">
116
+ <p v-if="loading && !projects.length" class="rm-project-panel__muted">{{ T.loading }}</p>
117
+ <p v-else-if="failed" class="rm-project-panel__muted">{{ T.failed }}</p>
118
+ <template v-else>
119
+ <div class="rm-project-panel__tools">
120
+ <RmSearchBar
121
+ v-if="projects.length > 4"
122
+ v-model="search"
123
+ class="rm-project-panel__search"
124
+ :have-advanced="false"
125
+ :placeholder="T.search"
126
+ />
127
+ <span v-if="!formOpen" class="rm-project-panel__new">
128
+ <slot name="new" :open-form="openForm">
129
+ <RmButton variant="secondary" size="sm" prefix-icon="Plus" @click="openForm">{{ T.new }}</RmButton>
130
+ </slot>
131
+ </span>
132
+ </div>
133
+
134
+ <template v-for="group in groups" :key="group.key">
135
+ <p class="rm-project-panel__group">
136
+ <RmIcon v-if="group.key === 'pinned'" name="PushPin" weight="fill" :size="12" />
137
+ {{ group.label }}
138
+ </p>
139
+ <article
140
+ v-for="project in group.projects"
141
+ :key="project.id"
142
+ class="rm-project-panel__card"
143
+ :class="{ 'is-active': project.id === activeId }"
144
+ >
145
+ <div class="rm-project-panel__card-head">
146
+ <p class="rm-project-panel__card-title">{{ project.title }}</p>
147
+ <RmButton
148
+ variant="tertiary"
149
+ size="sm"
150
+ icon-only
151
+ ghost
152
+ class="rm-project-panel__pin"
153
+ :class="{ 'is-on': project.pinned }"
154
+ :aria-pressed="!!project.pinned"
155
+ :aria-label="project.pinned ? T.unpin : T.pin"
156
+ :title="project.pinned ? T.unpin : T.pin"
157
+ @click="emit('pin', project.id)"
158
+ >
159
+ <RmIcon name="PushPin" :weight="project.pinned ? 'fill' : 'regular'" :size="14" />
160
+ </RmButton>
161
+ </div>
162
+ <p class="rm-project-panel__meta">{{ rmProjectSummary(project, lang) }}</p>
163
+ <p v-if="project.createdAt" class="rm-project-panel__meta">
164
+ {{ T.created }} <RmDate :date="project.createdAt" :lang="lang" />
165
+ </p>
166
+ <div class="rm-project-panel__actions">
167
+ <RmButton
168
+ :variant="project.id === activeId ? 'primary' : 'tertiary'"
169
+ size="sm"
170
+ :disabled="project.id === activeId"
171
+ @click="emit('activate', project.id)"
172
+ >
173
+ {{ project.id === activeId ? T.active : T.activate }}
174
+ </RmButton>
175
+ <RmButton variant="tertiary" size="sm" @click="emit('open', project.id)">{{ T.open }}</RmButton>
176
+ </div>
177
+ </article>
178
+ </template>
179
+
180
+ <p v-if="search && !shown.length" class="rm-project-panel__muted">{{ T.noMatch }}</p>
181
+ <p v-if="!projects.length" class="rm-project-panel__muted">{{ T.none }}</p>
182
+ </template>
183
+ </div>
184
+
185
+ <div v-if="allTo" class="rm-project-panel__footer">
186
+ <NuxtLink :to="allTo" class="rm-project-panel__link">{{ T.all }}</NuxtLink>
187
+ </div>
188
+ </div>
189
+ </template>
190
+
191
+ <style scoped>
192
+ .rm-project-panel{display:flex;flex-direction:column;gap:16px;width:100%}.rm-project-panel__head{background:color-mix(in srgb,var(--rm-shell-accent) 12%,var(--rm-shell-surface));border:1px solid color-mix(in srgb,var(--rm-shell-accent) 35%,transparent);border-left:4px solid var(--rm-shell-accent);border-radius:var(--radius-lg);display:flex;flex-direction:column;gap:2px;padding:var(--space-12)}.rm-project-panel__label{color:var(--rm-shell-text-dark);font-size:var(--font-12);font-weight:var(--weight-semibold);letter-spacing:.02em;margin:0;text-transform:uppercase}.rm-project-panel__active{color:var(--rm-shell-accent);font-size:var(--font-16);font-weight:var(--weight-bold);margin:0}.rm-project-panel__muted{color:var(--rm-shell-text-dark);font-size:var(--font-12);margin:0}.rm-project-panel__create{background:var(--rm-shell-surface);border:1px solid var(--rm-shell-border);border-radius:10px;padding:12px}.rm-project-panel__create,.rm-project-panel__list{display:flex;flex-direction:column;gap:8px}.rm-project-panel__tools{align-items:center;display:flex;gap:8px}.rm-project-panel__search{flex:1;min-width:0}.rm-project-panel__search :deep(.search-bar){box-sizing:border-box;display:flex;min-width:0;width:100%}.rm-project-panel__new{display:inline-flex;flex:0 0 auto;margin-left:auto}.rm-project-panel__group{align-items:center;color:var(--rm-shell-text-muted);display:flex;font-size:11px;font-weight:700;gap:6px;letter-spacing:.06em;margin:4px 0 0;text-transform:uppercase}.rm-project-panel__card{background:var(--rm-shell-surface);border:1px solid var(--rm-shell-border);border-radius:10px;display:flex;flex-direction:column;gap:6px;padding:12px}.rm-project-panel__card.is-active{background:color-mix(in srgb,var(--rm-shell-accent) 8%,var(--rm-shell-surface));border-color:color-mix(in srgb,var(--rm-shell-accent) 45%,transparent)}.rm-project-panel__card-head{align-items:center;display:flex;gap:8px}.rm-project-panel__card-title{color:var(--rm-shell-text-dark);flex:1;font-size:14px;font-weight:600;margin:0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rm-project-panel__pin{margin-right:-6px}.rm-project-panel__pin.is-on{color:var(--color-primary)}.rm-project-panel__meta{color:var(--rm-shell-text-muted);display:flex;font-size:12px;gap:4px;margin:0}.rm-project-panel__actions{display:flex;flex-wrap:wrap;gap:6px;margin-top:2px}.rm-project-panel__footer{border-top:1px solid var(--rm-shell-border);padding-top:12px}.rm-project-panel__link{color:var(--rm-shell-accent);font-size:12px;font-weight:600;text-decoration:none}.rm-project-panel__link:hover{text-decoration:underline}
193
+ </style>
@@ -0,0 +1,67 @@
1
+ <!--
2
+ RmProjectRenameDialog - renommer un projet.
3
+
4
+ Un champ dans une modale plutot qu'un titre editable sur place : pour que
5
+ l'enregistrement et l'echec aient ou se dire. Le champ repart du titre
6
+ courant a chaque ouverture ; Entree enregistre.
7
+ -->
8
+ <script setup lang="ts">
9
+ import { computed, ref, watch } from 'vue'
10
+
11
+ const props = withDefaults(
12
+ defineProps<{
13
+ modelValue: boolean
14
+ /** Titre actuel du projet. */
15
+ title: string
16
+ loading?: boolean
17
+ error?: string
18
+ lang?: 'fr' | 'en'
19
+ }>(),
20
+ { loading: false, error: '', lang: 'fr' },
21
+ )
22
+
23
+ const emit = defineEmits<{
24
+ 'update:modelValue': [value: boolean]
25
+ 'save': [title: string]
26
+ }>()
27
+
28
+ const WORDS = {
29
+ fr: { heading: 'Renommer le projet', name: 'Nom du projet', cancel: 'Annuler', save: 'Enregistrer' },
30
+ en: { heading: 'Rename the project', name: 'Project name', cancel: 'Cancel', save: 'Save' },
31
+ }
32
+ const T = computed(() => WORDS[props.lang] ?? WORDS.fr)
33
+
34
+ const draft = ref('')
35
+ watch(() => props.modelValue, (open) => {
36
+ if (open) draft.value = props.title
37
+ }, { immediate: true })
38
+
39
+ const open = computed({
40
+ get: () => props.modelValue,
41
+ set: (value: boolean) => emit('update:modelValue', value),
42
+ })
43
+
44
+ function save() {
45
+ const title = draft.value.trim()
46
+ if (!title || props.loading) return
47
+ emit('save', title)
48
+ }
49
+ </script>
50
+
51
+ <template>
52
+ <RmModal v-model="open" size="sm" :label="T.heading">
53
+ <template #header>
54
+ <h2 class="rm-project-dialog__title">{{ T.heading }}</h2>
55
+ </template>
56
+ <RmInput v-model="draft" :label="T.name" @keyup.enter="save" />
57
+ <p v-if="error" class="rm-project-dialog__error">{{ error }}</p>
58
+ <template #footer>
59
+ <RmButton variant="tertiary" @click="open = false">{{ T.cancel }}</RmButton>
60
+ <RmButton variant="primary" :loading="loading" :disabled="!draft.trim()" @click="save">{{ T.save }}</RmButton>
61
+ </template>
62
+ </RmModal>
63
+ </template>
64
+
65
+ <style scoped>
66
+ .rm-project-dialog__title{font-size:var(--font-18);font-weight:var(--weight-bold);margin:0}.rm-project-dialog__error{color:var(--color-danger);font-size:var(--font-13);margin:8px 0 0}
67
+ </style>
@@ -0,0 +1,258 @@
1
+ <!--
2
+ RmProjectResources - l'onglet « Ressources » d'un projet.
3
+
4
+ Une ligne par element range, toutes familles confondues : icone de la
5
+ famille, titre (un vrai lien : Ctrl/Cmd-clic ouvre la fiche dans un onglet,
6
+ le clic simple rend `open` pour un apercu sur place), sous-titre, d'ou vient
7
+ l'element et quand il est entre, puis Commenter et Retirer.
8
+
9
+ Recherche, puces de famille, filtre « commentees » et tri se font en
10
+ memoire (cf. rmProjectFilterRows) : un projet tient quelques centaines
11
+ d'elements, deja relus. L'app donne les lignes (cf. rmProjectRows), avec
12
+ `path` deja prefixe de la langue.
13
+
14
+ Un element dont le document a disparu de l'index reste liste sous son
15
+ identifiant : mieux vaut le montrer, et laisser le retirer, que le faire
16
+ disparaitre en silence.
17
+ -->
18
+ <script setup lang="ts">
19
+ import { computed, ref } from 'vue'
20
+ import {
21
+ rmProjectCount,
22
+ rmProjectFamilies,
23
+ rmProjectFilterRows,
24
+ type RmProjectResource,
25
+ type RmProjectSort,
26
+ } from '../utils/projects'
27
+
28
+ const props = withDefaults(
29
+ defineProps<{
30
+ rows: RmProjectResource[]
31
+ loading?: boolean
32
+ failed?: boolean
33
+ /** `type-id` de la ligne en cours de retrait. */
34
+ removing?: string
35
+ /** Texte de l'etat vide : ou trouver le bouton Projet dans cette app. */
36
+ emptyText?: string
37
+ /** Id de l'element a la barre de filtres collante. Vide : pas de barre collante. */
38
+ stickyTo?: string | null
39
+ lang?: 'fr' | 'en'
40
+ }>(),
41
+ { loading: false, failed: false, removing: '', emptyText: '', stickyTo: 'rm_page_header', lang: 'fr' },
42
+ )
43
+
44
+ const emit = defineEmits<{
45
+ open: [row: RmProjectResource]
46
+ comment: [row: RmProjectResource]
47
+ remove: [row: RmProjectResource]
48
+ }>()
49
+
50
+ const WORDS = {
51
+ fr: {
52
+ search: 'Rechercher une ressource', sortBy: 'Trier par', comments: 'Annotations', withComments: 'Contient des commentaires',
53
+ loading: 'Chargement', failedTitle: 'Projet indisponible', failedText: 'Le service des projets n\'a pas répondu.',
54
+ emptyTitle: 'Ce projet est vide', emptyText: 'Parcourez les pages et utilisez le bouton Projet d\'une carte pour la ranger ici.',
55
+ families: 'Filtrer par famille', all: 'Toutes', resources: 'Ressources', noMatch: 'Aucune ressource ne correspond à ces filtres.',
56
+ comment: 'Commenter', commentsN: (n: number) => `Commentaires (${n})`, remove: 'Retirer du projet',
57
+ unknown: (id: string) => `Élément inconnu (id ${id})`, source: 'Source', added: 'Ajouté le',
58
+ filed: (n: number) => `${rmProjectCount(n, 'fr')} ${n > 1 ? 'rangées' : 'rangée'} dans le projet.`,
59
+ shown: (s: number, n: number) => `${s} ${s > 1 ? 'affichées' : 'affichée'} sur ${rmProjectCount(n, 'fr')}.`,
60
+ sorts: { 'date-desc': 'Ajout, plus récent d\'abord', 'date-asc': 'Ajout, plus ancien d\'abord', 'title': 'Titre', 'family': 'Famille' },
61
+ },
62
+ en: {
63
+ search: 'Search a resource', sortBy: 'Sort by', comments: 'Comments', withComments: 'With comments',
64
+ loading: 'Loading', failedTitle: 'Project unavailable', failedText: 'The project service did not answer.',
65
+ emptyTitle: 'Nothing in this project yet', emptyText: 'Browse the pages and use the Project button on a card to file it here.',
66
+ families: 'Filter by family', all: 'All', resources: 'Resources', noMatch: 'No resource matches these filters.',
67
+ comment: 'Comment', commentsN: (n: number) => `Comments (${n})`, remove: 'Remove from the project',
68
+ unknown: (id: string) => `Unknown item (id ${id})`, source: 'Source', added: 'Added on',
69
+ filed: (n: number) => `${rmProjectCount(n, 'en')} filed in the project.`,
70
+ shown: (s: number, n: number) => `${s} shown out of ${rmProjectCount(n, 'en')}.`,
71
+ sorts: { 'date-desc': 'Newest first', 'date-asc': 'Oldest first', 'title': 'Title', 'family': 'Family' },
72
+ },
73
+ }
74
+ const T = computed(() => WORDS[props.lang] ?? WORDS.fr)
75
+
76
+ const search = ref('')
77
+ const family = ref('all')
78
+ const onlyCommented = ref(false)
79
+ const sort = ref<RmProjectSort>('date-desc')
80
+
81
+ /** RmDropdownSelect ne porte que des libelles : on traduit dans les deux sens. */
82
+ const sortLabels = computed(() => Object.values(T.value.sorts))
83
+ const sortLabel = computed({
84
+ get: () => T.value.sorts[sort.value],
85
+ set: (label: string) => {
86
+ const found = (Object.keys(T.value.sorts) as RmProjectSort[]).find(key => T.value.sorts[key] === label)
87
+ if (found) sort.value = found
88
+ },
89
+ })
90
+
91
+ function clearFilters() {
92
+ search.value = ''
93
+ family.value = 'all'
94
+ onlyCommented.value = false
95
+ }
96
+
97
+ const families = computed(() => rmProjectFamilies(props.rows))
98
+ const shown = computed(() => rmProjectFilterRows(props.rows, {
99
+ search: search.value,
100
+ family: family.value,
101
+ onlyCommented: onlyCommented.value,
102
+ sort: sort.value,
103
+ }, props.lang))
104
+
105
+ const context = computed(() => {
106
+ const total = props.rows.length
107
+ return shown.value.length === total ? T.value.filed(total) : T.value.shown(shown.value.length, total)
108
+ })
109
+
110
+ function formatDate(value?: string) {
111
+ const date = new Date(value ?? '')
112
+ if (Number.isNaN(date.getTime())) return ''
113
+ return date.toLocaleDateString(props.lang === 'en' ? 'en-GB' : 'fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
114
+ }
115
+
116
+ function meta(row: RmProjectResource) {
117
+ return [
118
+ row.section ? `${T.value.source} : ${row.section}` : '',
119
+ row.createdAt ? `${T.value.added} ${formatDate(row.createdAt)}` : '',
120
+ ].filter(Boolean).join(' · ')
121
+ }
122
+
123
+ function commentLabel(row: RmProjectResource) {
124
+ return row.comments ? T.value.commentsN(row.comments) : T.value.comment
125
+ }
126
+
127
+ /** Le clic simple ouvre l'apercu ; les autres gardent le comportement du lien. */
128
+ function onTitle(event: MouseEvent, row: RmProjectResource) {
129
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return
130
+ event.preventDefault()
131
+ emit('open', row)
132
+ }
133
+ </script>
134
+
135
+ <template>
136
+ <div class="rm-project-res">
137
+ <RmFilterBar
138
+ :filters-amount="onlyCommented ? 1 : 0"
139
+ :lang="lang"
140
+ :sticky-to="stickyTo"
141
+ @clear-filters="clearFilters"
142
+ >
143
+ <template #Search>
144
+ <div class="rm-project-res__search">
145
+ <RmSearchBar v-model="search" :have-advanced="false" :placeholder="T.search" />
146
+ </div>
147
+ </template>
148
+ <template #Filters>
149
+ <RmDropdownSelect
150
+ v-model="sortLabel"
151
+ :label="T.sortBy"
152
+ :options="sortLabels"
153
+ :search="false"
154
+ sort-icon
155
+ close-on-click
156
+ :lang="lang"
157
+ />
158
+ <RmCheckbox v-model="onlyCommented" :field-label="T.comments" :label="T.withComments" />
159
+ </template>
160
+ </RmFilterBar>
161
+
162
+ <RmLoadingState v-if="loading && !rows.length" spinner :label="T.loading" />
163
+
164
+ <RmErrorState v-else-if="failed" icon="CloudWarning" :title="T.failedTitle" :text="T.failedText" action-label="" />
165
+
166
+ <RmEmptyState v-else-if="!rows.length" icon="FolderSimplePlus" :title="T.emptyTitle" :text="emptyText || T.emptyText" />
167
+
168
+ <template v-else>
169
+ <div class="rm-project-res__chips" role="group" :aria-label="T.families">
170
+ <RmPill :label="T.all" :active="family === 'all'" @click="family = 'all'">
171
+ <span class="rm-project-res__chip-count">{{ rows.length }}</span>
172
+ </RmPill>
173
+ <RmPill
174
+ v-for="chip in families"
175
+ :key="chip.type"
176
+ :label="chip.label"
177
+ :active="family === chip.type"
178
+ @click="family = chip.type"
179
+ >
180
+ <span class="rm-project-res__chip-count">{{ chip.count }}</span>
181
+ </RmPill>
182
+ </div>
183
+
184
+ <RmCard padding="lg">
185
+ <div class="rm-project-res__head">
186
+ <h2 class="rm-project-res__title">{{ T.resources }}</h2>
187
+ <p class="rm-project-res__context">{{ context }}</p>
188
+ </div>
189
+
190
+ <ol v-if="shown.length" class="rm-project-res__list">
191
+ <li v-for="row in shown" :key="`${row.type}-${row.id}`" class="rm-project-res__item">
192
+ <span class="rm-project-res__icon" aria-hidden="true">
193
+ <RmIcon :name="row.icon" :size="17" />
194
+ </span>
195
+
196
+ <div class="rm-project-res__text">
197
+ <p class="rm-project-res__item-title">
198
+ <a v-if="row.path" :href="row.path" @click="onTitle($event, row)">{{ row.title || T.unknown(row.id) }}</a>
199
+ <span v-else>{{ row.title || T.unknown(row.id) }}</span>
200
+ <button
201
+ v-if="row.comments"
202
+ type="button"
203
+ class="rm-project-res__comments"
204
+ :aria-label="commentLabel(row)"
205
+ @click="emit('comment', row)"
206
+ >
207
+ <RmIcon name="ChatCircle" :size="12" />
208
+ {{ row.comments }}
209
+ </button>
210
+ </p>
211
+ <p v-if="row.subtitle" class="rm-project-res__sub">{{ row.subtitle }}</p>
212
+ <p v-if="meta(row)" class="rm-project-res__meta">{{ meta(row) }}</p>
213
+ </div>
214
+
215
+ <RmBadge variant="info" class="rm-project-res__family">{{ row.family }}</RmBadge>
216
+
217
+ <!-- Des icones seules : la bulle dit le geste avant le clic. Elle
218
+ sort a gauche, la carte couperait une bulle centree. -->
219
+ <div class="rm-project-res__actions">
220
+ <RmTooltip :text="commentLabel(row)" dir="left">
221
+ <RmButton
222
+ variant="tertiary"
223
+ size="sm"
224
+ icon-only
225
+ ghost
226
+ prefix-icon="ChatCircle"
227
+ :aria-label="commentLabel(row)"
228
+ @click="emit('comment', row)"
229
+ />
230
+ </RmTooltip>
231
+ <RmTooltip :text="T.remove" dir="left">
232
+ <RmButton
233
+ variant="tertiary"
234
+ size="sm"
235
+ icon-only
236
+ ghost
237
+ prefix-icon="Trash"
238
+ :loading="removing === `${row.type}-${row.id}`"
239
+ :aria-label="T.remove"
240
+ @click="emit('remove', row)"
241
+ />
242
+ </RmTooltip>
243
+ </div>
244
+ </li>
245
+ </ol>
246
+
247
+ <p v-else class="rm-project-res__none">
248
+ <RmIcon name="FunnelX" :size="16" />
249
+ {{ T.noMatch }}
250
+ </p>
251
+ </RmCard>
252
+ </template>
253
+ </div>
254
+ </template>
255
+
256
+ <style scoped>
257
+ .rm-project-res{display:flex;flex-direction:column;gap:16px}.rm-project-res__search{flex:0 1 360px;min-width:160px}.rm-project-res__chips{display:flex;flex-wrap:wrap;gap:6px}.rm-project-res__chip-count{background:var(--bg-surface-3);border-radius:999px;color:var(--text-secondary);font-size:10px;font-weight:700;padding:1px 6px}.toolbar-pill--active .rm-project-res__chip-count{background:hsla(0,0%,100%,.25);color:var(--color-text-inverse)}.rm-project-res__head{display:flex;flex-direction:column;gap:4px;margin-bottom:8px}.rm-project-res__title{color:var(--text-primary);font-size:15px;font-weight:var(--weight-bold);margin:0}.rm-project-res__context{color:var(--text-muted);font-size:12px;margin:0}.rm-project-res__list{display:flex;flex-direction:column;list-style:none;margin:0;padding:0}.rm-project-res__item{align-items:center;border-bottom:1px solid var(--border-soft,var(--border-default));display:flex;gap:12px;padding:10px 0}.rm-project-res__item:last-child{border-bottom:none}.rm-project-res__icon{align-items:center;background:var(--bg-surface-3);border-radius:var(--radius-lg,8px);color:var(--text-secondary);display:flex;flex:none;height:34px;justify-content:center;width:34px}.rm-project-res__text{flex:1;min-width:0}.rm-project-res__item-title{align-items:center;color:var(--text-primary);display:flex;font-size:13px;font-weight:var(--weight-semibold);gap:6px;margin:0}.rm-project-res__item-title a{color:var(--color-primary);text-decoration:underline;text-underline-offset:2px}.rm-project-res__comments{align-items:center;background:var(--bg-surface-3);border:none;border-radius:999px;color:var(--text-secondary);cursor:pointer;display:inline-flex;font:inherit;font-size:11px;gap:3px;padding:1px 6px}.rm-project-res__sub{color:var(--text-secondary);font-size:12px;margin:2px 0 0}.rm-project-res__meta{color:var(--text-muted);font-size:11px;margin:2px 0 0}.rm-project-res__family{flex:none}.rm-project-res__actions{align-items:center;display:flex;flex:none;gap:2px}.rm-project-res__none{align-items:center;color:var(--text-secondary);display:flex;font-size:13px;gap:6px;margin:8px 0}
258
+ </style>
@@ -14,6 +14,8 @@
14
14
  internes (export, liens).
15
15
 
16
16
  Slots :
17
+ code — identifiant au-dessus du titre (RmCopyCode : RPPS, NCT...).
18
+ Hors de la ligne meta, ou il se perdait parmi les tags.
17
19
  title — identite, une ligne en gras
18
20
  subtitle — complement gris sous le titre
19
21
  actions — a droite du titre (export...). Le clic doit y etre stoppe.
@@ -97,6 +99,7 @@ withDefaults(
97
99
  <div class="rm-rcard" :class="{ 'rm-rcard--clickable': clickable, 'rm-rcard--dense': dense }">
98
100
  <div class="rm-rcard__head">
99
101
  <div class="rm-rcard__identity">
102
+ <div v-if="$slots.code" class="rm-rcard__code"><slot name="code" /></div>
100
103
  <h3 class="rm-rcard__title"><slot name="title" /></h3>
101
104
  <div v-if="$slots.subtitle" class="rm-rcard__subtitle"><slot name="subtitle" /></div>
102
105
  </div>
@@ -135,5 +138,5 @@ withDefaults(
135
138
  </template>
136
139
 
137
140
  <style scoped>
138
- .rm-rcard{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-2xl);color:var(--text-primary);display:block;font-family:var(--font-sans);overflow:hidden;transition:border-color var(--duration-fast) linear,box-shadow var(--duration-fast) linear;width:100%}.rm-rcard--clickable{cursor:pointer}.rm-rcard--clickable:hover{border-color:color-mix(in srgb,var(--color-primary) 35%,var(--border-default));box-shadow:var(--shadow-md)}.rm-rcard__head{align-items:flex-start;display:flex;gap:var(--space-16);padding:var(--space-20) var(--space-24) 0}.rm-rcard__identity{flex:1 1 auto;min-width:0}.rm-rcard__title{font-size:var(--font-18);font-weight:800;letter-spacing:.01em;margin:0}.rm-rcard__subtitle{color:var(--text-muted);font-size:var(--font-14);margin-top:4px;overflow-wrap:anywhere}.rm-rcard__actions{align-items:center;display:flex;flex:0 0 auto;gap:var(--space-8)}.rm-rcard__body{align-items:stretch;display:flex;gap:var(--space-24);padding:var(--space-16) var(--space-24) var(--space-20)}.rm-rcard__count{align-items:center;border-right:1px solid var(--border-default);display:flex;flex:0 0 128px;flex-direction:column;gap:var(--space-2);justify-content:center;padding-right:var(--space-24);text-align:center}.rm-rcard__count-value{color:var(--color-primary);font-size:var(--font-32);font-weight:var(--weight-bold);line-height:1.1;margin:0}.rm-rcard__count-label{color:var(--text-muted);font-size:var(--font-12);font-weight:var(--weight-semibold);letter-spacing:.08em;margin:0;text-transform:uppercase}.rm-rcard__count-extra{color:var(--color-primary);font-size:var(--font-13);line-height:1.3;margin:6px 0 0}.rm-rcard__count-extra+.rm-rcard__count-extra{margin-top:2px}.rm-rcard__main{display:flex;flex:1 1 auto;flex-direction:column;gap:var(--space-6);min-width:0}.rm-rcard__meta{align-items:center;display:flex;flex-wrap:wrap;gap:var(--space-8)}.rm-rcard__more{color:var(--color-primary);font-size:var(--font-14);font-weight:var(--weight-semibold);margin:4px 0 0}.rm-rcard__footer{background:var(--bg-surface-2);border-top:1px solid var(--border-default);font-size:var(--font-13);padding:var(--space-12) var(--space-24)}.rm-rcard :deep(.rm-rcard__label){align-items:center;color:var(--text-secondary);display:inline-flex;font-size:var(--font-12);font-weight:var(--weight-bold);gap:var(--space-4);letter-spacing:.06em;text-transform:uppercase}.rm-rcard :deep(.rm-rcard__ref){background:var(--bg-surface-3);border-radius:var(--radius-sm);color:var(--text-secondary);font-family:Courier New,monospace;font-size:var(--font-12);padding:var(--space-2) var(--space-8)}.rm-rcard :deep(.rm-rcard__lead){font-size:15px;font-weight:var(--weight-bold);margin:0}.rm-rcard :deep(.rm-rcard__text){font-size:15px;margin:0}.rm-rcard :deep(.rm-rcard__desc){color:var(--text-secondary);font-size:var(--font-14);line-height:1.5;margin:0}.rm-rcard :deep(.rm-rcard__clamp){display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.rm-rcard--dense{border-radius:var(--radius-xl)}.rm-rcard--dense .rm-rcard__head{gap:var(--space-12);padding:var(--space-12) var(--space-16) 0}.rm-rcard--dense .rm-rcard__title{font-size:15px}.rm-rcard--dense .rm-rcard__subtitle{font-size:var(--font-13);margin-top:2px}.rm-rcard--dense .rm-rcard__body{gap:var(--space-16);padding:var(--space-8) var(--space-16) var(--space-12)}.rm-rcard--dense .rm-rcard__count{flex-basis:104px;padding-right:var(--space-16)}.rm-rcard--dense .rm-rcard__count-value{font-size:var(--font-24)}.rm-rcard--dense .rm-rcard__count-label{font-size:var(--font-11)}.rm-rcard--dense .rm-rcard__main{gap:var(--space-4)}.rm-rcard--dense .rm-rcard__count-extra,.rm-rcard--dense .rm-rcard__footer,.rm-rcard--dense .rm-rcard__main>:not(.rm-rcard__meta,.rm-rcard__lead){display:none}.rm-rcard--dense :deep(.rm-rcard__lead){display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden}.rm-rcard--dense .rm-rcard__body{align-items:center}.rm-rcard--dense :deep(.rm-rcard__lead),.rm-rcard--dense :deep(.rm-rcard__text){font-size:var(--font-14)}.rm-rcard--dense :deep(.rm-rcard__desc){font-size:var(--font-13)}
141
+ .rm-rcard{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-2xl);color:var(--text-primary);display:block;font-family:var(--font-sans);overflow:hidden;transition:border-color var(--duration-fast) linear,box-shadow var(--duration-fast) linear;width:100%}.rm-rcard--clickable{cursor:pointer}.rm-rcard--clickable:hover{border-color:color-mix(in srgb,var(--color-primary) 35%,var(--border-default));box-shadow:var(--shadow-md)}.rm-rcard__head{align-items:flex-start;display:flex;gap:var(--space-16);padding:var(--space-20) var(--space-24) 0}.rm-rcard__identity{flex:1 1 auto;min-width:0}.rm-rcard__code{display:flex;flex-wrap:wrap;gap:var(--space-8);margin-bottom:var(--space-8)}.rm-rcard__title{font-size:var(--font-18);font-weight:800;letter-spacing:.01em;margin:0}.rm-rcard__subtitle{color:var(--text-muted);font-size:var(--font-14);margin-top:4px;overflow-wrap:anywhere}.rm-rcard__actions{align-items:center;display:flex;flex:0 0 auto;gap:var(--space-8)}.rm-rcard__body{align-items:stretch;display:flex;gap:var(--space-24);padding:var(--space-16) var(--space-24) var(--space-20)}.rm-rcard__count{align-items:center;border-right:1px solid var(--border-default);display:flex;flex:0 0 128px;flex-direction:column;gap:var(--space-2);justify-content:center;padding-right:var(--space-24);text-align:center}.rm-rcard__count-value{color:var(--color-primary);font-size:var(--font-32);font-weight:var(--weight-bold);line-height:1.1;margin:0}.rm-rcard__count-label{color:var(--text-muted);font-size:var(--font-12);font-weight:var(--weight-semibold);letter-spacing:.08em;margin:0;text-transform:uppercase}.rm-rcard__count-extra{color:var(--color-primary);font-size:var(--font-13);line-height:1.3;margin:6px 0 0}.rm-rcard__count-extra+.rm-rcard__count-extra{margin-top:2px}.rm-rcard__main{display:flex;flex:1 1 auto;flex-direction:column;gap:var(--space-6);min-width:0}.rm-rcard__meta{align-items:center;display:flex;flex-wrap:wrap;gap:var(--space-8)}.rm-rcard__more{color:var(--color-primary);font-size:var(--font-14);font-weight:var(--weight-semibold);margin:4px 0 0}.rm-rcard__footer{background:var(--bg-surface-2);border-top:1px solid var(--border-default);font-size:var(--font-13);padding:var(--space-12) var(--space-24)}.rm-rcard :deep(.rm-rcard__label){align-items:center;color:var(--text-secondary);display:inline-flex;font-size:var(--font-12);font-weight:var(--weight-bold);gap:var(--space-4);letter-spacing:.06em;text-transform:uppercase}.rm-rcard :deep(.rm-rcard__ref){background:var(--bg-surface-3);border-radius:var(--radius-sm);color:var(--text-secondary);font-family:Courier New,monospace;font-size:var(--font-12);padding:var(--space-2) var(--space-8)}.rm-rcard :deep(.rm-rcard__lead){font-size:15px;font-weight:var(--weight-bold);margin:0}.rm-rcard :deep(.rm-rcard__text){font-size:15px;margin:0}.rm-rcard :deep(.rm-rcard__desc){color:var(--text-secondary);font-size:var(--font-14);line-height:1.5;margin:0}.rm-rcard :deep(.rm-rcard__clamp){display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.rm-rcard--dense{border-radius:var(--radius-xl)}.rm-rcard--dense .rm-rcard__head{gap:var(--space-12);padding:var(--space-12) var(--space-16) 0}.rm-rcard--dense .rm-rcard__title{font-size:15px}.rm-rcard--dense .rm-rcard__subtitle{font-size:var(--font-13);margin-top:2px}.rm-rcard--dense .rm-rcard__body{gap:var(--space-16);padding:var(--space-8) var(--space-16) var(--space-12)}.rm-rcard--dense .rm-rcard__count{flex-basis:104px;padding-right:var(--space-16)}.rm-rcard--dense .rm-rcard__count-value{font-size:var(--font-24)}.rm-rcard--dense .rm-rcard__count-label{font-size:var(--font-11)}.rm-rcard--dense .rm-rcard__main{gap:var(--space-4)}.rm-rcard--dense .rm-rcard__count-extra,.rm-rcard--dense .rm-rcard__footer,.rm-rcard--dense .rm-rcard__main>:not(.rm-rcard__meta,.rm-rcard__lead){display:none}.rm-rcard--dense :deep(.rm-rcard__lead){display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden}.rm-rcard--dense .rm-rcard__body{align-items:center}.rm-rcard--dense :deep(.rm-rcard__lead),.rm-rcard--dense :deep(.rm-rcard__text){font-size:var(--font-14)}.rm-rcard--dense :deep(.rm-rcard__desc){font-size:var(--font-13)}
139
142
  </style>
@@ -19,6 +19,7 @@
19
19
  partent jamais dans le navigateur.
20
20
  -->
21
21
  <script setup lang="ts">
22
+ import { rmCopyText } from '../utils/clipboard'
22
23
  import { computed, ref, useId, onUnmounted } from 'vue'
23
24
 
24
25
  const props = withDefaults(defineProps<{
@@ -193,37 +194,8 @@ const teaserHtml = computed(() => (props.summary ? '' : props.aboutSource || '')
193
194
  const copied = ref(false)
194
195
  let copyTimer: ReturnType<typeof setTimeout> | undefined
195
196
 
196
- /**
197
- * Le presse-papiers moderne demande un contexte sur, et refuse dans un iframe
198
- * sans permission : la selection cachee reste le seul chemin qui marche partout.
199
- * En cas d'echec des deux, pas de coche : elle mentirait.
200
- */
201
- async function writeToClipboard(text: string) {
202
- try {
203
- await navigator.clipboard.writeText(text)
204
- return true
205
- }
206
- catch {
207
- try {
208
- const field = document.createElement('textarea')
209
- field.value = text
210
- field.setAttribute('readonly', '')
211
- field.style.position = 'fixed'
212
- field.style.opacity = '0'
213
- document.body.appendChild(field)
214
- field.select()
215
- const done = document.execCommand('copy')
216
- document.body.removeChild(field)
217
- return done
218
- }
219
- catch {
220
- return false
221
- }
222
- }
223
- }
224
-
225
197
  async function copyTitle() {
226
- if (!(await writeToClipboard(props.title))) return
198
+ if (!(await rmCopyText(props.title))) return
227
199
  copied.value = true
228
200
  clearTimeout(copyTimer)
229
201
  copyTimer = setTimeout(() => (copied.value = false), 2000)
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Copie un texte dans le presse-papiers. Rend `false` si rien n'a ete copie,
3
+ * pour qu'un composant n'affiche pas une coche qui mentirait.
4
+ *
5
+ * Le presse-papiers moderne demande un contexte sur, et refuse dans un iframe
6
+ * sans permission : la selection cachee reste le seul chemin qui marche partout.
7
+ */
8
+ export declare function rmCopyText(text: string): Promise<boolean>;
@@ -0,0 +1,21 @@
1
+ export async function rmCopyText(text) {
2
+ try {
3
+ await navigator.clipboard.writeText(text);
4
+ return true;
5
+ } catch {
6
+ try {
7
+ const field = document.createElement("textarea");
8
+ field.value = text;
9
+ field.setAttribute("readonly", "");
10
+ field.style.position = "fixed";
11
+ field.style.opacity = "0";
12
+ document.body.appendChild(field);
13
+ field.select();
14
+ const done = document.execCommand("copy");
15
+ document.body.removeChild(field);
16
+ return done;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+ }