@tekkare/romulus 0.1.242 → 0.1.243

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 CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.16.0"
6
6
  },
7
- "version": "0.1.241",
7
+ "version": "0.1.242",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "0.8.4",
10
10
  "unbuild": "2.0.0"
@@ -0,0 +1,157 @@
1
+ <!-- "A new version is available" prompt.
2
+ Nuxt's own check-outdated-build plugin polls /_nuxt/builds/latest.json and
3
+ fires `app:manifest:update` when the deployed build id stops matching the
4
+ one this tab booted with. Nuxt then hard-reloads on the next route change —
5
+ silently, and possibly mid-task. This toast surfaces that instead, so the
6
+ user reloads when it suits them; the built-in reload stays as the fallback. -->
7
+ <template>
8
+ <Transition name="update-toast">
9
+ <div v-if="visible" class="update-toast" role="status" aria-live="polite">
10
+ <span class="update-toast-mark" aria-hidden="true">
11
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
12
+ <path d="M21 12a9 9 0 1 1-2.64-6.36" />
13
+ <path d="M21 3v6h-6" />
14
+ </svg>
15
+ </span>
16
+ <div class="update-toast-body">
17
+ <p class="update-toast-title">
18
+ {{ locale === "en" ? "A new version is available" : "Une nouvelle version est disponible" }}
19
+ </p>
20
+ <p class="update-toast-text">
21
+ {{ locale === "en" ? "Reload to get the latest updates." : "Rechargez la page pour profiter des dernières mises à jour." }}
22
+ </p>
23
+ <div class="update-toast-actions">
24
+ <button type="button" class="update-toast-reload" @click="reload">
25
+ {{ locale === "en" ? "Reload" : "Recharger" }}
26
+ </button>
27
+ <button type="button" class="update-toast-later" @click="visible = false">
28
+ {{ locale === "en" ? "Later" : "Plus tard" }}
29
+ </button>
30
+ </div>
31
+ </div>
32
+ </div>
33
+ </Transition>
34
+ </template>
35
+
36
+ <script setup>
37
+ const nuxtApp = useNuxtApp();
38
+ // Textes bilingues par ternaire, comme les autres panneaux du cadre.
39
+ const { locale } = useI18n();
40
+
41
+ const visible = ref(false);
42
+
43
+ // Only ever fires client-side, so the server render and the hydrating render
44
+ // both agree on `false`.
45
+ const unhook = nuxtApp.hook('app:manifest:update', () => {
46
+ visible.value = true;
47
+ });
48
+ onUnmounted(() => unhook());
49
+
50
+ // force: true — reloadNuxtApp otherwise no-ops when the same path was already
51
+ // reloaded in the last 10s (its sessionStorage guard), which would leave the
52
+ // button dead. The path carries the query so filters survive the reload, and
53
+ // persistState stays false: the point is to boot the new build clean.
54
+ const reload = () =>
55
+ reloadNuxtApp({
56
+ path: window.location.pathname + window.location.search,
57
+ persistState: false,
58
+ force: true,
59
+ });
60
+ </script>
61
+
62
+ <style scoped>
63
+ .update-toast {
64
+ position: fixed;
65
+ right: 20px;
66
+ bottom: 20px;
67
+ z-index: 70;
68
+ display: flex;
69
+ gap: 11px;
70
+ max-width: 340px;
71
+ padding: 14px 16px;
72
+ border-radius: 12px;
73
+ border: 1px solid #dbdef0;
74
+ background: var(--bg-surface);
75
+ box-shadow: 0 10px 30px rgba(30, 41, 59, 0.13);
76
+ }
77
+
78
+ .update-toast-body {
79
+ min-width: 0;
80
+ }
81
+
82
+ .update-toast-title {
83
+ font-weight: 600;
84
+ font-size: 14px;
85
+ color: #1e293b;
86
+ }
87
+
88
+ .update-toast-text {
89
+ margin-top: 2px;
90
+ font-size: 12px;
91
+ color: #878ca6;
92
+ }
93
+
94
+ .update-toast-actions {
95
+ display: flex;
96
+ align-items: center;
97
+ gap: 8px;
98
+ margin-top: 10px;
99
+ }
100
+
101
+ .update-toast-mark {
102
+ flex-shrink: 0;
103
+ display: flex;
104
+ align-items: center;
105
+ justify-content: center;
106
+ width: 28px;
107
+ height: 28px;
108
+ border-radius: 9999px;
109
+ color: var(--primary, #2017ad);
110
+ background: color-mix(in srgb, var(--primary, #2017ad) 9%, transparent);
111
+ }
112
+
113
+ .update-toast-reload,
114
+ .update-toast-later {
115
+ font-size: 13px;
116
+ font-weight: 600;
117
+ border-radius: 7px;
118
+ padding: 6px 12px;
119
+ transition: background-color 0.15s ease, color 0.15s ease;
120
+ }
121
+
122
+ .update-toast-reload {
123
+ color: #ffffff;
124
+ background: var(--primary, #2017ad);
125
+ }
126
+
127
+ .update-toast-reload:hover {
128
+ filter: brightness(0.9);
129
+ }
130
+
131
+ .update-toast-later {
132
+ color: #878ca6;
133
+ }
134
+
135
+ .update-toast-later:hover {
136
+ color: #475569;
137
+ background: var(--bg-surface-3);
138
+ }
139
+
140
+ .update-toast-enter-active,
141
+ .update-toast-leave-active {
142
+ transition: opacity 0.22s ease, transform 0.22s ease;
143
+ }
144
+
145
+ .update-toast-enter-from,
146
+ .update-toast-leave-to {
147
+ opacity: 0;
148
+ transform: translateY(10px);
149
+ }
150
+
151
+ @media (prefers-reduced-motion: reduce) {
152
+ .update-toast-enter-active,
153
+ .update-toast-leave-active {
154
+ transition: none;
155
+ }
156
+ }
157
+ </style>
@@ -29,6 +29,7 @@ import {
29
29
  import OipPanelDocuments from "./panels/OipPanelDocuments.vue";
30
30
  import OipPanelMetadata from "./panels/OipPanelMetadata.vue";
31
31
  import OipPanelNotes from "./panels/OipPanelNotes.vue";
32
+ import OipPanelProjects from "./panels/OipPanelProjects.vue";
32
33
  import OipPanelSources from "./panels/OipPanelSources.vue";
33
34
  import OipPanelSourceTables from "./panels/OipPanelSourceTables.vue";
34
35
  import OipPanelTopics from "./panels/OipPanelTopics.vue";
@@ -42,6 +43,7 @@ const LAYER_PANELS: Partial<Record<OipRailTileKey, Component>> = {
42
43
  Documents: OipPanelDocuments,
43
44
  Metadata: OipPanelMetadata,
44
45
  Notes: OipPanelNotes,
46
+ Projects: OipPanelProjects,
45
47
  Sources: OipPanelSources,
46
48
  SourcesTables: OipPanelSourceTables,
47
49
  Topics: OipPanelTopics,
@@ -0,0 +1,261 @@
1
+ <template>
2
+ <div class="auth-split">
3
+ <!-- Left brand panel (generic OIP Healthcare — reusable across apps) -->
4
+ <aside class="brand-panel">
5
+ <div class="blob b1"></div>
6
+ <div class="blob b2"></div>
7
+ <div class="blob b3"></div>
8
+
9
+ <div class="brand-logo">
10
+ <img src="https://rcd-media.com/skills/Tekkare_Baseline_White.svg" alt="Tekkare" />
11
+ </div>
12
+
13
+ <div class="brand-copy">
14
+ <span class="brand-eyebrow">OIP Healthcare<sup>®</sup></span>
15
+ <h1 v-if="fr">Des données de santé, <em>enfin exploitables</em>.</h1>
16
+ <h1 v-else>Health data, <em>finally usable</em>.</h1>
17
+ <p>{{ fr ? "Une vue à 360 degrés des systèmes de santé dans le monde, construite entièrement sur des données ouvertes officielles." : "A 360 degree view of health systems worldwide, built entirely on official open data." }}</p>
18
+ <div class="brand-points">
19
+ <div class="brand-point"><i class="ph ph-database"></i> {{ fr ? "Données ouvertes officielles, normalisées et prêtes à l'emploi" : "Official open data, normalized and ready to use" }}</div>
20
+ <div class="brand-point"><i class="ph ph-squares-four"></i> {{ fr ? "Des applications spécialisées dans chaque domaine de la santé" : "Specialized apps across every health domain" }}</div>
21
+ <div class="brand-point"><i class="ph ph-shield-check"></i> {{ fr ? "Uniquement des données ouvertes, conformes au RGPD par conception" : "Open data only, GDPR compliant by design" }}</div>
22
+ </div>
23
+ </div>
24
+
25
+ <div class="brand-foot">
26
+ <strong>TEKKARE®</strong><br />
27
+ Inspired by Healthcare
28
+ </div>
29
+ </aside>
30
+
31
+ <!-- Right form panel — host app drops its content here -->
32
+ <main class="form-panel">
33
+ <div class="form-panel-inner">
34
+ <slot />
35
+ </div>
36
+ </main>
37
+ </div>
38
+ </template>
39
+
40
+ <script setup lang="ts">
41
+ const { locale } = useI18n();
42
+ const fr = computed(() => locale.value === "fr");
43
+ </script>
44
+
45
+ <style scoped>
46
+ .auth-split {
47
+ --tkr-primary: var(--primary);
48
+ --tkr-secondary: #b485ff;
49
+ --tkr-darkest: #1e293b;
50
+ --tkr-dark: #525670;
51
+ --tkr-medium: #878ca6;
52
+ --tkr-light: #dbdef0;
53
+ --tkr-lightest: #f5f5f9;
54
+ --tkr-bg: #f9f9fa;
55
+ --tkr-grad: linear-gradient(135deg, #4dccf7 0%, #a06bf5 50%, #ff5cf0 100%);
56
+ --tkr-grad-btn: linear-gradient(135deg, var(--primary), var(--primary-hover));
57
+ --tkr-error: #e53535;
58
+
59
+ display: grid;
60
+ grid-template-columns: 1.05fr 1fr;
61
+ min-height: 100vh;
62
+ color: var(--tkr-darkest);
63
+ font-family: "Figtree", "Avenir", Arial, sans-serif;
64
+ }
65
+
66
+ /* ── Brand panel ── */
67
+ .brand-panel {
68
+ position: relative;
69
+ background: #16115c;
70
+ color: #fff;
71
+ padding: 56px 64px;
72
+ display: flex;
73
+ flex-direction: column;
74
+ justify-content: space-between;
75
+ overflow: hidden;
76
+ }
77
+
78
+ .brand-panel .blob {
79
+ position: absolute;
80
+ z-index: 0;
81
+ border-radius: 50%;
82
+ filter: blur(66px);
83
+ will-change: transform;
84
+ }
85
+
86
+ .brand-panel .blob.b1 {
87
+ width: 420px;
88
+ height: 420px;
89
+ background: #4dccf7;
90
+ top: -120px;
91
+ right: -80px;
92
+ opacity: 0.46;
93
+ animation: brandDrift 30s ease-in-out infinite;
94
+ }
95
+
96
+ .brand-panel .blob.b2 {
97
+ width: 360px;
98
+ height: 360px;
99
+ background: #a06bf5;
100
+ bottom: -100px;
101
+ left: -60px;
102
+ opacity: 0.45;
103
+ animation: brandDrift 34s ease-in-out infinite;
104
+ animation-delay: -11s;
105
+ }
106
+
107
+ .brand-panel .blob.b3 {
108
+ width: 300px;
109
+ height: 300px;
110
+ background: #ff5cf0;
111
+ top: 38%;
112
+ right: 18%;
113
+ opacity: 0.3;
114
+ animation: brandDrift 38s ease-in-out infinite;
115
+ animation-delay: -22s;
116
+ }
117
+
118
+ @keyframes brandDrift {
119
+ 0%,
120
+ 100% {
121
+ transform: translate(0, 0);
122
+ }
123
+ 50% {
124
+ transform: translate(-26px, 32px);
125
+ }
126
+ }
127
+
128
+ .brand-panel > *:not(.blob) {
129
+ position: relative;
130
+ z-index: 1;
131
+ }
132
+
133
+ .brand-logo img {
134
+ height: 34px;
135
+ }
136
+
137
+ .brand-copy {
138
+ max-width: 440px;
139
+ }
140
+
141
+ .brand-eyebrow {
142
+ display: inline-flex;
143
+ align-items: center;
144
+ gap: 8px;
145
+ background: rgba(255, 255, 255, 0.12);
146
+ border: 1px solid rgba(255, 255, 255, 0.22);
147
+ padding: 6px 14px;
148
+ border-radius: 999px;
149
+ font-size: 12px;
150
+ font-weight: 600;
151
+ letter-spacing: 0.04em;
152
+ margin-bottom: 26px;
153
+ backdrop-filter: blur(4px);
154
+ }
155
+
156
+ .brand-eyebrow sup {
157
+ font-size: 0.7em;
158
+ vertical-align: super;
159
+ line-height: 0;
160
+ margin-left: 1px;
161
+ }
162
+
163
+ .brand-copy h1 {
164
+ font-weight: 600;
165
+ font-size: 40px;
166
+ line-height: 1.16;
167
+ margin: 0 0 18px;
168
+ }
169
+
170
+ .brand-copy h1 em {
171
+ font-family: "Caudex", Georgia, serif;
172
+ font-weight: 700;
173
+ font-style: normal;
174
+ background: var(--tkr-grad);
175
+ -webkit-background-clip: text;
176
+ background-clip: text;
177
+ color: transparent;
178
+ }
179
+
180
+ .brand-copy p {
181
+ font-size: 15px;
182
+ line-height: 1.6;
183
+ color: rgba(255, 255, 255, 0.85);
184
+ margin: 0;
185
+ }
186
+
187
+ .brand-points {
188
+ display: flex;
189
+ flex-direction: column;
190
+ gap: 13px;
191
+ margin-top: 32px;
192
+ }
193
+
194
+ .brand-point {
195
+ display: flex;
196
+ align-items: center;
197
+ gap: 12px;
198
+ font-size: 14px;
199
+ color: rgba(255, 255, 255, 0.92);
200
+ }
201
+
202
+ .brand-point i {
203
+ font-size: 18px;
204
+ color: #fff;
205
+ background: rgba(255, 255, 255, 0.12);
206
+ border-radius: 8px;
207
+ width: 34px;
208
+ height: 34px;
209
+ display: inline-flex;
210
+ align-items: center;
211
+ justify-content: center;
212
+ flex-shrink: 0;
213
+ }
214
+
215
+ .brand-foot {
216
+ font-size: 12px;
217
+ color: rgba(255, 255, 255, 0.7);
218
+ line-height: 1.6;
219
+ }
220
+
221
+ /* ── Form panel (host content via slot) ── */
222
+ .form-panel {
223
+ display: flex;
224
+ padding: 48px 32px;
225
+ background: var(--tkr-bg);
226
+ overflow-y: auto;
227
+ }
228
+
229
+ .form-panel-inner {
230
+ margin: auto;
231
+ width: 100%;
232
+ display: flex;
233
+ justify-content: center;
234
+ }
235
+
236
+ .brand-panel *:focus-visible {
237
+ outline: 2px solid var(--tkr-secondary);
238
+ outline-offset: 2px;
239
+ }
240
+
241
+ /* ── Responsive ── */
242
+ @media (max-width: 880px) {
243
+ .auth-split {
244
+ grid-template-columns: 1fr;
245
+ }
246
+
247
+ .brand-panel {
248
+ display: none;
249
+ }
250
+
251
+ .form-panel {
252
+ padding: 40px 24px;
253
+ }
254
+ }
255
+
256
+ @media (prefers-reduced-motion: reduce) {
257
+ .brand-panel .blob {
258
+ animation: none;
259
+ }
260
+ }
261
+ </style>
@@ -0,0 +1,165 @@
1
+ <template>
2
+ <aside v-if="visible && video?.src" class="first-visit-video" aria-label="Tutoriel vidéo">
3
+ <div class="first-visit-video__head">
4
+ <div class="first-visit-video__heading">
5
+ <RmIcon name="PlayCircle" :size="18" />
6
+ <strong>{{ lang === "en" ? "Getting started" : "Bien démarrer" }}</strong>
7
+ </div>
8
+ <button
9
+ type="button"
10
+ class="first-visit-video__close"
11
+ :aria-label="lang === 'en' ? 'Close' : 'Fermer'"
12
+ @click="dismiss"
13
+ >
14
+ <RmIcon name="X" :size="16" />
15
+ </button>
16
+ </div>
17
+
18
+ <video
19
+ class="first-visit-video__player"
20
+ :src="video.src"
21
+ autoplay
22
+ controls
23
+ muted
24
+ playsinline
25
+ preload="metadata"
26
+ ></video>
27
+
28
+ <div class="first-visit-video__footer">
29
+ <span>{{ video.title }}</span>
30
+ <button type="button" class="first-visit-video__dismiss" @click="dismiss">
31
+ {{ lang === "en" ? "Got it" : "J’ai compris" }}
32
+ </button>
33
+ </div>
34
+ </aside>
35
+ </template>
36
+
37
+ <script setup lang="ts">
38
+ interface TutorialVideo {
39
+ id: number;
40
+ title: string;
41
+ description: string;
42
+ src: string;
43
+ }
44
+
45
+ const props = defineProps<{
46
+ video: TutorialVideo | null;
47
+ lang: "fr" | "en";
48
+ }>();
49
+
50
+ const STORAGE_KEY = "oip-first-visit-video-seen";
51
+ const visible = ref(false);
52
+
53
+ function hasSeenVideo() {
54
+ try {
55
+ return localStorage.getItem(STORAGE_KEY) === "1";
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ function dismiss() {
62
+ visible.value = false;
63
+ try {
64
+ localStorage.setItem(STORAGE_KEY, "1");
65
+ } catch {
66
+ // Le navigateur peut refuser le stockage, la fenêtre reste fermée pour la session.
67
+ }
68
+ }
69
+
70
+ watch(
71
+ () => props.video?.src,
72
+ (src) => {
73
+ if (src && !hasSeenVideo()) visible.value = true;
74
+ },
75
+ { immediate: true },
76
+ );
77
+ </script>
78
+
79
+ <style scoped>
80
+ .first-visit-video {
81
+ position: fixed;
82
+ right: 24px;
83
+ bottom: 24px;
84
+ z-index: 1100;
85
+ width: min(460px, calc(100vw - 32px));
86
+ overflow: hidden;
87
+ border: 1px solid var(--border-default);
88
+ border-radius: var(--radius-xl);
89
+ background: var(--bg-surface);
90
+ box-shadow: 0 18px 48px rgb(15 23 42 / 24%);
91
+ }
92
+
93
+ .first-visit-video__head,
94
+ .first-visit-video__footer {
95
+ align-items: center;
96
+ display: flex;
97
+ gap: var(--space-8);
98
+ justify-content: space-between;
99
+ padding: var(--space-12) var(--space-16);
100
+ }
101
+
102
+ .first-visit-video__head {
103
+ color: var(--color-secondary);
104
+ }
105
+
106
+ .first-visit-video__heading {
107
+ align-items: center;
108
+ display: flex;
109
+ gap: var(--space-8);
110
+ }
111
+
112
+ .first-visit-video__close {
113
+ align-items: center;
114
+ background: transparent;
115
+ border: 0;
116
+ color: var(--text-muted);
117
+ cursor: pointer;
118
+ display: flex;
119
+ justify-content: center;
120
+ padding: var(--space-4);
121
+ }
122
+
123
+ .first-visit-video__close:hover {
124
+ color: var(--text-primary);
125
+ }
126
+
127
+ .first-visit-video__player {
128
+ background: var(--bg-surface-3);
129
+ display: block;
130
+ max-height: 290px;
131
+ width: 100%;
132
+ }
133
+
134
+ .first-visit-video__footer {
135
+ color: var(--text-secondary);
136
+ font-size: var(--font-12);
137
+ }
138
+
139
+ .first-visit-video__footer > span {
140
+ overflow: hidden;
141
+ text-overflow: ellipsis;
142
+ white-space: nowrap;
143
+ }
144
+
145
+ .first-visit-video__dismiss {
146
+ background: transparent;
147
+ border: 0;
148
+ color: var(--color-secondary);
149
+ cursor: pointer;
150
+ flex-shrink: 0;
151
+ font: inherit;
152
+ font-weight: var(--weight-semibold);
153
+ }
154
+
155
+ .first-visit-video__dismiss:hover {
156
+ color: var(--color-primary);
157
+ }
158
+
159
+ @media (max-width: 600px) {
160
+ .first-visit-video {
161
+ bottom: 16px;
162
+ right: 16px;
163
+ }
164
+ }
165
+ </style>
@@ -0,0 +1,94 @@
1
+ <!--
2
+ Panneau « Projets » du rail, servi par le layer OIP (tuile Projects) :
3
+ RmProjectPanel.
4
+
5
+ Ou partent mes ajouts : le projet actif s'y designe, et un projet cree ici
6
+ devient l'actif sans quitter la page. Renommer et supprimer restent sur la
7
+ page du projet.
8
+
9
+ Les projets vivent dans la Virtual Lab API, que chaque app interroge avec
10
+ son propre client : l'app passe la liste et les gestes par
11
+ `panelProps.Projects` (cf. les props ci-dessous), et son bouton de creation
12
+ assistee par `newButton` (un composant qui emet `free` pour ouvrir le
13
+ formulaire simple).
14
+ -->
15
+ <template>
16
+ <RmProjectPanel
17
+ :projects="projects"
18
+ :active-id="activeId"
19
+ :active-count="activeCount"
20
+ :loading="loading"
21
+ :failed="failed"
22
+ :creating="creating"
23
+ :all-to="allTo"
24
+ :lang="locale === 'en' ? 'en' : 'fr'"
25
+ @activate="emit('activate', $event)"
26
+ @open="emit('open', $event)"
27
+ @pin="emit('pin', $event)"
28
+ @create="onCreate"
29
+ >
30
+ <template v-if="newButton" #new="{ openForm }">
31
+ <component :is="newButton" size="sm" @free="openForm" />
32
+ </template>
33
+ </RmProjectPanel>
34
+ </template>
35
+
36
+ <script setup lang="ts">
37
+ import type { Component } from "vue";
38
+ import type { RmProject } from "@tekkare/romulus/utils/projects";
39
+
40
+ const props = withDefaults(
41
+ defineProps<{
42
+ appKey?: string;
43
+ superAdmin?: boolean;
44
+ projects?: RmProject[];
45
+ activeId?: string | null;
46
+ /** Elements du projet actif. */
47
+ activeCount?: number;
48
+ loading?: boolean;
49
+ failed?: boolean;
50
+ /** Lien « Tous mes projets ». Vide : pas de lien. */
51
+ allTo?: string;
52
+ /**
53
+ * Cree un projet a partir d'un nom ; le panneau attend la fin pour
54
+ * rendre la main. L'app decide si le projet cree devient l'actif.
55
+ */
56
+ create?: (title: string) => Promise<unknown>;
57
+ /** Bouton de creation de l'app (assistant), qui emet `free`. */
58
+ newButton?: Component | null;
59
+ }>(),
60
+ {
61
+ projects: () => [],
62
+ activeId: null,
63
+ activeCount: 0,
64
+ loading: false,
65
+ failed: false,
66
+ allTo: "",
67
+ create: undefined,
68
+ newButton: null,
69
+ },
70
+ );
71
+
72
+ const emit = defineEmits<{
73
+ activate: [projectId: string];
74
+ open: [projectId: string];
75
+ pin: [projectId: string];
76
+ /** Au montage : l'app restaure le projet actif et charge la liste. */
77
+ mounted: [];
78
+ }>();
79
+
80
+ const { locale } = useI18n();
81
+ const creating = ref(false);
82
+
83
+ async function onCreate(title: string) {
84
+ if (!props.create) return;
85
+ creating.value = true;
86
+ try {
87
+ await props.create(title);
88
+ } finally {
89
+ creating.value = false;
90
+ }
91
+ }
92
+
93
+ onMounted(() => emit("mounted"));
94
+ </script>
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Mode modification du panneau « Mes vues », demande depuis la pastille de vue.
3
+ *
4
+ * `null` : le panneau montre ses listes. `{ id }` : il montre le formulaire de
5
+ * la vue `id` ; `{ id: null }` en fait une creation (vue temporaire, ou recue
6
+ * d'un collegue et donc en lecture seule). La pastille et le panneau ne se
7
+ * connaissent pas, l'etat passe par ici.
8
+ */
9
+ export function useViewEditing() {
10
+ return useState<{ id: string | null } | null>("oip-view-editing", () => null);
11
+ }