@tekkare/romulus 0.1.231 → 0.1.232

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,115 @@
1
+ <!--
2
+ RmProjectList - la page « Mes projets » : les projets en cartes, groupes.
3
+
4
+ Les epingles d'abord, puis les miens, puis ceux qu'on m'a partages (cf.
5
+ rmProjectGroups). Une carte mene au projet (`to`) ; l'epingle est posee sur
6
+ la carte, hors du lien, pour qu'epingler n'ouvre rien. Les epingles sont un
7
+ choix de lecture : l'app les garde ou elle veut et les rend dans `pinned`.
8
+
9
+ Chargement, echec et liste vide se disent ici. Le bouton de creation de
10
+ l'etat vide vient de l'app (slot `empty-action`) : elle seule sait si la
11
+ creation est libre ou assistee.
12
+ -->
13
+ <script setup lang="ts">
14
+ import { computed } from 'vue'
15
+ import { rmProjectGroups, rmProjectSummary, type RmProject } from '../utils/projects'
16
+
17
+ const props = withDefaults(
18
+ defineProps<{
19
+ projects: RmProject[]
20
+ /** Projet qui recoit les ajouts. */
21
+ activeId?: string | null
22
+ loading?: boolean
23
+ failed?: boolean
24
+ /** Texte de l'etat vide : ce qu'un projet rassemble dans cette app. */
25
+ emptyText?: string
26
+ lang?: 'fr' | 'en'
27
+ }>(),
28
+ { activeId: null, loading: false, failed: false, emptyText: '', lang: 'fr' },
29
+ )
30
+
31
+ const emit = defineEmits<{ pin: [projectId: string] }>()
32
+
33
+ const WORDS = {
34
+ fr: {
35
+ active: 'Actif', pin: 'Épingler en haut', unpin: 'Désépingler',
36
+ failedTitle: 'Projets indisponibles', failedText: 'Le service des projets n\'a pas répondu. Réessayez dans un instant.',
37
+ emptyTitle: 'Aucun projet', emptyText: 'Un projet rassemble ce que vous mettez de côté au fil des pages.',
38
+ },
39
+ en: {
40
+ active: 'Active', pin: 'Pin to the top', unpin: 'Unpin',
41
+ failedTitle: 'Projects unavailable', failedText: 'The project service did not answer. Try again in a moment.',
42
+ emptyTitle: 'No project yet', emptyText: 'A project gathers what you set aside across the pages.',
43
+ },
44
+ }
45
+ const T = computed(() => WORDS[props.lang] ?? WORDS.fr)
46
+
47
+ const groups = computed(() => rmProjectGroups(props.projects, props.lang))
48
+ </script>
49
+
50
+ <template>
51
+ <div class="rm-project-list">
52
+ <RmLoadingState v-if="loading && !projects.length" />
53
+
54
+ <RmErrorState v-else-if="failed" :title="T.failedTitle" :text="T.failedText" action-label="" />
55
+
56
+ <div v-else-if="!projects.length" class="rm-project-list__empty">
57
+ <RmEmptyState icon="FolderSimplePlus" tone="primary" :title="T.emptyTitle" :text="emptyText || T.emptyText" />
58
+ <slot name="empty-action" />
59
+ </div>
60
+
61
+ <section v-for="group in groups" v-else :key="group.key" class="rm-project-list__group">
62
+ <p class="rm-project-list__group-title">
63
+ <RmIcon v-if="group.key === 'pinned'" name="PushPin" weight="fill" :size="12" />
64
+ {{ group.label }}
65
+ </p>
66
+ <div class="rm-project-list__grid">
67
+ <RmSectionCard v-for="project in group.projects" :key="project.id" position="none" action class="rm-project-list__card">
68
+ <!-- Deux branches en dur : `<component :is="'NuxtLink'">` ne resout rien. -->
69
+ <NuxtLink v-if="project.to" :to="project.to" class="rm-project-list__link">
70
+ <div class="rm-project-list__head">
71
+ <RmIcon name="FolderSimple" :size="18" />
72
+ <span class="rm-project-list__title">{{ project.title }}</span>
73
+ <RmTag v-if="project.id === activeId" variant="teal">{{ T.active }}</RmTag>
74
+ </div>
75
+ <p v-if="project.description" class="rm-project-list__text">{{ project.description }}</p>
76
+ <p class="rm-project-list__meta">{{ rmProjectSummary(project, lang) }}</p>
77
+ <p v-if="project.createdAt" class="rm-project-list__meta">
78
+ <RmDate :date="project.createdAt" :lang="lang" />
79
+ </p>
80
+ </NuxtLink>
81
+ <div v-else class="rm-project-list__link">
82
+ <div class="rm-project-list__head">
83
+ <RmIcon name="FolderSimple" :size="18" />
84
+ <span class="rm-project-list__title">{{ project.title }}</span>
85
+ <RmTag v-if="project.id === activeId" variant="teal">{{ T.active }}</RmTag>
86
+ </div>
87
+ <p v-if="project.description" class="rm-project-list__text">{{ project.description }}</p>
88
+ <p class="rm-project-list__meta">{{ rmProjectSummary(project, lang) }}</p>
89
+ <p v-if="project.createdAt" class="rm-project-list__meta">
90
+ <RmDate :date="project.createdAt" :lang="lang" />
91
+ </p>
92
+ </div>
93
+ <RmButton
94
+ variant="tertiary"
95
+ size="sm"
96
+ icon-only
97
+ ghost
98
+ class="rm-project-list__pin"
99
+ :class="{ 'is-on': project.pinned }"
100
+ :aria-pressed="!!project.pinned"
101
+ :aria-label="project.pinned ? T.unpin : T.pin"
102
+ :title="project.pinned ? T.unpin : T.pin"
103
+ @click="emit('pin', project.id)"
104
+ >
105
+ <RmIcon name="PushPin" :weight="project.pinned ? 'fill' : 'regular'" :size="14" />
106
+ </RmButton>
107
+ </RmSectionCard>
108
+ </div>
109
+ </section>
110
+ </div>
111
+ </template>
112
+
113
+ <style scoped>
114
+ .rm-project-list{display:flex;flex-direction:column;gap:24px}.rm-project-list__group{display:flex;flex-direction:column;gap:12px}.rm-project-list__group-title{align-items:center;color:var(--rm-shell-text-muted);display:flex;font-size:11px;font-weight:700;gap:6px;letter-spacing:.06em;margin:0;text-transform:uppercase}.rm-project-list__grid{display:grid;gap:16px;grid-template-columns:repeat(auto-fill,minmax(320px,1fr))}.rm-project-list__card{display:flex;flex-direction:column;position:relative}.rm-project-list__link{color:inherit;display:flex;flex-direction:column;gap:6px;text-decoration:none}.rm-project-list__head{align-items:center;display:flex;gap:8px;padding-right:32px}.rm-project-list__title{font-size:15px;font-weight:600}.rm-project-list__text{color:var(--rm-shell-text-muted);font-size:13px;margin:0}.rm-project-list__meta{color:var(--rm-shell-text-muted);display:flex;font-size:12px;gap:6px;margin:0}.rm-project-list__pin{position:absolute;right:10px;top:10px}.rm-project-list__pin.is-on{color:var(--color-primary)}.rm-project-list__empty{align-items:center;display:flex;flex-direction:column;gap:16px}
115
+ </style>
@@ -0,0 +1,294 @@
1
+ <!--
2
+ RmProjectOverview - la vue d'ensemble d'un projet, d'un coup d'oeil.
3
+
4
+ Tout se calcule sur ce que la page a deja relu (lignes, commentaires). En
5
+ tete, les chiffres ; puis le cadrage (la description, que les membres
6
+ ecrivent, et les rangees que l'app deduit de ses ressources : marques,
7
+ maladies, pays...) avec les informations du projet en pied ; a droite les
8
+ facons d'avancer (`steps`, un lien par onglet) et la repartition par
9
+ famille ; en bas, les derniers ajouts.
10
+
11
+ La description s'edite sur place : `describe` part avec le texte, l'app
12
+ l'enregistre et repasse `saving` a faux. Sans `saveError`, l'editeur se
13
+ referme.
14
+ -->
15
+ <script setup lang="ts">
16
+ import { computed, ref, watch } from 'vue'
17
+ import { rmProjectFamilies, type RmProjectResource, type RmProjectScopeRow, type RmProjectStep } from '../utils/projects'
18
+
19
+ const props = withDefaults(
20
+ defineProps<{
21
+ project: {
22
+ title: string
23
+ description?: string
24
+ createdAt?: string
25
+ ownerName?: string
26
+ /** Ouvert a tout l'espace de travail. */
27
+ shared?: boolean
28
+ } | null
29
+ rows: RmProjectResource[]
30
+ /** Personnes a qui le projet est ouvert nommement, proprietaire exclu. */
31
+ sharedCount?: number
32
+ /** Documents deposes. `null` : inconnu, la tuile n'est pas montree. */
33
+ documentsCount?: number | null
34
+ scopeRows?: RmProjectScopeRow[]
35
+ steps?: RmProjectStep[]
36
+ /** Le lecteur peut modifier la description. */
37
+ editable?: boolean
38
+ loading?: boolean
39
+ saving?: boolean
40
+ saveError?: string
41
+ /** Texte des derniers ajouts quand le projet est vide. */
42
+ emptyText?: string
43
+ lang?: 'fr' | 'en'
44
+ }>(),
45
+ {
46
+ sharedCount: 0,
47
+ documentsCount: null,
48
+ scopeRows: () => [],
49
+ steps: () => [],
50
+ editable: true,
51
+ loading: false,
52
+ saving: false,
53
+ saveError: '',
54
+ emptyText: '',
55
+ lang: 'fr',
56
+ },
57
+ )
58
+
59
+ const emit = defineEmits<{
60
+ tab: [key: string]
61
+ describe: [description: string]
62
+ open: [row: RmProjectResource]
63
+ }>()
64
+
65
+ const WORDS = {
66
+ fr: {
67
+ resources: 'Ressources', families: 'Familles', documents: 'Documents déposés', comments: 'Commentaires',
68
+ scope: 'Cadrage du projet', scopeText: 'Déduit des ressources rangées dans le projet. La description reste modifiable.',
69
+ edit: 'Modifier', placeholder: 'De quoi parle ce projet, à quoi il sert, ce qu\'il reste à faire',
70
+ cancel: 'Annuler', save: 'Enregistrer', fill: 'À renseigner',
71
+ created: 'Créé le', owner: 'Propriétaire', visibility: 'Visibilité', last: 'Dernier ajout',
72
+ workspace: 'Tout l\'espace de travail', private: 'Privé', people: (n: number) => `${n} ${n > 1 ? 'personnes' : 'personne'}`,
73
+ start: 'Démarrer', startText: 'Pour faire avancer ce projet.', startEmpty: 'Le projet est vide pour l\'instant. Pour le remplir :',
74
+ breakdown: 'Répartition par famille', nothing: 'Rien n\'est encore rangé.',
75
+ latest: 'Derniers ajouts', all: 'Toutes les ressources', unknown: 'Élément inconnu',
76
+ empty: 'Parcourez les pages et utilisez le bouton Projet d\'une carte pour la ranger ici.',
77
+ },
78
+ en: {
79
+ resources: 'Resources', families: 'Families', documents: 'Documents', comments: 'Comments',
80
+ scope: 'Project scope', scopeText: 'Drawn from the resources filed in the project. The description can be edited.',
81
+ edit: 'Edit', placeholder: 'What is this project about, what is it for, what is left to do',
82
+ cancel: 'Cancel', save: 'Save', fill: 'To fill in',
83
+ created: 'Created', owner: 'Owner', visibility: 'Visibility', last: 'Last addition',
84
+ workspace: 'Whole workspace', private: 'Private', people: (n: number) => `${n} ${n > 1 ? 'people' : 'person'}`,
85
+ start: 'Get started', startText: 'Ways to move this project forward.', startEmpty: 'The project is empty for now. To fill it:',
86
+ breakdown: 'Breakdown by family', nothing: 'Nothing filed yet.',
87
+ latest: 'Latest additions', all: 'All resources', unknown: 'Unknown item',
88
+ empty: 'Browse the pages and use the Project button on a card to file it here.',
89
+ },
90
+ }
91
+ const T = computed(() => WORDS[props.lang] ?? WORDS.fr)
92
+
93
+ const families = computed(() => rmProjectFamilies(props.rows))
94
+ const commentTotal = computed(() => props.rows.reduce((sum, row) => sum + (row.comments ?? 0), 0))
95
+
96
+ const kpis = computed(() => [
97
+ { icon: 'Stack', value: props.rows.length, legend: T.value.resources },
98
+ { icon: 'SquaresFour', value: families.value.length, legend: T.value.families },
99
+ ...(props.documentsCount == null ? [] : [{ icon: 'FileText', value: props.documentsCount, legend: T.value.documents }]),
100
+ { icon: 'ChatCircle', value: commentTotal.value, legend: T.value.comments },
101
+ ])
102
+
103
+ const breakdown = computed(() =>
104
+ [...families.value].sort((a, b) => b.count - a.count).map(f => ({ name: f.label, value: f.count })))
105
+
106
+ const visibility = computed(() => {
107
+ if (props.project?.shared) return T.value.workspace
108
+ return props.sharedCount ? T.value.people(props.sharedCount) : T.value.private
109
+ })
110
+
111
+ const lastAdded = computed(() =>
112
+ props.rows.map(row => row.createdAt ?? '').filter(Boolean).sort().at(-1) ?? '')
113
+
114
+ const latest = computed(() =>
115
+ [...props.rows].sort((a, b) => String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? ''))).slice(0, 5))
116
+
117
+ function formatDate(value?: string) {
118
+ const date = new Date(value ?? '')
119
+ if (Number.isNaN(date.getTime())) return ''
120
+ return date.toLocaleDateString(props.lang === 'en' ? 'en-GB' : 'fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
121
+ }
122
+
123
+ const editing = ref(false)
124
+ const draft = ref('')
125
+
126
+ function startEdit() {
127
+ draft.value = props.project?.description ?? ''
128
+ editing.value = true
129
+ }
130
+
131
+ watch(() => props.saving, (now, was) => {
132
+ if (was && !now && !props.saveError) editing.value = false
133
+ })
134
+
135
+ /** Le reste d'une rangee du cadrage, au survol de sa pastille « +X ». */
136
+ const SCOPE_MAX = 8
137
+ const moreOpen = ref(false)
138
+ const moreAnchor = ref<HTMLElement | null>(null)
139
+ const moreRow = ref<RmProjectScopeRow | null>(null)
140
+
141
+ function showMore(row: RmProjectScopeRow, event: Event) {
142
+ moreAnchor.value = event.currentTarget as HTMLElement
143
+ moreRow.value = row
144
+ moreOpen.value = true
145
+ }
146
+
147
+ function onLatest(event: MouseEvent, row: RmProjectResource) {
148
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return
149
+ event.preventDefault()
150
+ emit('open', row)
151
+ }
152
+ </script>
153
+
154
+ <template>
155
+ <div class="rm-project-ov">
156
+ <RmKpiBar v-if="loading" loading :tiles="kpis.length" />
157
+ <RmKpiBar v-else :kpis="kpis" />
158
+
159
+ <div class="rm-project-ov__top">
160
+ <RmCard padding="lg" class="rm-project-ov__card is-scope">
161
+ <div class="rm-project-ov__card-head">
162
+ <div class="rm-project-ov__titles">
163
+ <h2 class="rm-project-ov__title">{{ T.scope }}</h2>
164
+ <p class="rm-project-ov__context">{{ T.scopeText }}</p>
165
+ </div>
166
+ <RmButton v-if="editable && !editing" variant="secondary" size="sm" prefix-icon="PencilSimple" @click="startEdit">
167
+ {{ T.edit }}
168
+ </RmButton>
169
+ </div>
170
+
171
+ <ul class="rm-project-ov__scope">
172
+ <li class="rm-project-ov__scope-row">
173
+ <RmIcon name="TextAlignLeft" :size="16" class="rm-project-ov__scope-icon" />
174
+ <div class="rm-project-ov__scope-body">
175
+ <p class="rm-project-ov__scope-key">Description</p>
176
+ <template v-if="editing">
177
+ <RmTextarea v-model="draft" :rows="4" :placeholder="T.placeholder" :error="saveError" />
178
+ <div class="rm-project-ov__edit-actions">
179
+ <RmButton variant="tertiary" size="sm" @click="editing = false">{{ T.cancel }}</RmButton>
180
+ <RmButton variant="primary" size="sm" :loading="saving" @click="emit('describe', draft.trim())">{{ T.save }}</RmButton>
181
+ </div>
182
+ </template>
183
+ <!-- Coupee a trois lignes : le cadrage se lit d'un coup d'oeil. -->
184
+ <RmClampText v-else-if="project?.description" class="rm-project-ov__scope-value" :text="project.description" :lines="3" :lang="lang" />
185
+ <button v-else-if="editable" type="button" class="rm-project-ov__placeholder" @click="startEdit">{{ T.fill }}</button>
186
+ </div>
187
+ </li>
188
+
189
+ <li v-for="row in scopeRows" :key="row.key" class="rm-project-ov__scope-row">
190
+ <RmIcon :name="row.icon" :size="16" class="rm-project-ov__scope-icon" />
191
+ <div class="rm-project-ov__scope-body">
192
+ <p class="rm-project-ov__scope-key">{{ row.label }}</p>
193
+ <div v-if="row.values.length" class="rm-project-ov__tags">
194
+ <RmTag v-for="value in row.values.slice(0, SCOPE_MAX)" :key="value" variant="gray" class="rm-project-ov__tag" :title="value">
195
+ {{ value }}
196
+ </RmTag>
197
+ <RmTag
198
+ v-if="row.values.length > SCOPE_MAX"
199
+ variant="gray"
200
+ tabindex="0"
201
+ :aria-label="`${row.label} : ${row.values.slice(SCOPE_MAX).join(', ')}`"
202
+ @mouseenter="showMore(row, $event)"
203
+ @focus="showMore(row, $event)"
204
+ @mouseleave="moreOpen = false"
205
+ @blur="moreOpen = false"
206
+ >
207
+ +{{ row.values.length - SCOPE_MAX }}
208
+ </RmTag>
209
+ </div>
210
+ <p v-else class="rm-project-ov__scope-none">{{ row.none ?? '' }}</p>
211
+ </div>
212
+ </li>
213
+ </ul>
214
+
215
+ <RmPopover v-model="moreOpen" :anchor="moreAnchor" :label="moreRow?.label" align="start">
216
+ <ul class="rm-project-ov__more">
217
+ <li v-for="value in moreRow?.values.slice(SCOPE_MAX) ?? []" :key="value">{{ value }}</li>
218
+ </ul>
219
+ </RmPopover>
220
+
221
+ <dl class="rm-project-ov__meta">
222
+ <div v-if="project?.createdAt">
223
+ <dt>{{ T.created }}</dt>
224
+ <dd>{{ formatDate(project.createdAt) }}</dd>
225
+ </div>
226
+ <div v-if="project?.ownerName">
227
+ <dt>{{ T.owner }}</dt>
228
+ <dd>{{ project.ownerName }}</dd>
229
+ </div>
230
+ <div>
231
+ <dt>{{ T.visibility }}</dt>
232
+ <dd>{{ visibility }}</dd>
233
+ </div>
234
+ <div v-if="lastAdded">
235
+ <dt>{{ T.last }}</dt>
236
+ <dd>{{ formatDate(lastAdded) }}</dd>
237
+ </div>
238
+ </dl>
239
+ </RmCard>
240
+
241
+ <div class="rm-project-ov__side">
242
+ <RmCard v-if="steps.length" padding="lg" class="rm-project-ov__card">
243
+ <div class="rm-project-ov__titles">
244
+ <h2 class="rm-project-ov__title">{{ T.start }}</h2>
245
+ <p class="rm-project-ov__context">{{ rows.length ? T.startText : T.startEmpty }}</p>
246
+ </div>
247
+ <ol class="rm-project-ov__start">
248
+ <li v-for="step in steps" :key="step.tab">
249
+ <button type="button" class="rm-project-ov__start-item" @click="emit('tab', step.tab)">
250
+ <span class="rm-project-ov__start-icon"><RmIcon :name="step.icon" :size="17" /></span>
251
+ <span class="rm-project-ov__start-text">
252
+ <span class="rm-project-ov__start-title">{{ step.title }}</span>
253
+ <span class="rm-project-ov__start-desc">{{ step.text }}</span>
254
+ </span>
255
+ <RmIcon name="ArrowRight" :size="14" class="rm-project-ov__start-arrow" />
256
+ </button>
257
+ </li>
258
+ </ol>
259
+ </RmCard>
260
+
261
+ <RmSectionCard class="rm-project-ov__card">
262
+ <template #header>{{ T.breakdown }}</template>
263
+ <RmDonutChart v-if="breakdown.length" :data="breakdown" :height="240" hide-legend />
264
+ <p v-else class="rm-project-ov__empty">{{ T.nothing }}</p>
265
+ </RmSectionCard>
266
+ </div>
267
+ </div>
268
+
269
+ <RmSectionCard class="rm-project-ov__card">
270
+ <template #header>
271
+ <div class="rm-project-ov__head">
272
+ <span>{{ T.latest }}</span>
273
+ <RmButton v-if="rows.length" variant="text" size="sm" suffix-icon="ArrowRight" @click="emit('tab', 'resources')">
274
+ {{ T.all }}
275
+ </RmButton>
276
+ </div>
277
+ </template>
278
+ <ul v-if="latest.length" class="rm-project-ov__list">
279
+ <li v-for="row in latest" :key="`${row.type}-${row.id}`" class="rm-project-ov__row">
280
+ <RmIcon :name="row.icon" :size="16" class="rm-project-ov__row-icon" />
281
+ <a v-if="row.path" :href="row.path" class="rm-project-ov__row-title" @click="onLatest($event, row)">{{ row.title || T.unknown }}</a>
282
+ <span v-else class="rm-project-ov__row-title">{{ row.title || T.unknown }}</span>
283
+ <span class="rm-project-ov__row-family">{{ row.family }}</span>
284
+ <span v-if="row.createdAt" class="rm-project-ov__row-date">{{ formatDate(row.createdAt) }}</span>
285
+ </li>
286
+ </ul>
287
+ <p v-else class="rm-project-ov__empty">{{ emptyText || T.empty }}</p>
288
+ </RmSectionCard>
289
+ </div>
290
+ </template>
291
+
292
+ <style scoped>
293
+ .rm-project-ov{display:flex;flex-direction:column;gap:16px}.rm-project-ov__top{align-items:stretch;display:grid;gap:16px;grid-template-columns:minmax(0,3fr) minmax(0,2fr)}@media (max-width:1100px){.rm-project-ov__top{grid-template-columns:1fr}}.rm-project-ov__card{min-width:0}.rm-project-ov__card.is-scope,.rm-project-ov__card.is-scope :deep(.r-card__body){display:flex;flex-direction:column}.rm-project-ov__card.is-scope{height:100%}.rm-project-ov__card.is-scope :deep(.r-card__body){flex:1}.rm-project-ov__card-head{align-items:flex-start;display:flex;gap:12px;justify-content:space-between}.rm-project-ov__titles{display:flex;flex-direction:column;gap:4px;margin-bottom:12px}.rm-project-ov__title{color:var(--text-primary);font-size:15px;font-weight:var(--weight-bold);margin:0}.rm-project-ov__context{color:var(--text-muted);font-size:12px;margin:0}.rm-project-ov__scope{display:flex;flex-direction:column;list-style:none;margin:0 0 16px;padding:0}.rm-project-ov__scope-row{align-items:flex-start;border-bottom:1px solid var(--border-soft,var(--border-default));display:flex;gap:12px;padding:8px 0}.rm-project-ov__scope-icon{color:var(--color-secondary);flex:none;margin-top:1px}.rm-project-ov__scope-body{flex:1;min-width:0}.rm-project-ov__scope-key{color:var(--text-muted);font-size:10.5px;font-weight:700;letter-spacing:.08em;margin:0 0 4px;text-transform:uppercase}.rm-project-ov__scope-value{color:var(--text-primary);font-size:13px;font-weight:600;margin:0;white-space:pre-line}.rm-project-ov__tags{display:flex;flex-wrap:wrap;gap:6px}.rm-project-ov__tag{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rm-project-ov__scope-none{color:var(--text-muted);font-size:13px;font-style:italic;margin:0}.rm-project-ov__more{font-size:13px;margin:0;padding-left:16px}.rm-project-ov__placeholder{background:none;border:none;color:var(--text-muted);cursor:pointer;font:inherit;font-size:13px;font-style:italic;margin:0;padding:0}.rm-project-ov__placeholder:hover{text-decoration:underline}.rm-project-ov__edit-actions{display:flex;gap:8px;justify-content:flex-end;margin:8px 0 12px}.rm-project-ov__meta{border-top:1px solid var(--border-default);display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));margin:auto 0 0;padding-top:16px}.rm-project-ov__meta dt{color:var(--text-secondary);font-size:12px}.rm-project-ov__meta dd{font-size:14px;font-weight:500;margin:2px 0 0}.rm-project-ov__side{display:flex;flex-direction:column;gap:16px;min-width:0}.rm-project-ov__side>:last-child{flex:1}.rm-project-ov__start{display:flex;flex-direction:column;gap:8px;list-style:none;margin:0;padding:0}.rm-project-ov__start-item{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-xl,12px);color:inherit;cursor:pointer;display:flex;font:inherit;gap:12px;padding:10px 12px;text-align:left;transition:border-color .15s,background .15s,box-shadow .15s;width:100%}.rm-project-ov__start-item:hover{background:var(--bg-hover,var(--bg-surface-2));border-color:var(--color-secondary);box-shadow:var(--shadow-sm)}.rm-project-ov__start-item:focus-visible{outline:2px solid var(--color-primary);outline-offset:2px}.rm-project-ov__start-icon{align-items:center;background:var(--bg-hover,var(--bg-surface-2));border-radius:var(--radius-lg,8px);color:var(--color-secondary);display:flex;flex:none;height:34px;justify-content:center;width:34px}.rm-project-ov__start-text{display:flex;flex:1;flex-direction:column;gap:2px;min-width:0}.rm-project-ov__start-title{color:var(--text-primary);font-size:13px;font-weight:700}.rm-project-ov__start-desc{color:var(--text-muted);font-size:11px;line-height:1.45}.rm-project-ov__start-arrow{color:var(--text-muted);flex:none}.rm-project-ov__head{align-items:center;display:flex;justify-content:space-between;width:100%}.rm-project-ov__empty{color:var(--text-secondary);font-size:14px;margin:0}.rm-project-ov__list{display:flex;flex-direction:column;list-style:none;margin:0;padding:0}.rm-project-ov__row{align-items:center;border-bottom:1px solid var(--border-default);display:flex;font-size:14px;gap:10px;padding:8px 0}.rm-project-ov__row:last-child{border-bottom:none}.rm-project-ov__row-icon{color:var(--text-secondary);flex:none}.rm-project-ov__row-title{color:var(--text-primary);flex:1;min-width:0;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}a.rm-project-ov__row-title{color:var(--color-primary)}a.rm-project-ov__row-title:hover{text-decoration:underline}.rm-project-ov__row-date,.rm-project-ov__row-family{color:var(--text-muted);flex:none;font-size:12px}
294
+ </style>
@@ -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>