@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
package/dist/module.json
CHANGED
|
@@ -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>
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmProjectCreateDialog - creer un projet : un nom, une description facultative.
|
|
3
|
+
|
|
4
|
+
Fermer apres avoir commence a remplir (croix, clic a cote, Echap) ne doit
|
|
5
|
+
pas perdre ce qui a ete tape : une seconde question « Quitter sans
|
|
6
|
+
enregistrer ? » se pose par-dessus. Le formulaire se vide quand l'app le
|
|
7
|
+
ferme apres une creation reussie, ou quand on confirme l'abandon.
|
|
8
|
+
-->
|
|
9
|
+
<script setup lang="ts">
|
|
10
|
+
import { computed, ref, watch } from 'vue'
|
|
11
|
+
|
|
12
|
+
const props = withDefaults(
|
|
13
|
+
defineProps<{
|
|
14
|
+
modelValue: boolean
|
|
15
|
+
loading?: boolean
|
|
16
|
+
/** Refus de l'API, affiche sous le formulaire. */
|
|
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
|
+
'create': [value: { title: string, description: string }]
|
|
26
|
+
}>()
|
|
27
|
+
|
|
28
|
+
const WORDS = {
|
|
29
|
+
fr: {
|
|
30
|
+
heading: 'Nouveau projet', name: 'Nom', placeholder: 'Veille oncologie', description: 'Description (facultative)',
|
|
31
|
+
required: 'Un nom est requis', cancel: 'Annuler', create: 'Créer',
|
|
32
|
+
leave: 'Quitter sans enregistrer ?', lost: 'Ce que vous avez saisi sera perdu.', keep: 'Continuer la saisie', quit: 'Quitter',
|
|
33
|
+
},
|
|
34
|
+
en: {
|
|
35
|
+
heading: 'New project', name: 'Name', placeholder: 'Oncology watch', description: 'Description (optional)',
|
|
36
|
+
required: 'A name is required', cancel: 'Cancel', create: 'Create',
|
|
37
|
+
leave: 'Leave without saving?', lost: 'What you entered will be lost.', keep: 'Keep editing', quit: 'Leave',
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
const T = computed(() => WORDS[props.lang] ?? WORDS.fr)
|
|
41
|
+
|
|
42
|
+
const title = ref('')
|
|
43
|
+
const description = ref('')
|
|
44
|
+
const missing = ref(false)
|
|
45
|
+
const confirming = ref(false)
|
|
46
|
+
|
|
47
|
+
function reset() {
|
|
48
|
+
title.value = ''
|
|
49
|
+
description.value = ''
|
|
50
|
+
missing.value = false
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Ferme par l'app (creation faite) : le prochain projet repart de zero.
|
|
54
|
+
watch(() => props.modelValue, (open, was) => {
|
|
55
|
+
if (was && !open && !confirming.value) reset()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
function requestClose() {
|
|
59
|
+
if (title.value.trim() || description.value.trim()) confirming.value = true
|
|
60
|
+
else emit('update:modelValue', false)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function discard() {
|
|
64
|
+
confirming.value = false
|
|
65
|
+
reset()
|
|
66
|
+
emit('update:modelValue', false)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function submit() {
|
|
70
|
+
const name = title.value.trim()
|
|
71
|
+
if (!name) {
|
|
72
|
+
missing.value = true
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
emit('create', { title: name, description: description.value.trim() })
|
|
76
|
+
}
|
|
77
|
+
</script>
|
|
78
|
+
|
|
79
|
+
<template>
|
|
80
|
+
<RmModal
|
|
81
|
+
:model-value="modelValue"
|
|
82
|
+
size="sm"
|
|
83
|
+
mode="free"
|
|
84
|
+
:label="T.heading"
|
|
85
|
+
@update:model-value="$event ? emit('update:modelValue', true) : requestClose()"
|
|
86
|
+
>
|
|
87
|
+
<template #header>
|
|
88
|
+
<p class="rm-project-dialog__title">{{ T.heading }}</p>
|
|
89
|
+
</template>
|
|
90
|
+
<form class="rm-project-dialog__form" @submit.prevent="submit">
|
|
91
|
+
<RmInput v-model="title" :label="T.name" :placeholder="T.placeholder" :error="missing ? T.required : ''" />
|
|
92
|
+
<RmInput v-model="description" :label="T.description" />
|
|
93
|
+
<p v-if="error" class="rm-project-dialog__error">{{ error }}</p>
|
|
94
|
+
<!-- Entree valide le formulaire. -->
|
|
95
|
+
<button type="submit" hidden />
|
|
96
|
+
</form>
|
|
97
|
+
<template #footer>
|
|
98
|
+
<RmButton variant="tertiary" @click="requestClose">{{ T.cancel }}</RmButton>
|
|
99
|
+
<RmButton variant="primary" :loading="loading" @click="submit">{{ T.create }}</RmButton>
|
|
100
|
+
</template>
|
|
101
|
+
</RmModal>
|
|
102
|
+
|
|
103
|
+
<RmModal v-model="confirming" size="sm" mode="free" :label="T.leave">
|
|
104
|
+
<template #header>
|
|
105
|
+
<p class="rm-project-dialog__title">{{ T.leave }}</p>
|
|
106
|
+
</template>
|
|
107
|
+
<p class="rm-project-dialog__text">{{ T.lost }}</p>
|
|
108
|
+
<template #footer>
|
|
109
|
+
<RmButton variant="tertiary" @click="confirming = false">{{ T.keep }}</RmButton>
|
|
110
|
+
<RmButton variant="danger" @click="discard">{{ T.quit }}</RmButton>
|
|
111
|
+
</template>
|
|
112
|
+
</RmModal>
|
|
113
|
+
</template>
|
|
114
|
+
|
|
115
|
+
<style scoped>
|
|
116
|
+
.rm-project-dialog__title{font-size:15px;font-weight:600;margin:0}.rm-project-dialog__form{display:flex;flex-direction:column;gap:16px}.rm-project-dialog__text{font-size:var(--font-14);line-height:1.5;margin:0}.rm-project-dialog__error{color:var(--color-danger);font-size:var(--font-13);margin:0}
|
|
117
|
+
</style>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmProjectDeleteDialog - supprimer un projet, pour de bon.
|
|
3
|
+
|
|
4
|
+
La suppression emporte tout ce que le projet rassemble, pour tous ses
|
|
5
|
+
membres : la modale le dit, nomme le projet, et attend un second clic.
|
|
6
|
+
-->
|
|
7
|
+
<script setup lang="ts">
|
|
8
|
+
import { computed } from 'vue'
|
|
9
|
+
|
|
10
|
+
const props = withDefaults(
|
|
11
|
+
defineProps<{
|
|
12
|
+
modelValue: boolean
|
|
13
|
+
title: string
|
|
14
|
+
loading?: boolean
|
|
15
|
+
error?: string
|
|
16
|
+
lang?: 'fr' | 'en'
|
|
17
|
+
}>(),
|
|
18
|
+
{ loading: false, error: '', lang: 'fr' },
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
const emit = defineEmits<{
|
|
22
|
+
'update:modelValue': [value: boolean]
|
|
23
|
+
'confirm': []
|
|
24
|
+
}>()
|
|
25
|
+
|
|
26
|
+
const WORDS = {
|
|
27
|
+
fr: {
|
|
28
|
+
heading: 'Supprimer le projet', cancel: 'Annuler', confirm: 'Supprimer définitivement',
|
|
29
|
+
text: (t: string) => `« ${t} » sera supprimé définitivement, avec ses ressources, ses commentaires et ses documents déposés, pour tous ses membres. Cette action est irréversible.`,
|
|
30
|
+
},
|
|
31
|
+
en: {
|
|
32
|
+
heading: 'Delete the project', cancel: 'Cancel', confirm: 'Delete for good',
|
|
33
|
+
text: (t: string) => `« ${t} » will be deleted for good, with its resources, comments and uploaded documents, for every member. This cannot be undone.`,
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
const T = computed(() => WORDS[props.lang] ?? WORDS.fr)
|
|
37
|
+
|
|
38
|
+
const open = computed({
|
|
39
|
+
get: () => props.modelValue,
|
|
40
|
+
set: (value: boolean) => emit('update:modelValue', value),
|
|
41
|
+
})
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<template>
|
|
45
|
+
<RmModal v-model="open" size="sm" :label="T.heading">
|
|
46
|
+
<template #header>
|
|
47
|
+
<h2 class="rm-project-dialog__title">{{ T.heading }}</h2>
|
|
48
|
+
</template>
|
|
49
|
+
<p class="rm-project-dialog__text">{{ T.text(title) }}</p>
|
|
50
|
+
<p v-if="error" class="rm-project-dialog__error">{{ error }}</p>
|
|
51
|
+
<template #footer>
|
|
52
|
+
<RmButton variant="tertiary" @click="open = false">{{ T.cancel }}</RmButton>
|
|
53
|
+
<RmButton variant="danger" prefix-icon="Trash" :loading="loading" @click="emit('confirm')">{{ T.confirm }}</RmButton>
|
|
54
|
+
</template>
|
|
55
|
+
</RmModal>
|
|
56
|
+
</template>
|
|
57
|
+
|
|
58
|
+
<style scoped>
|
|
59
|
+
.rm-project-dialog__title{font-size:var(--font-18);font-weight:var(--weight-bold);margin:0}.rm-project-dialog__text{font-size:var(--font-14);line-height:1.5;margin:0}.rm-project-dialog__error{color:var(--color-danger);font-size:var(--font-13);margin:8px 0 0}
|
|
60
|
+
</style>
|