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