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