@portaki/module-sections 1.3.0 → 1.4.0
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 +2 -2
- package/src/host/SectionCreateModal.tsx +159 -0
- package/src/host/SectionsHostWorkspace.tsx +501 -0
- package/src/host/sections-host.types.ts +62 -0
- package/src/module.test.tsx +19 -4
- package/src/{sections-module-definition.ts → sections-module-definition.tsx} +4 -0
- package/src/test/mock-host-module-context.ts +79 -0
package/package.json
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@portaki/module-sections",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Portaki module — editorial sections (TipTap)",
|
|
5
5
|
"license": "AGPL-3.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./src/index.tsx",
|
|
8
8
|
"dependencies": {
|
|
9
|
+
"@dnd-kit/core": "^6.3.1",
|
|
10
|
+
"@dnd-kit/sortable": "^10.0.0",
|
|
11
|
+
"@dnd-kit/utilities": "^3.2.2",
|
|
9
12
|
"@portaki/sdk": "^3.0.0",
|
|
13
|
+
"lucide-react": "^1.14.0",
|
|
10
14
|
"@tiptap/core": "^3.0.0",
|
|
11
15
|
"@tiptap/html": "^3.0.0",
|
|
12
16
|
"@tiptap/starter-kit": "^3.0.0",
|
package/portaki.catalog.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"fr": "Blocs de contenu riches (TipTap) pour le carnet d’accueil invité.",
|
|
10
10
|
"en": "Rich content blocks (TipTap) for the guest welcome book."
|
|
11
11
|
},
|
|
12
|
-
"version": "1.
|
|
12
|
+
"version": "1.4.0",
|
|
13
13
|
"releaseNotesUrl": "https://github.com/PortakiApp/portaki-modules/releases",
|
|
14
14
|
"changelog": [
|
|
15
15
|
{
|
|
@@ -122,6 +122,6 @@
|
|
|
122
122
|
},
|
|
123
123
|
"artifacts": {
|
|
124
124
|
"wasmUrl": "oci://ghcr.io/portakiapp/portaki-module-sections",
|
|
125
|
-
"guestEsmUrl": "https://esm.sh/@portaki/module-sections@1.
|
|
125
|
+
"guestEsmUrl": "https://esm.sh/@portaki/module-sections@1.4.0"
|
|
126
126
|
}
|
|
127
127
|
}
|
package/portaki.module.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"fr": "Blocs de contenu riches (TipTap) pour le carnet d’accueil invité.",
|
|
10
10
|
"en": "Rich content blocks (TipTap) for the guest welcome book."
|
|
11
11
|
},
|
|
12
|
-
"version": "1.
|
|
12
|
+
"version": "1.4.0",
|
|
13
13
|
"releaseNotesUrl": "https://github.com/PortakiApp/portaki-modules/releases",
|
|
14
14
|
"changelog": [
|
|
15
15
|
{
|
|
@@ -122,6 +122,6 @@
|
|
|
122
122
|
},
|
|
123
123
|
"artifacts": {
|
|
124
124
|
"wasmUrl": "oci://ghcr.io/portakiapp/portaki-module-sections",
|
|
125
|
-
"guestEsmUrl": "https://esm.sh/@portaki/module-sections@1.
|
|
125
|
+
"guestEsmUrl": "https://esm.sh/@portaki/module-sections@1.4.0"
|
|
126
126
|
}
|
|
127
127
|
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { HostModuleContext } from '@portaki/sdk'
|
|
4
|
+
import { useEffect, useState } from 'react'
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
SectionContentPresetId,
|
|
8
|
+
SectionCreateModalDesign,
|
|
9
|
+
SectionTemplateDesign,
|
|
10
|
+
} from './sections-host.types'
|
|
11
|
+
|
|
12
|
+
export type SectionCreatePayload = {
|
|
13
|
+
titleFr: string
|
|
14
|
+
titleEn: string
|
|
15
|
+
contentPreset: SectionContentPresetId
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type Props = {
|
|
19
|
+
open: boolean
|
|
20
|
+
onOpenChange: (open: boolean) => void
|
|
21
|
+
design: SectionCreateModalDesign
|
|
22
|
+
onCreate: (payload: SectionCreatePayload) => Promise<void>
|
|
23
|
+
creating?: boolean
|
|
24
|
+
shell: HostModuleContext['shell']
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function SectionCreateModal({
|
|
28
|
+
open,
|
|
29
|
+
onOpenChange,
|
|
30
|
+
design,
|
|
31
|
+
onCreate,
|
|
32
|
+
creating = false,
|
|
33
|
+
shell,
|
|
34
|
+
}: Props) {
|
|
35
|
+
const {
|
|
36
|
+
Dialog,
|
|
37
|
+
DialogContent,
|
|
38
|
+
DialogDescription,
|
|
39
|
+
DialogFooter,
|
|
40
|
+
DialogHeader,
|
|
41
|
+
DialogTitle,
|
|
42
|
+
Button,
|
|
43
|
+
Input,
|
|
44
|
+
cn,
|
|
45
|
+
} = shell
|
|
46
|
+
|
|
47
|
+
const defaultTemplate =
|
|
48
|
+
design.templates.find((t) => t.id === design.defaultTemplateId) ?? design.templates[0]
|
|
49
|
+
|
|
50
|
+
const [selectedId, setSelectedId] = useState(defaultTemplate?.id ?? '')
|
|
51
|
+
const [titleFr, setTitleFr] = useState(defaultTemplate?.titleFr ?? '')
|
|
52
|
+
|
|
53
|
+
const selected = design.templates.find((t) => t.id === selectedId) ?? defaultTemplate
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!open || !defaultTemplate) {
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
setSelectedId(defaultTemplate.id)
|
|
60
|
+
setTitleFr(defaultTemplate.titleFr)
|
|
61
|
+
}, [open, defaultTemplate])
|
|
62
|
+
|
|
63
|
+
function selectTemplate(template: SectionTemplateDesign) {
|
|
64
|
+
setSelectedId(template.id)
|
|
65
|
+
setTitleFr(template.titleFr)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function handleSubmit() {
|
|
69
|
+
if (!selected || !titleFr.trim()) {
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
await onCreate({
|
|
73
|
+
titleFr: titleFr.trim(),
|
|
74
|
+
titleEn: selected.titleEn,
|
|
75
|
+
contentPreset: selected.contentPreset,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
81
|
+
<DialogContent className="flex max-h-[min(92vh,calc(100%-80px))] max-w-[min(96vw,920px)] flex-col gap-0 overflow-hidden p-0">
|
|
82
|
+
<DialogHeader className="shrink-0 px-[26px] pb-4 pt-6">
|
|
83
|
+
<DialogTitle className="font-heading text-[22px] font-semibold tracking-tight">
|
|
84
|
+
{design.titleFr}
|
|
85
|
+
</DialogTitle>
|
|
86
|
+
<DialogDescription className="text-[13px] leading-relaxed text-[var(--text-muted)]">
|
|
87
|
+
{design.subtitleFr}
|
|
88
|
+
</DialogDescription>
|
|
89
|
+
</DialogHeader>
|
|
90
|
+
|
|
91
|
+
<div className="min-h-0 flex-1 overflow-y-auto px-[26px] pb-4">
|
|
92
|
+
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
|
|
93
|
+
{design.templates.map((template) => {
|
|
94
|
+
const active = template.id === selectedId
|
|
95
|
+
return (
|
|
96
|
+
<button
|
|
97
|
+
key={template.id}
|
|
98
|
+
type="button"
|
|
99
|
+
onClick={() => selectTemplate(template)}
|
|
100
|
+
className={cn(
|
|
101
|
+
'rounded-xl border px-3 py-3 text-left transition-colors',
|
|
102
|
+
active
|
|
103
|
+
? 'border-[var(--amber)] bg-[var(--color-amber-soft)] shadow-[inset_0_0_0_1px_rgba(245,158,11,0.35)]'
|
|
104
|
+
: 'border-[var(--border)] bg-white hover:border-[var(--border-md)] hover:bg-[var(--bg-muted)]/40',
|
|
105
|
+
)}
|
|
106
|
+
>
|
|
107
|
+
<span className="text-[22px] leading-none" aria-hidden>
|
|
108
|
+
{template.icon}
|
|
109
|
+
</span>
|
|
110
|
+
<p className="mt-2 font-heading text-[14px] font-semibold leading-snug text-[var(--text)]">
|
|
111
|
+
{template.titleFr}
|
|
112
|
+
</p>
|
|
113
|
+
<p className="mt-1 text-[12px] leading-snug text-[var(--text-muted)]">
|
|
114
|
+
{template.descriptionFr}
|
|
115
|
+
</p>
|
|
116
|
+
</button>
|
|
117
|
+
)
|
|
118
|
+
})}
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<div className="mt-5">
|
|
122
|
+
<label className="mb-1.5 block text-[13px] font-medium text-[var(--text)]">
|
|
123
|
+
{design.titleFieldLabelFr}
|
|
124
|
+
</label>
|
|
125
|
+
<Input
|
|
126
|
+
value={titleFr}
|
|
127
|
+
onChange={(e) => setTitleFr(e.target.value)}
|
|
128
|
+
placeholder={selected?.titleFr}
|
|
129
|
+
className="h-11"
|
|
130
|
+
/>
|
|
131
|
+
<p className="mt-1.5 text-[12px] text-[var(--text-muted)]">{design.titleFieldHintFr}</p>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<p className="mt-4 text-[12px] text-[var(--text-muted)]">{design.footerHintFr}</p>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<DialogFooter className="shrink-0 flex-row items-center justify-end gap-2 border-t border-[var(--border)] bg-[var(--bg-surface)] px-[26px] py-4">
|
|
138
|
+
<Button
|
|
139
|
+
type="button"
|
|
140
|
+
variant="ghost"
|
|
141
|
+
className="text-[var(--text-muted)]"
|
|
142
|
+
disabled={creating}
|
|
143
|
+
onClick={() => onOpenChange(false)}
|
|
144
|
+
>
|
|
145
|
+
{design.cancelLabelFr}
|
|
146
|
+
</Button>
|
|
147
|
+
<Button
|
|
148
|
+
type="button"
|
|
149
|
+
className="bg-[var(--amber)] font-semibold text-[var(--text)] hover:bg-[var(--amber-solid-hover)]"
|
|
150
|
+
disabled={creating || !titleFr.trim()}
|
|
151
|
+
onClick={() => void handleSubmit()}
|
|
152
|
+
>
|
|
153
|
+
{creating ? 'Création…' : design.submitLabelFr}
|
|
154
|
+
</Button>
|
|
155
|
+
</DialogFooter>
|
|
156
|
+
</DialogContent>
|
|
157
|
+
</Dialog>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
@@ -0,0 +1,501 @@
|
|
|
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 type { SectionsEditorDesign } from './sections-host.types'
|
|
26
|
+
|
|
27
|
+
type SectionRow = {
|
|
28
|
+
id: string;
|
|
29
|
+
sortOrder: number;
|
|
30
|
+
titleFr: string;
|
|
31
|
+
titleEn: string;
|
|
32
|
+
contentFr: unknown;
|
|
33
|
+
contentEn: unknown;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type LangTab = "fr" | "en";
|
|
37
|
+
|
|
38
|
+
function unwrapJsonbField(
|
|
39
|
+
raw: unknown,
|
|
40
|
+
parseTipTapDoc: HostModuleContext['shell']['parseTipTapDoc'],
|
|
41
|
+
): unknown {
|
|
42
|
+
if (raw == null) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
if (typeof raw === "string") {
|
|
46
|
+
return parseTipTapDoc(raw);
|
|
47
|
+
}
|
|
48
|
+
if (typeof raw === "object" && !Array.isArray(raw)) {
|
|
49
|
+
const record = raw as Record<string, unknown>;
|
|
50
|
+
if (record.type === "jsonb" && record.value != null) {
|
|
51
|
+
return unwrapJsonbField(record.value, parseTipTapDoc);
|
|
52
|
+
}
|
|
53
|
+
if (record.type === "doc") {
|
|
54
|
+
return record;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return raw;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeSectionRow(
|
|
61
|
+
row: Record<string, unknown>,
|
|
62
|
+
parseTipTapDoc: HostModuleContext['shell']['parseTipTapDoc'],
|
|
63
|
+
): SectionRow {
|
|
64
|
+
return {
|
|
65
|
+
id: String(row.id ?? ""),
|
|
66
|
+
sortOrder: Number(row.sortOrder ?? row.sort_order ?? 0),
|
|
67
|
+
titleFr: String(row.titleFr ?? row.title_fr ?? ""),
|
|
68
|
+
titleEn: String(row.titleEn ?? row.title_en ?? ""),
|
|
69
|
+
contentFr: unwrapJsonbField(row.contentFr ?? row.content_fr, parseTipTapDoc),
|
|
70
|
+
contentEn: unwrapJsonbField(row.contentEn ?? row.content_en, parseTipTapDoc),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function tipTapValue(
|
|
75
|
+
raw: unknown,
|
|
76
|
+
shell: HostModuleContext['shell'],
|
|
77
|
+
): string {
|
|
78
|
+
const doc = unwrapJsonbField(raw, shell.parseTipTapDoc);
|
|
79
|
+
if (doc && typeof doc === "object") {
|
|
80
|
+
return shell.serializeTipTapDoc(doc as Record<string, unknown>);
|
|
81
|
+
}
|
|
82
|
+
return shell.serializeTipTapDoc(shell.parseTipTapDoc(null));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function SortableSectionItem({
|
|
86
|
+
section,
|
|
87
|
+
selected,
|
|
88
|
+
onSelect,
|
|
89
|
+
cn,
|
|
90
|
+
}: {
|
|
91
|
+
section: SectionRow
|
|
92
|
+
selected: boolean
|
|
93
|
+
onSelect: () => void
|
|
94
|
+
cn: HostModuleContext['shell']['cn']
|
|
95
|
+
}) {
|
|
96
|
+
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
97
|
+
id: section.id,
|
|
98
|
+
});
|
|
99
|
+
const style = {
|
|
100
|
+
transform: CSS.Transform.toString(transform),
|
|
101
|
+
transition,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<li ref={setNodeRef} style={style} className={cn(isDragging && "z-10 opacity-90")}>
|
|
106
|
+
<div
|
|
107
|
+
className={cn(
|
|
108
|
+
"flex items-center gap-1 rounded-lg px-1 py-0.5",
|
|
109
|
+
selected ? "bg-[var(--amber)]/12" : "hover:bg-black/[0.03]",
|
|
110
|
+
)}
|
|
111
|
+
>
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
className="touch-none rounded p-1 text-[var(--text-muted)] hover:text-[var(--text)]"
|
|
115
|
+
aria-label="Réordonner"
|
|
116
|
+
{...attributes}
|
|
117
|
+
{...listeners}
|
|
118
|
+
>
|
|
119
|
+
<GripVertical className="size-4" aria-hidden />
|
|
120
|
+
</button>
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
onClick={onSelect}
|
|
124
|
+
className={cn(
|
|
125
|
+
"min-w-0 flex-1 rounded-md px-2 py-2 text-left text-[13px] transition-colors",
|
|
126
|
+
selected ? "font-medium text-[var(--text)]" : "text-[var(--text-muted)] hover:text-[var(--text)]",
|
|
127
|
+
)}
|
|
128
|
+
>
|
|
129
|
+
{section.titleFr || "Sans titre"}
|
|
130
|
+
</button>
|
|
131
|
+
</div>
|
|
132
|
+
</li>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function SectionsHostWorkspace({ ctx }: { ctx: HostModuleContext }) {
|
|
137
|
+
const design = ctx.design as SectionsEditorDesign
|
|
138
|
+
const { propertyId, gateway, shell } = ctx
|
|
139
|
+
const toasts = design.toasts
|
|
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>, parseTipTapDoc));
|
|
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(toasts.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(toasts.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(toasts.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 buildPreset = shell.buildSectionTipTapPreset
|
|
284
|
+
if (buildPreset == null) {
|
|
285
|
+
throw new Error('host_shell_missing_buildSectionTipTapPreset')
|
|
286
|
+
}
|
|
287
|
+
const { contentFr, contentEn } = buildPreset(payload.contentPreset)
|
|
288
|
+
try {
|
|
289
|
+
await gateway.command(gatewayOps.saveCommand, {
|
|
290
|
+
id,
|
|
291
|
+
titleFr: payload.titleFr,
|
|
292
|
+
titleEn: payload.titleEn,
|
|
293
|
+
contentFr,
|
|
294
|
+
contentEn,
|
|
295
|
+
sortOrder,
|
|
296
|
+
});
|
|
297
|
+
setCreateModalOpen(false);
|
|
298
|
+
await load();
|
|
299
|
+
setSelectedId(id);
|
|
300
|
+
toast.success(toasts.createSuccess)
|
|
301
|
+
} catch {
|
|
302
|
+
toast.error(toasts.createError)
|
|
303
|
+
} finally {
|
|
304
|
+
setCreating(false);
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
[gateway, gatewayOps.saveCommand, sections.length, load, toast]
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
const deleteSelected = useCallback(async () => {
|
|
311
|
+
if (!selected) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
try {
|
|
315
|
+
await gateway.command(gatewayOps.deleteCommand, {
|
|
316
|
+
id: selected.id,
|
|
317
|
+
});
|
|
318
|
+
toast.success(toasts.deleteSuccess)
|
|
319
|
+
await load();
|
|
320
|
+
} catch {
|
|
321
|
+
toast.error(toasts.deleteError)
|
|
322
|
+
}
|
|
323
|
+
}, [gateway, gatewayOps.deleteCommand, selected, load, toast])
|
|
324
|
+
|
|
325
|
+
if (loading) {
|
|
326
|
+
return <p className="text-sm text-[var(--text-muted)]">Chargement des sections…</p>;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (sections.length === 0) {
|
|
330
|
+
return (
|
|
331
|
+
<>
|
|
332
|
+
<HostEmptyPlaceholder
|
|
333
|
+
icon={FileText}
|
|
334
|
+
title={design.emptyState.titleFr}
|
|
335
|
+
description={design.emptyState.descriptionFr}
|
|
336
|
+
primaryAction={{
|
|
337
|
+
label: design.emptyState.primaryActionLabelFr,
|
|
338
|
+
onClick: () => setCreateModalOpen(true),
|
|
339
|
+
}}
|
|
340
|
+
/>
|
|
341
|
+
<SectionCreateModal
|
|
342
|
+
open={createModalOpen}
|
|
343
|
+
onOpenChange={setCreateModalOpen}
|
|
344
|
+
design={design.createModal}
|
|
345
|
+
onCreate={createSection}
|
|
346
|
+
creating={creating}
|
|
347
|
+
shell={shell}
|
|
348
|
+
/>
|
|
349
|
+
</>
|
|
350
|
+
)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const editorValue =
|
|
354
|
+
selected == null
|
|
355
|
+
? serializeTipTapDoc(parseTipTapDoc(null))
|
|
356
|
+
: lang === "fr"
|
|
357
|
+
? tipTapValue(selected.contentFr, shell)
|
|
358
|
+
: tipTapValue(selected.contentEn, shell);
|
|
359
|
+
|
|
360
|
+
return (
|
|
361
|
+
<div className="flex min-h-[min(70vh,720px)] flex-col gap-4 lg:flex-row">
|
|
362
|
+
<aside className="w-full shrink-0 rounded-xl border border-[var(--border)] bg-white lg:w-72">
|
|
363
|
+
<div className="flex items-center justify-between border-b border-[var(--border)] px-3 py-2.5">
|
|
364
|
+
<span className="text-[13px] font-medium text-[var(--text)]">Sections</span>
|
|
365
|
+
<Button
|
|
366
|
+
type="button"
|
|
367
|
+
variant="secondary"
|
|
368
|
+
className="min-h-8 px-3 text-xs"
|
|
369
|
+
onClick={() => setCreateModalOpen(true)}
|
|
370
|
+
>
|
|
371
|
+
<Plus className="size-4" aria-hidden />
|
|
372
|
+
Nouvelle
|
|
373
|
+
</Button>
|
|
374
|
+
</div>
|
|
375
|
+
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
|
376
|
+
<SortableContext items={sections.map((s) => s.id)} strategy={verticalListSortingStrategy}>
|
|
377
|
+
<ul className="max-h-[min(60vh,560px)] overflow-y-auto p-2">
|
|
378
|
+
{sections.length === 0 ? (
|
|
379
|
+
<li className="px-2 py-8 text-center">
|
|
380
|
+
<p className="text-[13px] text-[var(--text-muted)]">Aucune section pour l'instant.</p>
|
|
381
|
+
<Button
|
|
382
|
+
type="button"
|
|
383
|
+
variant="secondary"
|
|
384
|
+
className="mt-3 min-h-9 px-4 text-xs"
|
|
385
|
+
onClick={() => setCreateModalOpen(true)}
|
|
386
|
+
>
|
|
387
|
+
<Plus className="size-4" aria-hidden />
|
|
388
|
+
Nouvelle section
|
|
389
|
+
</Button>
|
|
390
|
+
</li>
|
|
391
|
+
) : (
|
|
392
|
+
sections.map((s) => (
|
|
393
|
+
<SortableSectionItem
|
|
394
|
+
key={s.id}
|
|
395
|
+
section={s}
|
|
396
|
+
selected={selectedId === s.id}
|
|
397
|
+
onSelect={() => setSelectedId(s.id)}
|
|
398
|
+
cn={cn}
|
|
399
|
+
/>
|
|
400
|
+
))
|
|
401
|
+
)}
|
|
402
|
+
</ul>
|
|
403
|
+
</SortableContext>
|
|
404
|
+
</DndContext>
|
|
405
|
+
</aside>
|
|
406
|
+
|
|
407
|
+
<div className="min-w-0 flex-1 rounded-xl border border-[var(--border)] bg-white">
|
|
408
|
+
{selected ? (
|
|
409
|
+
<>
|
|
410
|
+
<div className="flex flex-col gap-3 border-b border-[var(--border)] px-4 py-3">
|
|
411
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
412
|
+
<Input
|
|
413
|
+
value={selected.titleFr}
|
|
414
|
+
onChange={(e) => updateDraft({ titleFr: e.target.value })}
|
|
415
|
+
placeholder="Titre (FR)"
|
|
416
|
+
className="max-w-xs"
|
|
417
|
+
/>
|
|
418
|
+
<Input
|
|
419
|
+
value={selected.titleEn}
|
|
420
|
+
onChange={(e) => updateDraft({ titleEn: e.target.value })}
|
|
421
|
+
placeholder="Title (EN)"
|
|
422
|
+
className="max-w-xs"
|
|
423
|
+
/>
|
|
424
|
+
<div className="ml-auto flex items-center gap-2">
|
|
425
|
+
<span className="text-[11px] text-[var(--text-muted)]">
|
|
426
|
+
{saving
|
|
427
|
+
? "Enregistrement…"
|
|
428
|
+
: lastSavedAt
|
|
429
|
+
? `Auto-sauvegardé — ${formatRelative(lastSavedAt)}`
|
|
430
|
+
: ""}
|
|
431
|
+
</span>
|
|
432
|
+
<Button
|
|
433
|
+
type="button"
|
|
434
|
+
variant="ghost"
|
|
435
|
+
className="min-h-8 px-3 text-xs text-destructive"
|
|
436
|
+
onClick={() => void deleteSelected()}
|
|
437
|
+
>
|
|
438
|
+
<Trash2 className="size-4" aria-hidden />
|
|
439
|
+
Supprimer
|
|
440
|
+
</Button>
|
|
441
|
+
</div>
|
|
442
|
+
</div>
|
|
443
|
+
<HostUnderlineTabs
|
|
444
|
+
options={[
|
|
445
|
+
{ value: 'fr', label: 'Contenu FR' },
|
|
446
|
+
{ value: 'en', label: 'Content EN' },
|
|
447
|
+
]}
|
|
448
|
+
value={lang}
|
|
449
|
+
onChange={(value) => setLang(value as LangTab)}
|
|
450
|
+
ariaLabel="Langue du contenu"
|
|
451
|
+
/>
|
|
452
|
+
</div>
|
|
453
|
+
<div className="p-4">
|
|
454
|
+
<NotionLikeEditor
|
|
455
|
+
key={`${selected.id}-${lang}`}
|
|
456
|
+
value={editorValue}
|
|
457
|
+
onChange={(json) => {
|
|
458
|
+
try {
|
|
459
|
+
const node = JSON.parse(json) as unknown;
|
|
460
|
+
if (lang === "fr") {
|
|
461
|
+
updateDraft({ contentFr: node });
|
|
462
|
+
} else {
|
|
463
|
+
updateDraft({ contentEn: node });
|
|
464
|
+
}
|
|
465
|
+
} catch {
|
|
466
|
+
/* ignore */
|
|
467
|
+
}
|
|
468
|
+
}}
|
|
469
|
+
/>
|
|
470
|
+
</div>
|
|
471
|
+
</>
|
|
472
|
+
) : (
|
|
473
|
+
<div className="flex min-h-[320px] items-center justify-center p-8 text-[13px] text-[var(--text-muted)]">
|
|
474
|
+
Sélectionnez ou créez une section.
|
|
475
|
+
</div>
|
|
476
|
+
)}
|
|
477
|
+
</div>
|
|
478
|
+
|
|
479
|
+
<SectionCreateModal
|
|
480
|
+
open={createModalOpen}
|
|
481
|
+
onOpenChange={setCreateModalOpen}
|
|
482
|
+
design={design.createModal}
|
|
483
|
+
onCreate={createSection}
|
|
484
|
+
creating={creating}
|
|
485
|
+
shell={shell}
|
|
486
|
+
/>
|
|
487
|
+
</div>
|
|
488
|
+
)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function formatRelative(d: Date): string {
|
|
492
|
+
const sec = Math.round((Date.now() - d.getTime()) / 1000);
|
|
493
|
+
if (sec < 8) {
|
|
494
|
+
return "à l’instant";
|
|
495
|
+
}
|
|
496
|
+
if (sec < 60) {
|
|
497
|
+
return `il y a ${sec} s`;
|
|
498
|
+
}
|
|
499
|
+
const min = Math.round(sec / 60);
|
|
500
|
+
return `il y a ${min} min`;
|
|
501
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export const SECTION_CONTENT_PRESET_IDS = [
|
|
2
|
+
'welcome',
|
|
3
|
+
'beaches',
|
|
4
|
+
'restaurants',
|
|
5
|
+
'transport',
|
|
6
|
+
'emergencies',
|
|
7
|
+
'empty',
|
|
8
|
+
] as const
|
|
9
|
+
|
|
10
|
+
export type SectionContentPresetId = (typeof SECTION_CONTENT_PRESET_IDS)[number]
|
|
11
|
+
|
|
12
|
+
export type SectionTemplateId = SectionContentPresetId
|
|
13
|
+
|
|
14
|
+
export type SectionsEditorEmptyStateDesign = {
|
|
15
|
+
titleFr: string
|
|
16
|
+
descriptionFr: string
|
|
17
|
+
primaryActionLabelFr: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type SectionCreateModalDesign = {
|
|
21
|
+
titleFr: string
|
|
22
|
+
subtitleFr: string
|
|
23
|
+
titleFieldLabelFr: string
|
|
24
|
+
titleFieldHintFr: string
|
|
25
|
+
footerHintFr: string
|
|
26
|
+
cancelLabelFr: string
|
|
27
|
+
submitLabelFr: string
|
|
28
|
+
defaultTemplateId: SectionTemplateId
|
|
29
|
+
templates: readonly SectionTemplateDesign[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type SectionTemplateDesign = {
|
|
33
|
+
id: SectionTemplateId
|
|
34
|
+
icon: string
|
|
35
|
+
titleFr: string
|
|
36
|
+
titleEn: string
|
|
37
|
+
descriptionFr: string
|
|
38
|
+
contentPreset: SectionContentPresetId
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type SectionsEditorDesign = {
|
|
42
|
+
id: 'sections-editor-v1'
|
|
43
|
+
moduleId: 'sections'
|
|
44
|
+
layout: 'sections-split-panel'
|
|
45
|
+
gateway: {
|
|
46
|
+
listQuery: 'sections.list'
|
|
47
|
+
saveCommand: 'sections.section.save'
|
|
48
|
+
deleteCommand: 'sections.section.delete'
|
|
49
|
+
reorderCommand: 'sections.reorder'
|
|
50
|
+
}
|
|
51
|
+
createModal: SectionCreateModalDesign
|
|
52
|
+
emptyState: SectionsEditorEmptyStateDesign
|
|
53
|
+
toasts: {
|
|
54
|
+
loadError: string
|
|
55
|
+
saveError: string
|
|
56
|
+
reorderError: string
|
|
57
|
+
createSuccess: string
|
|
58
|
+
createError: string
|
|
59
|
+
deleteSuccess: string
|
|
60
|
+
deleteError: string
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/module.test.tsx
CHANGED
|
@@ -1,17 +1,32 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import { waitFor } from '@testing-library/react'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
assertGuestSurface,
|
|
5
|
+
renderGuestModule,
|
|
6
|
+
renderHostModule,
|
|
7
|
+
} from '@portaki/sdk-test-support'
|
|
4
8
|
|
|
5
9
|
import moduleDef from './index'
|
|
10
|
+
import { createSectionsTestHostContext } from './test/mock-host-module-context'
|
|
6
11
|
|
|
7
12
|
describe('@portaki/module-sections', () => {
|
|
8
13
|
it('exposes a valid guest module definition', () => {
|
|
9
14
|
assertGuestSurface(moduleDef)
|
|
10
15
|
})
|
|
11
16
|
|
|
12
|
-
it('renders without crashing', async () => {
|
|
13
|
-
const view =
|
|
14
|
-
|
|
17
|
+
it('renders guest surface without crashing', async () => {
|
|
18
|
+
const view = renderGuestModule(moduleDef)
|
|
19
|
+
await waitFor(() => {
|
|
20
|
+
expect(view.container).toBeTruthy()
|
|
21
|
+
})
|
|
22
|
+
view.unmount()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('renders host surface without crashing', async () => {
|
|
26
|
+
if (moduleDef.renderHost == null) {
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
const view = renderHostModule(moduleDef, createSectionsTestHostContext())
|
|
15
30
|
await waitFor(() => {
|
|
16
31
|
expect(view.container).toBeTruthy()
|
|
17
32
|
})
|
|
@@ -10,11 +10,14 @@ import {
|
|
|
10
10
|
timestamptz,
|
|
11
11
|
uuid,
|
|
12
12
|
uuidPrimaryKey,
|
|
13
|
+
type HostModuleContext,
|
|
13
14
|
type ModuleContext,
|
|
14
15
|
type PortakiFullModule,
|
|
15
16
|
} from '@portaki/sdk'
|
|
16
17
|
import type { ReactNode } from 'react'
|
|
17
18
|
|
|
19
|
+
import { SectionsHostWorkspace } from './host/SectionsHostWorkspace'
|
|
20
|
+
|
|
18
21
|
const sectionsSchema = moduleSchema([
|
|
19
22
|
table('items', 't_e_module_sections_item', {
|
|
20
23
|
columns: [
|
|
@@ -297,5 +300,6 @@ export function defineSectionsModule(render: (ctx: ModuleContext) => ReactNode):
|
|
|
297
300
|
},
|
|
298
301
|
|
|
299
302
|
render,
|
|
303
|
+
renderHost: (ctx: HostModuleContext) => <SectionsHostWorkspace ctx={ctx} />,
|
|
300
304
|
})
|
|
301
305
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { HostModuleContext, HostModuleShellComponents } from '@portaki/sdk'
|
|
2
|
+
import { createMockHostModuleContext } from '@portaki/sdk-test-support'
|
|
3
|
+
|
|
4
|
+
import type { SectionsEditorDesign } from '../host/sections-host.types'
|
|
5
|
+
|
|
6
|
+
/** Stubs until published `@portaki/sdk-test-support` includes `shell` / `gateway`. */
|
|
7
|
+
export const mockHostShell: HostModuleShellComponents = {
|
|
8
|
+
NotionLikeEditor: () => null,
|
|
9
|
+
HostEmptyPlaceholder: () => null,
|
|
10
|
+
HostUnderlineTabs: () => null,
|
|
11
|
+
Button: () => null,
|
|
12
|
+
Input: () => null,
|
|
13
|
+
Dialog: ({ children }) => children ?? null,
|
|
14
|
+
DialogContent: ({ children }) => children ?? null,
|
|
15
|
+
DialogHeader: ({ children }) => children ?? null,
|
|
16
|
+
DialogTitle: ({ children }) => children ?? null,
|
|
17
|
+
DialogDescription: ({ children }) => children ?? null,
|
|
18
|
+
DialogFooter: ({ children }) => children ?? null,
|
|
19
|
+
cn: (...parts) => parts.filter(Boolean).join(' '),
|
|
20
|
+
toast: { success: () => {}, error: () => {} },
|
|
21
|
+
parseTipTapDoc: () => ({ type: 'doc', content: [{ type: 'paragraph' }] }),
|
|
22
|
+
serializeTipTapDoc: (doc) => JSON.stringify(doc),
|
|
23
|
+
buildSectionTipTapPreset: () => ({
|
|
24
|
+
contentFr: { type: 'doc', content: [] },
|
|
25
|
+
contentEn: { type: 'doc', content: [] },
|
|
26
|
+
}),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const mockSectionsDesign: SectionsEditorDesign = {
|
|
30
|
+
id: 'sections-editor-v1',
|
|
31
|
+
moduleId: 'sections',
|
|
32
|
+
layout: 'sections-split-panel',
|
|
33
|
+
gateway: {
|
|
34
|
+
listQuery: 'sections.list',
|
|
35
|
+
saveCommand: 'sections.section.save',
|
|
36
|
+
deleteCommand: 'sections.section.delete',
|
|
37
|
+
reorderCommand: 'sections.reorder',
|
|
38
|
+
},
|
|
39
|
+
createModal: {
|
|
40
|
+
titleFr: 'Nouvelle section',
|
|
41
|
+
subtitleFr: 'Modèle',
|
|
42
|
+
titleFieldLabelFr: 'Titre',
|
|
43
|
+
titleFieldHintFr: 'Hint',
|
|
44
|
+
footerHintFr: 'Footer',
|
|
45
|
+
cancelLabelFr: 'Annuler',
|
|
46
|
+
submitLabelFr: 'Créer',
|
|
47
|
+
defaultTemplateId: 'welcome',
|
|
48
|
+
templates: [],
|
|
49
|
+
},
|
|
50
|
+
emptyState: {
|
|
51
|
+
titleFr: 'Vide',
|
|
52
|
+
descriptionFr: 'Desc',
|
|
53
|
+
primaryActionLabelFr: 'Créer',
|
|
54
|
+
},
|
|
55
|
+
toasts: {
|
|
56
|
+
loadError: 'load',
|
|
57
|
+
saveError: 'save',
|
|
58
|
+
reorderError: 'reorder',
|
|
59
|
+
createSuccess: 'created',
|
|
60
|
+
createError: 'create err',
|
|
61
|
+
deleteSuccess: 'deleted',
|
|
62
|
+
deleteError: 'delete err',
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function createSectionsTestHostContext(
|
|
67
|
+
overrides: Partial<HostModuleContext> = {},
|
|
68
|
+
): HostModuleContext {
|
|
69
|
+
const base = createMockHostModuleContext(overrides)
|
|
70
|
+
return {
|
|
71
|
+
...base,
|
|
72
|
+
design: overrides.design ?? mockSectionsDesign,
|
|
73
|
+
gateway: overrides.gateway ?? base.gateway ?? {
|
|
74
|
+
query: async <T,>() => [] as T,
|
|
75
|
+
command: async () => {},
|
|
76
|
+
},
|
|
77
|
+
shell: overrides.shell ?? base.shell ?? mockHostShell,
|
|
78
|
+
}
|
|
79
|
+
}
|