@tekkare/romulus 0.1.230 → 0.1.232

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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>