@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.
- package/dist/module.json +1 -1
- package/dist/runtime/components/ProjectAddButton.vue +226 -0
- package/dist/runtime/components/ProjectBanner.vue +134 -0
- package/dist/runtime/components/ProjectComments.vue +110 -0
- package/dist/runtime/components/ProjectCreateDialog.vue +117 -0
- package/dist/runtime/components/ProjectDeleteDialog.vue +60 -0
- package/dist/runtime/components/ProjectList.vue +115 -0
- package/dist/runtime/components/ProjectOverview.vue +294 -0
- package/dist/runtime/components/ProjectPanel.vue +193 -0
- package/dist/runtime/components/ProjectRenameDialog.vue +67 -0
- package/dist/runtime/components/ProjectResources.vue +258 -0
- package/dist/runtime/utils/projects.d.ts +200 -0
- package/dist/runtime/utils/projects.js +123 -0
- package/package.json +1 -1
|
@@ -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>
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Les projets Virtual Lab : un dossier partage ou l'utilisateur range ce qu'il
|
|
3
|
+
* croise dans les apps OIP (un essai, une publication, un avis HAS...).
|
|
4
|
+
*
|
|
5
|
+
* L'API ne garde d'un element que sa paire (resourceId, resourceType). Pour
|
|
6
|
+
* l'afficher, une app relit le document dans Elasticsearch, et c'est tout ce
|
|
7
|
+
* qu'elle a a decrire : une « famille » (`RmProjectKind`) dit dans quel index
|
|
8
|
+
* chercher, comment titrer le document et ou mene son lien. Le reste (les
|
|
9
|
+
* lignes, la recherche, le tri, les groupes de projets, le resume d'un projet)
|
|
10
|
+
* vit ici, pour que toutes les apps affichent n'importe quel projet de la
|
|
11
|
+
* meme facon, y compris les familles rangees depuis une autre app des qu'elles
|
|
12
|
+
* en connaissent la description.
|
|
13
|
+
*
|
|
14
|
+
* Les composants RmProject* ne font aucun appel : ils recoivent ces objets et
|
|
15
|
+
* rendent des intentions (ajouter, ouvrir, retirer). Les types sont ici et pas
|
|
16
|
+
* dans les `.vue` : le paquet n'expose que `utils/*`, et une app qui construit
|
|
17
|
+
* ces objets doit pouvoir les typer.
|
|
18
|
+
*/
|
|
19
|
+
export type RmProjectLang = 'fr' | 'en';
|
|
20
|
+
/** Un projet, tel que les listes l'affichent. */
|
|
21
|
+
export interface RmProject {
|
|
22
|
+
id: string;
|
|
23
|
+
title: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
createdAt?: string;
|
|
26
|
+
/** Proprietaire : nom affiche, a defaut l'email. */
|
|
27
|
+
ownerName?: string;
|
|
28
|
+
/** Le lecteur en est le proprietaire. */
|
|
29
|
+
mine?: boolean;
|
|
30
|
+
/** Ouvert a l'espace de travail ou a d'autres personnes. */
|
|
31
|
+
shared?: boolean;
|
|
32
|
+
/** Ressources presentes, toutes familles confondues. Absent : non compte. */
|
|
33
|
+
resourcesCount?: number;
|
|
34
|
+
pinned?: boolean;
|
|
35
|
+
/** Lien vers la page du projet. */
|
|
36
|
+
to?: string;
|
|
37
|
+
}
|
|
38
|
+
export interface RmProjectGroup {
|
|
39
|
+
key: 'pinned' | 'mine' | 'shared';
|
|
40
|
+
label: string;
|
|
41
|
+
projects: RmProject[];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Une famille de ressources : ce qu'une app sait relire et afficher.
|
|
45
|
+
*
|
|
46
|
+
* `S` est la forme du `_source` Elasticsearch. L'identifiant range est
|
|
47
|
+
* toujours l'`_id` du document : c'est par lui que l'API verifie un ajout.
|
|
48
|
+
*/
|
|
49
|
+
export interface RmProjectKind<S = any> {
|
|
50
|
+
/** Valeur de `resourceType` cote API. */
|
|
51
|
+
type: string;
|
|
52
|
+
index: string;
|
|
53
|
+
label: Record<RmProjectLang, string>;
|
|
54
|
+
/** Rubrique de l'app d'ou vient la famille (Essais, Publications...). */
|
|
55
|
+
section?: Record<RmProjectLang, string>;
|
|
56
|
+
/** Icone Phosphor. */
|
|
57
|
+
icon: string;
|
|
58
|
+
/** Champs a relire. Absent : le document entier. */
|
|
59
|
+
source?: string[];
|
|
60
|
+
title: (source: S, lang: RmProjectLang) => string;
|
|
61
|
+
subtitle?: (source: S, lang: RmProjectLang) => string;
|
|
62
|
+
/** Chemin de la fiche dans l'app, sans prefixe de langue. */
|
|
63
|
+
path?: (source: S, id: string) => string;
|
|
64
|
+
/** Titre lisible dans l'identifiant seul, quand le document a disparu. */
|
|
65
|
+
titleFromId?: (id: string) => string;
|
|
66
|
+
}
|
|
67
|
+
/** Un element du projet, tel que l'API le rend. */
|
|
68
|
+
export interface RmProjectItem {
|
|
69
|
+
resourceId: string;
|
|
70
|
+
resourceType: string;
|
|
71
|
+
createdAt?: string;
|
|
72
|
+
/** Commentaires et citations dans les notes. */
|
|
73
|
+
commentsCount?: number;
|
|
74
|
+
}
|
|
75
|
+
/** Une ligne de ressource, prete a afficher. */
|
|
76
|
+
export interface RmProjectResource {
|
|
77
|
+
id: string;
|
|
78
|
+
type: string;
|
|
79
|
+
/** Vide quand le document n'a pas ete retrouve. */
|
|
80
|
+
title: string;
|
|
81
|
+
subtitle?: string;
|
|
82
|
+
path?: string;
|
|
83
|
+
/** Libelle de la famille. */
|
|
84
|
+
family: string;
|
|
85
|
+
section?: string;
|
|
86
|
+
icon: string;
|
|
87
|
+
createdAt?: string;
|
|
88
|
+
comments?: number;
|
|
89
|
+
/** La famille est decrite par l'app. Faux : element range ailleurs. */
|
|
90
|
+
known: boolean;
|
|
91
|
+
/** Le document relu, pour un affichage propre a l'app. */
|
|
92
|
+
source?: unknown;
|
|
93
|
+
}
|
|
94
|
+
/** Une fiche refusee parce qu'archivee, nommee pour la question a poser. */
|
|
95
|
+
export interface RmProjectArchivedEntry {
|
|
96
|
+
key: string;
|
|
97
|
+
label: string;
|
|
98
|
+
}
|
|
99
|
+
/** Une rangee du cadrage, deduite par l'app de ce que le projet range. */
|
|
100
|
+
export interface RmProjectScopeRow {
|
|
101
|
+
key: string;
|
|
102
|
+
label: string;
|
|
103
|
+
icon: string;
|
|
104
|
+
values: string[];
|
|
105
|
+
/** Texte quand la rangee est vide. */
|
|
106
|
+
none?: string;
|
|
107
|
+
}
|
|
108
|
+
/** Une facon d'avancer : un onglet du projet. */
|
|
109
|
+
export interface RmProjectStep {
|
|
110
|
+
tab: string;
|
|
111
|
+
icon: string;
|
|
112
|
+
title: string;
|
|
113
|
+
text: string;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Une entree du fil d'une ressource : un commentaire, ou une note du projet
|
|
117
|
+
* qui la cite (avec les passages qui la citent).
|
|
118
|
+
*/
|
|
119
|
+
export interface RmProjectCommentEntry {
|
|
120
|
+
id: string;
|
|
121
|
+
author?: string;
|
|
122
|
+
date?: string;
|
|
123
|
+
text?: string;
|
|
124
|
+
/** Presente quand l'entree est une note qui cite la ressource. */
|
|
125
|
+
note?: {
|
|
126
|
+
id: string;
|
|
127
|
+
title?: string;
|
|
128
|
+
icon?: string;
|
|
129
|
+
private?: boolean;
|
|
130
|
+
excerpts?: string[];
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
export type RmProjectSort = 'date-desc' | 'date-asc' | 'title' | 'family';
|
|
134
|
+
/** Les documents relus, par famille puis par identifiant. */
|
|
135
|
+
export type RmProjectDocuments = Record<string, Record<string, unknown>>;
|
|
136
|
+
/** « 1 ressource », « 3 ressources ». */
|
|
137
|
+
export declare function rmProjectCount(count: number, lang?: RmProjectLang): string;
|
|
138
|
+
/**
|
|
139
|
+
* La ligne qui situe un projet : qui le tient, s'il est partage, combien il
|
|
140
|
+
* contient. Sans compte, elle s'en passe plutot que d'afficher zero.
|
|
141
|
+
*/
|
|
142
|
+
export declare function rmProjectSummary(project: RmProject, lang?: RmProjectLang): string;
|
|
143
|
+
/**
|
|
144
|
+
* Les epingles d'abord, quel que soit leur proprietaire, puis les miens, puis
|
|
145
|
+
* ceux qu'on m'a partages. Un groupe vide n'est pas rendu ; l'ordre recu tient
|
|
146
|
+
* dans chaque groupe.
|
|
147
|
+
*/
|
|
148
|
+
export declare function rmProjectGroups(list: RmProject[], lang?: RmProjectLang): RmProjectGroup[];
|
|
149
|
+
/**
|
|
150
|
+
* La requete qui relit les documents d'une famille. Un appel par famille : la
|
|
151
|
+
* requete porte sur l'index du type, c'est le seul lien entre un element range
|
|
152
|
+
* et sa donnee.
|
|
153
|
+
*/
|
|
154
|
+
export declare function rmProjectQuery(kind: RmProjectKind, ids: string[]): Record<string, unknown>;
|
|
155
|
+
/** Les hits d'une reponse, ranges par `_id`. */
|
|
156
|
+
export declare function rmProjectIndexHits(hits: Array<{
|
|
157
|
+
_id: string;
|
|
158
|
+
_source?: any;
|
|
159
|
+
}>): Record<string, unknown>;
|
|
160
|
+
/**
|
|
161
|
+
* Relit les documents de tous les elements, une requete par famille, en
|
|
162
|
+
* parallele. `search` est l'appel Elasticsearch de l'app (son proxy). Une
|
|
163
|
+
* famille en echec reste vide : ses lignes sortent sans titre, la page tient.
|
|
164
|
+
*/
|
|
165
|
+
export declare function rmProjectHydrate(items: RmProjectItem[], kinds: RmProjectKind[], search: (index: string, body: Record<string, unknown>) => Promise<{
|
|
166
|
+
hits?: {
|
|
167
|
+
hits?: Array<{
|
|
168
|
+
_id: string;
|
|
169
|
+
_source?: any;
|
|
170
|
+
}>;
|
|
171
|
+
};
|
|
172
|
+
} | null | undefined>): Promise<RmProjectDocuments>;
|
|
173
|
+
/**
|
|
174
|
+
* Les lignes du projet, dans l'ordre des familles de l'app. Un element dont
|
|
175
|
+
* la famille n'est pas decrite sort quand meme (`known: false`), sous son
|
|
176
|
+
* type : mieux vaut le montrer que le faire disparaitre du projet. A l'app de
|
|
177
|
+
* le filtrer si elle ne veut que les siennes.
|
|
178
|
+
*/
|
|
179
|
+
export declare function rmProjectRows(items: RmProjectItem[], documents: RmProjectDocuments, kinds: RmProjectKind[], lang?: RmProjectLang): RmProjectResource[];
|
|
180
|
+
/** Les familles presentes, avec leur compte, dans l'ordre des lignes. */
|
|
181
|
+
export declare function rmProjectFamilies(rows: RmProjectResource[]): Array<{
|
|
182
|
+
type: string;
|
|
183
|
+
label: string;
|
|
184
|
+
icon: string;
|
|
185
|
+
count: number;
|
|
186
|
+
}>;
|
|
187
|
+
export interface RmProjectFilter {
|
|
188
|
+
search?: string;
|
|
189
|
+
/** Famille retenue, `all` ou absent pour toutes. */
|
|
190
|
+
family?: string;
|
|
191
|
+
onlyCommented?: boolean;
|
|
192
|
+
sort?: RmProjectSort;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Filtre et trie en memoire : un projet tient quelques centaines d'elements,
|
|
196
|
+
* deja relus. La recherche porte sur tout ce que la ligne affiche et sur
|
|
197
|
+
* l'identifiant (un element sans document n'a plus que lui) ; chaque mot doit
|
|
198
|
+
* se trouver quelque part, casse et accents ignores.
|
|
199
|
+
*/
|
|
200
|
+
export declare function rmProjectFilterRows(rows: RmProjectResource[], filter: RmProjectFilter, lang?: RmProjectLang): RmProjectResource[];
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
const WORDS = {
|
|
2
|
+
fr: {
|
|
3
|
+
pinned: "\xC9pingl\xE9s",
|
|
4
|
+
mine: "Mes projets",
|
|
5
|
+
shared: "Partag\xE9s avec moi",
|
|
6
|
+
me: "Moi",
|
|
7
|
+
isShared: "Partag\xE9",
|
|
8
|
+
personal: "Personnel",
|
|
9
|
+
resource: "ressource",
|
|
10
|
+
resources: "ressources"
|
|
11
|
+
},
|
|
12
|
+
en: {
|
|
13
|
+
pinned: "Pinned",
|
|
14
|
+
mine: "My projects",
|
|
15
|
+
shared: "Shared with me",
|
|
16
|
+
me: "Me",
|
|
17
|
+
isShared: "Shared",
|
|
18
|
+
personal: "Personal",
|
|
19
|
+
resource: "resource",
|
|
20
|
+
resources: "resources"
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
export function rmProjectCount(count, lang = "fr") {
|
|
24
|
+
const T = WORDS[lang] ?? WORDS.fr;
|
|
25
|
+
return `${count} ${count > 1 ? T.resources : T.resource}`;
|
|
26
|
+
}
|
|
27
|
+
export function rmProjectSummary(project, lang = "fr") {
|
|
28
|
+
const T = WORDS[lang] ?? WORDS.fr;
|
|
29
|
+
const owner = project.mine ? T.me : project.ownerName || "";
|
|
30
|
+
const kind = project.shared ? T.isShared : T.personal;
|
|
31
|
+
const size = project.resourcesCount == null ? "" : rmProjectCount(project.resourcesCount, lang);
|
|
32
|
+
return [owner, kind, size].filter(Boolean).join(" \xB7 ");
|
|
33
|
+
}
|
|
34
|
+
export function rmProjectGroups(list, lang = "fr") {
|
|
35
|
+
const T = WORDS[lang] ?? WORDS.fr;
|
|
36
|
+
const groups = [
|
|
37
|
+
{ key: "pinned", label: T.pinned, projects: list.filter((p) => p.pinned) },
|
|
38
|
+
{ key: "mine", label: T.mine, projects: list.filter((p) => !p.pinned && p.mine) },
|
|
39
|
+
{ key: "shared", label: T.shared, projects: list.filter((p) => !p.pinned && !p.mine) }
|
|
40
|
+
];
|
|
41
|
+
return groups.filter((group) => group.projects.length);
|
|
42
|
+
}
|
|
43
|
+
function fold(value) {
|
|
44
|
+
return String(value ?? "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
|
|
45
|
+
}
|
|
46
|
+
export function rmProjectQuery(kind, ids) {
|
|
47
|
+
return {
|
|
48
|
+
size: ids.length,
|
|
49
|
+
...kind.source && { _source: kind.source },
|
|
50
|
+
query: { ids: { values: ids } }
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function rmProjectIndexHits(hits) {
|
|
54
|
+
return Object.fromEntries(hits.map((hit) => [String(hit._id), hit._source ?? {}]));
|
|
55
|
+
}
|
|
56
|
+
export async function rmProjectHydrate(items, kinds, search) {
|
|
57
|
+
const byType = /* @__PURE__ */ new Map();
|
|
58
|
+
for (const item of items) {
|
|
59
|
+
if (!kinds.some((kind) => kind.type === item.resourceType)) continue;
|
|
60
|
+
byType.set(item.resourceType, [...byType.get(item.resourceType) ?? [], item.resourceId]);
|
|
61
|
+
}
|
|
62
|
+
const out = {};
|
|
63
|
+
await Promise.all(
|
|
64
|
+
[...byType].map(async ([type, ids]) => {
|
|
65
|
+
const kind = kinds.find((k) => k.type === type);
|
|
66
|
+
out[type] = {};
|
|
67
|
+
try {
|
|
68
|
+
const response = await search(kind.index, rmProjectQuery(kind, [...new Set(ids)]));
|
|
69
|
+
out[type] = rmProjectIndexHits(response?.hits?.hits ?? []);
|
|
70
|
+
} catch {
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
);
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
export function rmProjectRows(items, documents, kinds, lang = "fr") {
|
|
77
|
+
const rank = (type) => {
|
|
78
|
+
const index = kinds.findIndex((kind) => kind.type === type);
|
|
79
|
+
return index < 0 ? kinds.length : index;
|
|
80
|
+
};
|
|
81
|
+
return [...items].sort((a, b) => rank(a.resourceType) - rank(b.resourceType)).map((item) => {
|
|
82
|
+
const kind = kinds.find((k) => k.type === item.resourceType);
|
|
83
|
+
const source = documents[item.resourceType]?.[item.resourceId];
|
|
84
|
+
return {
|
|
85
|
+
id: item.resourceId,
|
|
86
|
+
type: item.resourceType,
|
|
87
|
+
title: kind && source ? kind.title(source, lang) : kind?.titleFromId?.(item.resourceId) ?? "",
|
|
88
|
+
subtitle: kind && source ? kind.subtitle?.(source, lang) ?? "" : "",
|
|
89
|
+
path: kind && source ? kind.path?.(source, item.resourceId) ?? "" : "",
|
|
90
|
+
family: kind ? kind.label[lang] : item.resourceType,
|
|
91
|
+
section: kind?.section?.[lang],
|
|
92
|
+
icon: kind?.icon ?? "Question",
|
|
93
|
+
createdAt: item.createdAt,
|
|
94
|
+
comments: item.commentsCount ?? 0,
|
|
95
|
+
known: !!kind,
|
|
96
|
+
source
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
export function rmProjectFamilies(rows) {
|
|
101
|
+
const out = [];
|
|
102
|
+
for (const row of rows) {
|
|
103
|
+
const found = out.find((family) => family.type === row.type);
|
|
104
|
+
if (found) found.count++;
|
|
105
|
+
else out.push({ type: row.type, label: row.family, icon: row.icon, count: 1 });
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
export function rmProjectFilterRows(rows, filter, lang = "fr") {
|
|
110
|
+
const words = fold(filter.search).split(/\s+/).filter(Boolean);
|
|
111
|
+
const family = filter.family && filter.family !== "all" ? filter.family : "";
|
|
112
|
+
const order = rmProjectFamilies(rows).map((f) => f.type);
|
|
113
|
+
const list = rows.filter((row) => !family || row.type === family).filter((row) => !filter.onlyCommented || (row.comments ?? 0) > 0).filter((row) => !words.length || words.every((word) => fold([row.title, row.subtitle, row.family, row.section, row.id].join(" ")).includes(word)));
|
|
114
|
+
const byDate = (a, b) => String(a.createdAt ?? "").localeCompare(String(b.createdAt ?? ""));
|
|
115
|
+
const byTitle = (a, b) => (a.title || a.id).localeCompare(b.title || b.id, lang);
|
|
116
|
+
const sort = filter.sort ?? "date-desc";
|
|
117
|
+
return [...list].sort((a, b) => {
|
|
118
|
+
if (sort === "date-asc") return byDate(a, b);
|
|
119
|
+
if (sort === "title") return byTitle(a, b);
|
|
120
|
+
if (sort === "family") return order.indexOf(a.type) - order.indexOf(b.type) || byTitle(a, b);
|
|
121
|
+
return byDate(b, a);
|
|
122
|
+
});
|
|
123
|
+
}
|