@portaki/module-sections 1.2.7 → 1.3.1
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/package.json +5 -1
- package/portaki.catalog.json +2 -2
- package/portaki.module.json +3 -3
- package/src/components/SectionsGuestView.tsx +3 -7
- package/src/host/SectionCreateModal.tsx +159 -0
- package/src/host/SectionsHostWorkspace.tsx +497 -0
- package/src/host/section-tiptap-presets.ts +156 -0
- package/src/host/sections-editor-design.ts +79 -0
- package/src/host/sections-host.types.ts +53 -0
- package/src/host/sections-toast-copy.ts +9 -0
- package/src/module.test.tsx +19 -4
- package/src/{sections-module-definition.ts → sections-module-definition.tsx} +131 -49
- package/src/test/mock-host-module-context.ts +35 -0
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { HostModuleContext } from '@portaki/sdk'
|
|
4
|
+
import {
|
|
5
|
+
DndContext,
|
|
6
|
+
closestCenter,
|
|
7
|
+
KeyboardSensor,
|
|
8
|
+
PointerSensor,
|
|
9
|
+
useSensor,
|
|
10
|
+
useSensors,
|
|
11
|
+
type DragEndEvent,
|
|
12
|
+
} from '@dnd-kit/core'
|
|
13
|
+
import {
|
|
14
|
+
SortableContext,
|
|
15
|
+
arrayMove,
|
|
16
|
+
sortableKeyboardCoordinates,
|
|
17
|
+
useSortable,
|
|
18
|
+
verticalListSortingStrategy,
|
|
19
|
+
} from '@dnd-kit/sortable'
|
|
20
|
+
import { CSS } from '@dnd-kit/utilities'
|
|
21
|
+
import { FileText, GripVertical, Plus, Trash2 } from 'lucide-react'
|
|
22
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
23
|
+
|
|
24
|
+
import { SectionCreateModal, type SectionCreatePayload } from './SectionCreateModal'
|
|
25
|
+
import { sectionsEditorDesign } from './sections-editor-design'
|
|
26
|
+
import type { SectionsEditorDesign } from './sections-host.types'
|
|
27
|
+
import { buildSectionTipTapPreset } from './section-tiptap-presets'
|
|
28
|
+
import { sectionsToastCopy } from './sections-toast-copy'
|
|
29
|
+
|
|
30
|
+
type SectionRow = {
|
|
31
|
+
id: string;
|
|
32
|
+
sortOrder: number;
|
|
33
|
+
titleFr: string;
|
|
34
|
+
titleEn: string;
|
|
35
|
+
contentFr: unknown;
|
|
36
|
+
contentEn: unknown;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type LangTab = "fr" | "en";
|
|
40
|
+
|
|
41
|
+
const DEFAULT_DESIGN = sectionsEditorDesign
|
|
42
|
+
|
|
43
|
+
function unwrapJsonbField(raw: unknown): unknown {
|
|
44
|
+
if (raw == null) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
if (typeof raw === "string") {
|
|
48
|
+
return parseTipTapDoc(raw);
|
|
49
|
+
}
|
|
50
|
+
if (typeof raw === "object" && !Array.isArray(raw)) {
|
|
51
|
+
const record = raw as Record<string, unknown>;
|
|
52
|
+
if (record.type === "jsonb" && record.value != null) {
|
|
53
|
+
return unwrapJsonbField(record.value);
|
|
54
|
+
}
|
|
55
|
+
if (record.type === "doc") {
|
|
56
|
+
return record;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return raw;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeSectionRow(row: Record<string, unknown>): SectionRow {
|
|
63
|
+
return {
|
|
64
|
+
id: String(row.id ?? ""),
|
|
65
|
+
sortOrder: Number(row.sortOrder ?? row.sort_order ?? 0),
|
|
66
|
+
titleFr: String(row.titleFr ?? row.title_fr ?? ""),
|
|
67
|
+
titleEn: String(row.titleEn ?? row.title_en ?? ""),
|
|
68
|
+
contentFr: unwrapJsonbField(row.contentFr ?? row.content_fr),
|
|
69
|
+
contentEn: unwrapJsonbField(row.contentEn ?? row.content_en),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function tipTapValue(raw: unknown): string {
|
|
74
|
+
const doc = unwrapJsonbField(raw);
|
|
75
|
+
if (doc && typeof doc === "object") {
|
|
76
|
+
return serializeTipTapDoc(doc as Record<string, unknown>);
|
|
77
|
+
}
|
|
78
|
+
return serializeTipTapDoc(parseTipTapDoc(null));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function SortableSectionItem({
|
|
82
|
+
section,
|
|
83
|
+
selected,
|
|
84
|
+
onSelect,
|
|
85
|
+
cn,
|
|
86
|
+
}: {
|
|
87
|
+
section: SectionRow
|
|
88
|
+
selected: boolean
|
|
89
|
+
onSelect: () => void
|
|
90
|
+
cn: HostModuleContext['shell']['cn']
|
|
91
|
+
}) {
|
|
92
|
+
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
93
|
+
id: section.id,
|
|
94
|
+
});
|
|
95
|
+
const style = {
|
|
96
|
+
transform: CSS.Transform.toString(transform),
|
|
97
|
+
transition,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<li ref={setNodeRef} style={style} className={cn(isDragging && "z-10 opacity-90")}>
|
|
102
|
+
<div
|
|
103
|
+
className={cn(
|
|
104
|
+
"flex items-center gap-1 rounded-lg px-1 py-0.5",
|
|
105
|
+
selected ? "bg-[var(--amber)]/12" : "hover:bg-black/[0.03]",
|
|
106
|
+
)}
|
|
107
|
+
>
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
className="touch-none rounded p-1 text-[var(--text-muted)] hover:text-[var(--text)]"
|
|
111
|
+
aria-label="Réordonner"
|
|
112
|
+
{...attributes}
|
|
113
|
+
{...listeners}
|
|
114
|
+
>
|
|
115
|
+
<GripVertical className="size-4" aria-hidden />
|
|
116
|
+
</button>
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
onClick={onSelect}
|
|
120
|
+
className={cn(
|
|
121
|
+
"min-w-0 flex-1 rounded-md px-2 py-2 text-left text-[13px] transition-colors",
|
|
122
|
+
selected ? "font-medium text-[var(--text)]" : "text-[var(--text-muted)] hover:text-[var(--text)]",
|
|
123
|
+
)}
|
|
124
|
+
>
|
|
125
|
+
{section.titleFr || "Sans titre"}
|
|
126
|
+
</button>
|
|
127
|
+
</div>
|
|
128
|
+
</li>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function SectionsHostWorkspace({
|
|
133
|
+
ctx,
|
|
134
|
+
design = DEFAULT_DESIGN,
|
|
135
|
+
}: {
|
|
136
|
+
ctx: HostModuleContext
|
|
137
|
+
design?: SectionsEditorDesign
|
|
138
|
+
}) {
|
|
139
|
+
const { propertyId, gateway, shell } = ctx
|
|
140
|
+
const {
|
|
141
|
+
NotionLikeEditor,
|
|
142
|
+
HostEmptyPlaceholder,
|
|
143
|
+
HostUnderlineTabs,
|
|
144
|
+
Button,
|
|
145
|
+
Input,
|
|
146
|
+
cn,
|
|
147
|
+
toast,
|
|
148
|
+
parseTipTapDoc,
|
|
149
|
+
serializeTipTapDoc,
|
|
150
|
+
} = shell
|
|
151
|
+
const gatewayOps = design.gateway
|
|
152
|
+
|
|
153
|
+
const [sections, setSections] = useState<SectionRow[]>([]);
|
|
154
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
155
|
+
const [lang, setLang] = useState<LangTab>("fr");
|
|
156
|
+
const [loading, setLoading] = useState(true);
|
|
157
|
+
const [saving, setSaving] = useState(false);
|
|
158
|
+
const [creating, setCreating] = useState(false);
|
|
159
|
+
const [createModalOpen, setCreateModalOpen] = useState(false);
|
|
160
|
+
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null);
|
|
161
|
+
|
|
162
|
+
const sensors = useSensors(
|
|
163
|
+
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
|
164
|
+
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const selected = useMemo(
|
|
168
|
+
() => sections.find((s) => s.id === selectedId) ?? null,
|
|
169
|
+
[sections, selectedId],
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const load = useCallback(async () => {
|
|
173
|
+
setLoading(true);
|
|
174
|
+
try {
|
|
175
|
+
const data = await gateway.query<SectionRow[]>(gatewayOps.listQuery)
|
|
176
|
+
const list = data.map((row) => normalizeSectionRow(row as Record<string, unknown>));
|
|
177
|
+
setSections(list);
|
|
178
|
+
setSelectedId((prev) => {
|
|
179
|
+
if (prev && list.some((s) => s.id === prev)) {
|
|
180
|
+
return prev;
|
|
181
|
+
}
|
|
182
|
+
return list[0]?.id ?? null;
|
|
183
|
+
});
|
|
184
|
+
} catch {
|
|
185
|
+
toast.error(sectionsToastCopy.loadError)
|
|
186
|
+
} finally {
|
|
187
|
+
setLoading(false);
|
|
188
|
+
}
|
|
189
|
+
}, [propertyId, gateway, gatewayOps.listQuery, toast])
|
|
190
|
+
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
void load();
|
|
193
|
+
}, [load]);
|
|
194
|
+
|
|
195
|
+
const saveSelected = useCallback(
|
|
196
|
+
async (draft: SectionRow) => {
|
|
197
|
+
setSaving(true);
|
|
198
|
+
try {
|
|
199
|
+
await gateway.command(gatewayOps.saveCommand, {
|
|
200
|
+
id: draft.id,
|
|
201
|
+
titleFr: draft.titleFr,
|
|
202
|
+
titleEn: draft.titleEn || draft.titleFr,
|
|
203
|
+
contentFr: draft.contentFr,
|
|
204
|
+
contentEn: draft.contentEn,
|
|
205
|
+
sortOrder: draft.sortOrder,
|
|
206
|
+
});
|
|
207
|
+
setLastSavedAt(new Date());
|
|
208
|
+
await load();
|
|
209
|
+
} catch {
|
|
210
|
+
toast.error(sectionsToastCopy.saveError)
|
|
211
|
+
} finally {
|
|
212
|
+
setSaving(false);
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
[gateway, gatewayOps.saveCommand, load, toast],
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
219
|
+
|
|
220
|
+
const scheduleSave = useCallback(
|
|
221
|
+
(draft: SectionRow) => {
|
|
222
|
+
if (debounceRef.current) {
|
|
223
|
+
clearTimeout(debounceRef.current);
|
|
224
|
+
}
|
|
225
|
+
debounceRef.current = setTimeout(() => {
|
|
226
|
+
void saveSelected(draft);
|
|
227
|
+
}, 1200);
|
|
228
|
+
},
|
|
229
|
+
[saveSelected],
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const updateDraft = useCallback(
|
|
233
|
+
(patch: Partial<SectionRow>) => {
|
|
234
|
+
if (!selected) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const next = { ...selected, ...patch };
|
|
238
|
+
setSections((prev) => prev.map((s) => (s.id === next.id ? next : s)));
|
|
239
|
+
scheduleSave(next);
|
|
240
|
+
},
|
|
241
|
+
[selected, scheduleSave],
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
const persistOrder = useCallback(
|
|
245
|
+
async (ordered: SectionRow[]) => {
|
|
246
|
+
try {
|
|
247
|
+
await gateway.command(gatewayOps.reorderCommand, {
|
|
248
|
+
orderedIds: ordered.map((s) => s.id),
|
|
249
|
+
});
|
|
250
|
+
} catch {
|
|
251
|
+
toast.error(sectionsToastCopy.reorderError)
|
|
252
|
+
await load();
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
[gateway, gatewayOps.reorderCommand, load, toast]
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
const onDragEnd = useCallback(
|
|
259
|
+
(event: DragEndEvent) => {
|
|
260
|
+
const { active, over } = event;
|
|
261
|
+
if (!over || active.id === over.id) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
setSections((items) => {
|
|
265
|
+
const oldIndex = items.findIndex((s) => s.id === active.id);
|
|
266
|
+
const newIndex = items.findIndex((s) => s.id === over.id);
|
|
267
|
+
const moved = arrayMove(items, oldIndex, newIndex).map((s, index) => ({
|
|
268
|
+
...s,
|
|
269
|
+
sortOrder: index,
|
|
270
|
+
}));
|
|
271
|
+
void persistOrder(moved);
|
|
272
|
+
return moved;
|
|
273
|
+
});
|
|
274
|
+
},
|
|
275
|
+
[persistOrder],
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
const createSection = useCallback(
|
|
279
|
+
async (payload: SectionCreatePayload) => {
|
|
280
|
+
setCreating(true);
|
|
281
|
+
const sortOrder = sections.length;
|
|
282
|
+
const id = crypto.randomUUID();
|
|
283
|
+
const { contentFr, contentEn } = buildSectionTipTapPreset(payload.contentPreset);
|
|
284
|
+
try {
|
|
285
|
+
await gateway.command(gatewayOps.saveCommand, {
|
|
286
|
+
id,
|
|
287
|
+
titleFr: payload.titleFr,
|
|
288
|
+
titleEn: payload.titleEn,
|
|
289
|
+
contentFr,
|
|
290
|
+
contentEn,
|
|
291
|
+
sortOrder,
|
|
292
|
+
});
|
|
293
|
+
setCreateModalOpen(false);
|
|
294
|
+
await load();
|
|
295
|
+
setSelectedId(id);
|
|
296
|
+
toast.success(sectionsToastCopy.createSuccess)
|
|
297
|
+
} catch {
|
|
298
|
+
toast.error(sectionsToastCopy.createError)
|
|
299
|
+
} finally {
|
|
300
|
+
setCreating(false);
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
[gateway, gatewayOps.saveCommand, sections.length, load, toast]
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const deleteSelected = useCallback(async () => {
|
|
307
|
+
if (!selected) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
await gateway.command(gatewayOps.deleteCommand, {
|
|
312
|
+
id: selected.id,
|
|
313
|
+
});
|
|
314
|
+
toast.success(sectionsToastCopy.deleteSuccess)
|
|
315
|
+
await load();
|
|
316
|
+
} catch {
|
|
317
|
+
toast.error(sectionsToastCopy.deleteError)
|
|
318
|
+
}
|
|
319
|
+
}, [gateway, gatewayOps.deleteCommand, selected, load, toast])
|
|
320
|
+
|
|
321
|
+
if (loading) {
|
|
322
|
+
return <p className="text-sm text-[var(--text-muted)]">Chargement des sections…</p>;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (sections.length === 0) {
|
|
326
|
+
return (
|
|
327
|
+
<>
|
|
328
|
+
<HostEmptyPlaceholder
|
|
329
|
+
icon={FileText}
|
|
330
|
+
title={design.emptyState.titleFr}
|
|
331
|
+
description={design.emptyState.descriptionFr}
|
|
332
|
+
primaryAction={{
|
|
333
|
+
label: design.emptyState.primaryActionLabelFr,
|
|
334
|
+
onClick: () => setCreateModalOpen(true),
|
|
335
|
+
}}
|
|
336
|
+
/>
|
|
337
|
+
<SectionCreateModal
|
|
338
|
+
open={createModalOpen}
|
|
339
|
+
onOpenChange={setCreateModalOpen}
|
|
340
|
+
design={design.createModal}
|
|
341
|
+
onCreate={createSection}
|
|
342
|
+
creating={creating}
|
|
343
|
+
shell={shell}
|
|
344
|
+
/>
|
|
345
|
+
</>
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const editorValue =
|
|
350
|
+
selected == null
|
|
351
|
+
? serializeTipTapDoc(parseTipTapDoc(null))
|
|
352
|
+
: lang === "fr"
|
|
353
|
+
? tipTapValue(selected.contentFr)
|
|
354
|
+
: tipTapValue(selected.contentEn);
|
|
355
|
+
|
|
356
|
+
return (
|
|
357
|
+
<div className="flex min-h-[min(70vh,720px)] flex-col gap-4 lg:flex-row">
|
|
358
|
+
<aside className="w-full shrink-0 rounded-xl border border-[var(--border)] bg-white lg:w-72">
|
|
359
|
+
<div className="flex items-center justify-between border-b border-[var(--border)] px-3 py-2.5">
|
|
360
|
+
<span className="text-[13px] font-medium text-[var(--text)]">Sections</span>
|
|
361
|
+
<Button
|
|
362
|
+
type="button"
|
|
363
|
+
variant="secondary"
|
|
364
|
+
className="min-h-8 px-3 text-xs"
|
|
365
|
+
onClick={() => setCreateModalOpen(true)}
|
|
366
|
+
>
|
|
367
|
+
<Plus className="size-4" aria-hidden />
|
|
368
|
+
Nouvelle
|
|
369
|
+
</Button>
|
|
370
|
+
</div>
|
|
371
|
+
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
|
372
|
+
<SortableContext items={sections.map((s) => s.id)} strategy={verticalListSortingStrategy}>
|
|
373
|
+
<ul className="max-h-[min(60vh,560px)] overflow-y-auto p-2">
|
|
374
|
+
{sections.length === 0 ? (
|
|
375
|
+
<li className="px-2 py-8 text-center">
|
|
376
|
+
<p className="text-[13px] text-[var(--text-muted)]">Aucune section pour l'instant.</p>
|
|
377
|
+
<Button
|
|
378
|
+
type="button"
|
|
379
|
+
variant="secondary"
|
|
380
|
+
className="mt-3 min-h-9 px-4 text-xs"
|
|
381
|
+
onClick={() => setCreateModalOpen(true)}
|
|
382
|
+
>
|
|
383
|
+
<Plus className="size-4" aria-hidden />
|
|
384
|
+
Nouvelle section
|
|
385
|
+
</Button>
|
|
386
|
+
</li>
|
|
387
|
+
) : (
|
|
388
|
+
sections.map((s) => (
|
|
389
|
+
<SortableSectionItem
|
|
390
|
+
key={s.id}
|
|
391
|
+
section={s}
|
|
392
|
+
selected={selectedId === s.id}
|
|
393
|
+
onSelect={() => setSelectedId(s.id)}
|
|
394
|
+
cn={cn}
|
|
395
|
+
/>
|
|
396
|
+
))
|
|
397
|
+
)}
|
|
398
|
+
</ul>
|
|
399
|
+
</SortableContext>
|
|
400
|
+
</DndContext>
|
|
401
|
+
</aside>
|
|
402
|
+
|
|
403
|
+
<div className="min-w-0 flex-1 rounded-xl border border-[var(--border)] bg-white">
|
|
404
|
+
{selected ? (
|
|
405
|
+
<>
|
|
406
|
+
<div className="flex flex-col gap-3 border-b border-[var(--border)] px-4 py-3">
|
|
407
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
408
|
+
<Input
|
|
409
|
+
value={selected.titleFr}
|
|
410
|
+
onChange={(e) => updateDraft({ titleFr: e.target.value })}
|
|
411
|
+
placeholder="Titre (FR)"
|
|
412
|
+
className="max-w-xs"
|
|
413
|
+
/>
|
|
414
|
+
<Input
|
|
415
|
+
value={selected.titleEn}
|
|
416
|
+
onChange={(e) => updateDraft({ titleEn: e.target.value })}
|
|
417
|
+
placeholder="Title (EN)"
|
|
418
|
+
className="max-w-xs"
|
|
419
|
+
/>
|
|
420
|
+
<div className="ml-auto flex items-center gap-2">
|
|
421
|
+
<span className="text-[11px] text-[var(--text-muted)]">
|
|
422
|
+
{saving
|
|
423
|
+
? "Enregistrement…"
|
|
424
|
+
: lastSavedAt
|
|
425
|
+
? `Auto-sauvegardé — ${formatRelative(lastSavedAt)}`
|
|
426
|
+
: ""}
|
|
427
|
+
</span>
|
|
428
|
+
<Button
|
|
429
|
+
type="button"
|
|
430
|
+
variant="ghost"
|
|
431
|
+
className="min-h-8 px-3 text-xs text-destructive"
|
|
432
|
+
onClick={() => void deleteSelected()}
|
|
433
|
+
>
|
|
434
|
+
<Trash2 className="size-4" aria-hidden />
|
|
435
|
+
Supprimer
|
|
436
|
+
</Button>
|
|
437
|
+
</div>
|
|
438
|
+
</div>
|
|
439
|
+
<HostUnderlineTabs
|
|
440
|
+
options={[
|
|
441
|
+
{ value: 'fr', label: 'Contenu FR' },
|
|
442
|
+
{ value: 'en', label: 'Content EN' },
|
|
443
|
+
]}
|
|
444
|
+
value={lang}
|
|
445
|
+
onChange={(value) => setLang(value as LangTab)}
|
|
446
|
+
ariaLabel="Langue du contenu"
|
|
447
|
+
/>
|
|
448
|
+
</div>
|
|
449
|
+
<div className="p-4">
|
|
450
|
+
<NotionLikeEditor
|
|
451
|
+
key={`${selected.id}-${lang}`}
|
|
452
|
+
value={editorValue}
|
|
453
|
+
onChange={(json) => {
|
|
454
|
+
try {
|
|
455
|
+
const node = JSON.parse(json) as unknown;
|
|
456
|
+
if (lang === "fr") {
|
|
457
|
+
updateDraft({ contentFr: node });
|
|
458
|
+
} else {
|
|
459
|
+
updateDraft({ contentEn: node });
|
|
460
|
+
}
|
|
461
|
+
} catch {
|
|
462
|
+
/* ignore */
|
|
463
|
+
}
|
|
464
|
+
}}
|
|
465
|
+
/>
|
|
466
|
+
</div>
|
|
467
|
+
</>
|
|
468
|
+
) : (
|
|
469
|
+
<div className="flex min-h-[320px] items-center justify-center p-8 text-[13px] text-[var(--text-muted)]">
|
|
470
|
+
Sélectionnez ou créez une section.
|
|
471
|
+
</div>
|
|
472
|
+
)}
|
|
473
|
+
</div>
|
|
474
|
+
|
|
475
|
+
<SectionCreateModal
|
|
476
|
+
open={createModalOpen}
|
|
477
|
+
onOpenChange={setCreateModalOpen}
|
|
478
|
+
design={design.createModal}
|
|
479
|
+
onCreate={createSection}
|
|
480
|
+
creating={creating}
|
|
481
|
+
shell={shell}
|
|
482
|
+
/>
|
|
483
|
+
</div>
|
|
484
|
+
)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function formatRelative(d: Date): string {
|
|
488
|
+
const sec = Math.round((Date.now() - d.getTime()) / 1000);
|
|
489
|
+
if (sec < 8) {
|
|
490
|
+
return "à l’instant";
|
|
491
|
+
}
|
|
492
|
+
if (sec < 60) {
|
|
493
|
+
return `il y a ${sec} s`;
|
|
494
|
+
}
|
|
495
|
+
const min = Math.round(sec / 60);
|
|
496
|
+
return `il y a ${min} min`;
|
|
497
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { SectionContentPresetId } from "./sections-host.types";
|
|
2
|
+
|
|
3
|
+
export type SectionTipTapPreset = {
|
|
4
|
+
contentFr: Record<string, unknown>;
|
|
5
|
+
contentEn: Record<string, unknown>;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
function textNode(value: string): Record<string, unknown> {
|
|
9
|
+
return { type: "text", text: value };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function paragraph(...parts: string[]): Record<string, unknown> {
|
|
13
|
+
const joined = parts.filter(Boolean).join(" ");
|
|
14
|
+
return {
|
|
15
|
+
type: "paragraph",
|
|
16
|
+
content: joined ? [textNode(joined)] : [],
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function heading(level: 2 | 3, text: string): Record<string, unknown> {
|
|
21
|
+
return {
|
|
22
|
+
type: "heading",
|
|
23
|
+
attrs: { level },
|
|
24
|
+
content: text ? [textNode(text)] : [],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function bulletItem(text: string): Record<string, unknown> {
|
|
29
|
+
return {
|
|
30
|
+
type: "listItem",
|
|
31
|
+
content: [paragraph(text)],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function bulletList(items: string[]): Record<string, unknown> {
|
|
36
|
+
return {
|
|
37
|
+
type: "bulletList",
|
|
38
|
+
content: items.map(bulletItem),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function doc(...blocks: Record<string, unknown>[]): Record<string, unknown> {
|
|
43
|
+
return { type: "doc", content: blocks };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const PRESETS: Record<SectionContentPresetId, SectionTipTapPreset> = {
|
|
47
|
+
welcome: {
|
|
48
|
+
contentFr: doc(
|
|
49
|
+
paragraph(
|
|
50
|
+
"Bienvenue ! Nous sommes ravis de vous accueillir. Voici quelques informations utiles pour profiter pleinement du logement.",
|
|
51
|
+
),
|
|
52
|
+
paragraph(
|
|
53
|
+
"N'hésitez pas à nous écrire ou à nous appeler si vous avez la moindre question pendant votre séjour.",
|
|
54
|
+
),
|
|
55
|
+
),
|
|
56
|
+
contentEn: doc(
|
|
57
|
+
paragraph(
|
|
58
|
+
"Welcome! We're delighted to host you. Here is some useful information to help you make the most of your stay.",
|
|
59
|
+
),
|
|
60
|
+
paragraph("Feel free to message or call us if you have any questions during your visit."),
|
|
61
|
+
),
|
|
62
|
+
},
|
|
63
|
+
beaches: {
|
|
64
|
+
contentFr: doc(
|
|
65
|
+
heading(2, "Plages & criques"),
|
|
66
|
+
paragraph("Nos spots préférés aux alentours :"),
|
|
67
|
+
bulletList([
|
|
68
|
+
"Plage principale — accès facile, idéale en famille",
|
|
69
|
+
"Crique sauvage — 10 min à pied, prévoir des chaussures",
|
|
70
|
+
"Spot snorkeling — eau calme le matin",
|
|
71
|
+
]),
|
|
72
|
+
),
|
|
73
|
+
contentEn: doc(
|
|
74
|
+
heading(2, "Beaches & coves"),
|
|
75
|
+
paragraph("Our favourite spots nearby:"),
|
|
76
|
+
bulletList([
|
|
77
|
+
"Main beach — easy access, great for families",
|
|
78
|
+
"Hidden cove — 10 min walk, bring suitable shoes",
|
|
79
|
+
"Snorkelling spot — calm water in the morning",
|
|
80
|
+
]),
|
|
81
|
+
),
|
|
82
|
+
},
|
|
83
|
+
restaurants: {
|
|
84
|
+
contentFr: doc(
|
|
85
|
+
heading(2, "Restaurants"),
|
|
86
|
+
paragraph("Quelques adresses que nous recommandons, par budget :"),
|
|
87
|
+
bulletList([
|
|
88
|
+
"Bistrot du port — cuisine locale, réservation conseillée",
|
|
89
|
+
"Pizzeria du village — décontracté, terrasse ombragée",
|
|
90
|
+
"Marché du samedi matin — produits frais et spécialités",
|
|
91
|
+
]),
|
|
92
|
+
),
|
|
93
|
+
contentEn: doc(
|
|
94
|
+
heading(2, "Restaurants"),
|
|
95
|
+
paragraph("A few places we recommend, by budget:"),
|
|
96
|
+
bulletList([
|
|
97
|
+
"Harbour bistro — local cuisine, booking recommended",
|
|
98
|
+
"Village pizzeria — casual, shaded terrace",
|
|
99
|
+
"Saturday market — fresh produce and regional treats",
|
|
100
|
+
]),
|
|
101
|
+
),
|
|
102
|
+
},
|
|
103
|
+
transport: {
|
|
104
|
+
contentFr: doc(
|
|
105
|
+
heading(2, "Venir & repartir"),
|
|
106
|
+
paragraph("Pour vous déplacer facilement :"),
|
|
107
|
+
bulletList([
|
|
108
|
+
"Gare la plus proche — liaisons régulières vers les grandes villes",
|
|
109
|
+
"Bus — arrêt à quelques minutes à pied",
|
|
110
|
+
"Parking — place disponible sur la propriété",
|
|
111
|
+
"Taxi / VTC — nous pouvons partager nos contacts",
|
|
112
|
+
]),
|
|
113
|
+
),
|
|
114
|
+
contentEn: doc(
|
|
115
|
+
heading(2, "Getting here & leaving"),
|
|
116
|
+
paragraph("Getting around:"),
|
|
117
|
+
bulletList([
|
|
118
|
+
"Nearest train station — regular connections to major cities",
|
|
119
|
+
"Bus — stop a few minutes' walk away",
|
|
120
|
+
"Parking — space available on the property",
|
|
121
|
+
"Taxi / ride-hailing — we can share our contacts",
|
|
122
|
+
]),
|
|
123
|
+
),
|
|
124
|
+
},
|
|
125
|
+
emergencies: {
|
|
126
|
+
contentFr: doc(
|
|
127
|
+
heading(2, "Urgences"),
|
|
128
|
+
paragraph("En cas d'urgence :"),
|
|
129
|
+
bulletList([
|
|
130
|
+
"112 — numéro d'urgence européen",
|
|
131
|
+
"Médecin / pharmacie de garde — contactez-nous",
|
|
132
|
+
"Pompiers / police — composez le 112",
|
|
133
|
+
]),
|
|
134
|
+
paragraph("Adresse du logement et codes d'accès : à compléter selon votre situation."),
|
|
135
|
+
),
|
|
136
|
+
contentEn: doc(
|
|
137
|
+
heading(2, "Emergencies"),
|
|
138
|
+
paragraph("In an emergency:"),
|
|
139
|
+
bulletList([
|
|
140
|
+
"112 — European emergency number",
|
|
141
|
+
"Doctor / on-call pharmacy — contact us",
|
|
142
|
+
"Fire / police — dial 112",
|
|
143
|
+
]),
|
|
144
|
+
paragraph("Property address and access codes: please complete for your guests."),
|
|
145
|
+
),
|
|
146
|
+
},
|
|
147
|
+
empty: {
|
|
148
|
+
contentFr: doc(paragraph("")),
|
|
149
|
+
contentEn: doc(paragraph("")),
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/** TipTap JSON presets keyed by {@link SectionContentPresetId} (sections editor design). */
|
|
154
|
+
export function buildSectionTipTapPreset(preset: SectionContentPresetId): SectionTipTapPreset {
|
|
155
|
+
return PRESETS[preset] ?? PRESETS.empty;
|
|
156
|
+
}
|