vue-wswg-editor 0.0.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/README.md +91 -0
- package/dist/style.css +1 -0
- package/dist/types/components/AddBlockItem/AddBlockItem.vue.d.ts +6 -0
- package/dist/types/components/BlockBrowser/BlockBrowser.vue.d.ts +2 -0
- package/dist/types/components/BlockComponent/BlockComponent.vue.d.ts +15 -0
- package/dist/types/components/BlockEditorFieldNode/BlockEditorFieldNode.vue.d.ts +15 -0
- package/dist/types/components/BlockEditorFields/BlockEditorFields.vue.d.ts +15 -0
- package/dist/types/components/BlockMarginFieldNode/BlockMarginNode.vue.d.ts +23 -0
- package/dist/types/components/BlockRepeaterFieldNode/BlockRepeaterNode.vue.d.ts +15 -0
- package/dist/types/components/BrowserNavigation/BrowserNavigation.vue.d.ts +5 -0
- package/dist/types/components/EmptyState/EmptyState.vue.d.ts +15 -0
- package/dist/types/components/PageBlockList/PageBlockList.vue.d.ts +19 -0
- package/dist/types/components/PageBuilderSidebar/PageBuilderSidebar.vue.d.ts +30 -0
- package/dist/types/components/PageBuilderToolbar/PageBuilderToolbar.vue.d.ts +28 -0
- package/dist/types/components/PageRenderer/PageRenderer.vue.d.ts +6 -0
- package/dist/types/components/PageRenderer/blockModules.d.ts +1 -0
- package/dist/types/components/PageSettings/PageSettings.vue.d.ts +15 -0
- package/dist/types/components/ResizeHandle/ResizeHandle.vue.d.ts +6 -0
- package/dist/types/components/WswgJsonEditor/WswgJsonEditor.test.d.ts +1 -0
- package/dist/types/components/WswgJsonEditor/WswgJsonEditor.vue.d.ts +40 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/dist/types/util/fieldConfig.d.ts +82 -0
- package/dist/types/util/helpers.d.ts +28 -0
- package/dist/types/util/registry.d.ts +21 -0
- package/dist/types/util/validation.d.ts +15 -0
- package/dist/vue-wswg-editor.es.js +3377 -0
- package/package.json +85 -0
- package/src/assets/images/empty-state.jpg +0 -0
- package/src/assets/styles/_mixins.scss +73 -0
- package/src/assets/styles/main.css +3 -0
- package/src/components/AddBlockItem/AddBlockItem.vue +50 -0
- package/src/components/BlockBrowser/BlockBrowser.vue +69 -0
- package/src/components/BlockComponent/BlockComponent.vue +186 -0
- package/src/components/BlockEditorFieldNode/BlockEditorFieldNode.vue +378 -0
- package/src/components/BlockEditorFields/BlockEditorFields.vue +91 -0
- package/src/components/BlockMarginFieldNode/BlockMarginNode.vue +132 -0
- package/src/components/BlockRepeaterFieldNode/BlockRepeaterNode.vue +217 -0
- package/src/components/BrowserNavigation/BrowserNavigation.vue +27 -0
- package/src/components/EmptyState/EmptyState.vue +94 -0
- package/src/components/PageBlockList/PageBlockList.vue +103 -0
- package/src/components/PageBuilderSidebar/PageBuilderSidebar.vue +241 -0
- package/src/components/PageBuilderToolbar/PageBuilderToolbar.vue +63 -0
- package/src/components/PageRenderer/PageRenderer.vue +65 -0
- package/src/components/PageRenderer/blockModules-alternative.ts.example +9 -0
- package/src/components/PageRenderer/blockModules-manual.ts.example +19 -0
- package/src/components/PageRenderer/blockModules-runtime.ts.example +23 -0
- package/src/components/PageRenderer/blockModules.ts +3 -0
- package/src/components/PageSettings/PageSettings.vue +86 -0
- package/src/components/ResizeHandle/ResizeHandle.vue +105 -0
- package/src/components/WswgJsonEditor/WswgJsonEditor.test.ts +43 -0
- package/src/components/WswgJsonEditor/WswgJsonEditor.vue +391 -0
- package/src/index.ts +15 -0
- package/src/shims.d.ts +72 -0
- package/src/style.css +3 -0
- package/src/types/Block.d.ts +19 -0
- package/src/types/Layout.d.ts +9 -0
- package/src/util/fieldConfig.ts +173 -0
- package/src/util/helpers.ts +176 -0
- package/src/util/registry.ts +149 -0
- package/src/util/validation.ts +110 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="repeater-field">
|
|
3
|
+
<div v-if="fieldValue.length === 0" class="empty-state bg-zinc-50 px-4 py-5 text-center text-sm text-neutral-500">
|
|
4
|
+
<p class="underline underline-offset-2">No items yet.</p>
|
|
5
|
+
<button
|
|
6
|
+
v-if="editable"
|
|
7
|
+
:disabled="!!(fieldConfig.maxItems && fieldValue.length >= fieldConfig.maxItems)"
|
|
8
|
+
class="mx-auto mt-4 flex items-center gap-1 rounded-md border border-blue-200 bg-blue-50 px-2.5 py-1.5 text-sm text-blue-500 transition-all duration-200 hover:bg-blue-100 hover:text-blue-700"
|
|
9
|
+
@click="addItem"
|
|
10
|
+
>
|
|
11
|
+
<span>Add item</span>
|
|
12
|
+
<span>+</span>
|
|
13
|
+
</button>
|
|
14
|
+
<p v-else class="text-sm text-neutral-500">Enter edit mode to add items.</p>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<div v-else class="repeater-items space-y-2">
|
|
18
|
+
<div
|
|
19
|
+
v-for="(item, index) in fieldValue"
|
|
20
|
+
:key="`${fieldName}-item-${index}`"
|
|
21
|
+
class="repeater-item overflow-hidden rounded-lg border bg-white hover:border-zinc-300 hover:shadow-sm"
|
|
22
|
+
:class="{ 'is-open': openRepeaterItems.includes(item.id) }"
|
|
23
|
+
>
|
|
24
|
+
<div class="repeater-item-header flex items-center gap-2 bg-zinc-50 p-3">
|
|
25
|
+
<button
|
|
26
|
+
class="text-zinc-500 hover:text-zinc-700"
|
|
27
|
+
title="Toggle item"
|
|
28
|
+
@click="toggleRepeaterItem(item.id)"
|
|
29
|
+
>
|
|
30
|
+
<ChevronDownIcon v-if="!openRepeaterItems.includes(item.id)" class="size-4" />
|
|
31
|
+
<ChevronUpIcon v-else class="size-4" />
|
|
32
|
+
</button>
|
|
33
|
+
<h4 class="mr-auto text-sm font-medium text-gray-700">
|
|
34
|
+
<span v-if="fieldConfig.repeaterFieldLabel">
|
|
35
|
+
{{ item[fieldConfig.repeaterFieldLabel] || `Item ${index + 1}` }}
|
|
36
|
+
</span>
|
|
37
|
+
<span v-else> Item {{ index + 1 }} </span>
|
|
38
|
+
</h4>
|
|
39
|
+
<div v-if="editable" class="flex items-center gap-2">
|
|
40
|
+
<button v-if="index > 0" :disabled="!editable" title="Move item up" @click="moveItemUp(index)">
|
|
41
|
+
<BarsArrowUpIcon class="size-4" />
|
|
42
|
+
</button>
|
|
43
|
+
<button v-if="index < fieldValue.length - 1" title="Move item down" @click="moveItemDown(index)">
|
|
44
|
+
<BarsArrowDownIcon class="size-4" />
|
|
45
|
+
</button>
|
|
46
|
+
<button class="text-red-600 hover:text-red-700" title="Remove item" @click="removeItem(index)">
|
|
47
|
+
<XMarkIcon class="size-4" />
|
|
48
|
+
</button>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<div class="repeater-item-fields flex flex-col gap-3">
|
|
53
|
+
<template
|
|
54
|
+
v-for="(subFieldConfig, subFieldName) in fieldConfig.repeaterFields"
|
|
55
|
+
:key="`${fieldName}-${index}-${subFieldName}`"
|
|
56
|
+
>
|
|
57
|
+
<BlockEditorFieldNode
|
|
58
|
+
v-model="fieldValue[index][subFieldName]"
|
|
59
|
+
:fieldConfig="subFieldConfig"
|
|
60
|
+
:fieldName="subFieldName"
|
|
61
|
+
:editable="editable"
|
|
62
|
+
/>
|
|
63
|
+
</template>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<div class="mt-3 flex justify-between gap-2">
|
|
69
|
+
<div
|
|
70
|
+
v-if="fieldConfig.minItems && fieldValue.length === fieldConfig.minItems"
|
|
71
|
+
class="mt-2 text-xs text-amber-600"
|
|
72
|
+
>
|
|
73
|
+
Minimum {{ fieldConfig.minItems }} item{{ fieldConfig.minItems > 1 ? "s" : "" }} required
|
|
74
|
+
</div>
|
|
75
|
+
<div
|
|
76
|
+
v-if="fieldConfig.maxItems && fieldValue.length >= fieldConfig.maxItems"
|
|
77
|
+
class="mt-2 text-xs text-amber-600"
|
|
78
|
+
>
|
|
79
|
+
Maximum {{ fieldConfig.maxItems }} item{{ fieldConfig.maxItems > 1 ? "s" : "" }} allowed
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<button
|
|
83
|
+
v-if="editable && fieldValue.length"
|
|
84
|
+
:disabled="canAddItem"
|
|
85
|
+
class="ml-auto mt-1 flex items-center gap-1 rounded-md border border-blue-200 bg-blue-50 px-2.5 py-1.5 text-sm text-blue-500 transition-all duration-200 hover:bg-blue-100 hover:text-blue-700"
|
|
86
|
+
:class="{ 'cursor-not-allowed opacity-50': canAddItem }"
|
|
87
|
+
@click="addItem"
|
|
88
|
+
>
|
|
89
|
+
<span>Add item</span>
|
|
90
|
+
<PlusIcon class="size-4" />
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</template>
|
|
95
|
+
|
|
96
|
+
<script setup lang="ts">
|
|
97
|
+
import { ref, onBeforeMount, computed } from "vue";
|
|
98
|
+
import BlockEditorFieldNode from "../BlockEditorFieldNode/BlockEditorFieldNode.vue";
|
|
99
|
+
import type { EditorFieldConfig } from "../../util/fieldConfig";
|
|
100
|
+
import {
|
|
101
|
+
BarsArrowDownIcon,
|
|
102
|
+
BarsArrowUpIcon,
|
|
103
|
+
XMarkIcon,
|
|
104
|
+
PlusIcon,
|
|
105
|
+
ChevronDownIcon,
|
|
106
|
+
ChevronUpIcon,
|
|
107
|
+
} from "@heroicons/vue/24/outline";
|
|
108
|
+
|
|
109
|
+
const props = defineProps<{
|
|
110
|
+
fieldConfig: EditorFieldConfig;
|
|
111
|
+
fieldName: string;
|
|
112
|
+
editable: boolean;
|
|
113
|
+
}>();
|
|
114
|
+
|
|
115
|
+
const fieldValueModel = defineModel<any[]>();
|
|
116
|
+
|
|
117
|
+
// Wrap fieldValue with default value handling
|
|
118
|
+
const fieldValue = computed({
|
|
119
|
+
get: () => fieldValueModel.value ?? [],
|
|
120
|
+
set: (value) => {
|
|
121
|
+
fieldValueModel.value = value;
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Track open repeater items
|
|
126
|
+
const openRepeaterItems = ref<string[]>([]);
|
|
127
|
+
|
|
128
|
+
// Ensure fieldValue is always an array with id attribute
|
|
129
|
+
onBeforeMount(() => {
|
|
130
|
+
fieldValue.value = fieldValue.value.map((item) => ({
|
|
131
|
+
id: item.id || crypto.randomUUID(),
|
|
132
|
+
...item,
|
|
133
|
+
}));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
function addItem() {
|
|
137
|
+
if (props.fieldConfig.maxItems && fieldValue.value.length >= props.fieldConfig.maxItems) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const itemId = crypto.randomUUID();
|
|
141
|
+
// Merge the default value with the new item
|
|
142
|
+
const defaultValues = typeof props.fieldConfig.default === "object" ? props.fieldConfig.default : {};
|
|
143
|
+
const newItem = { id: itemId, ...defaultValues };
|
|
144
|
+
|
|
145
|
+
// Create a new array reference to ensure reactivity
|
|
146
|
+
fieldValue.value = [...fieldValue.value, newItem];
|
|
147
|
+
openRepeaterItems.value.push(itemId);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function removeItem(index: number) {
|
|
151
|
+
if (props.fieldConfig.minItems && fieldValue.value.length <= props.fieldConfig.minItems) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const itemId = fieldValue.value[index]?.id;
|
|
155
|
+
// Create a new array reference to ensure reactivity
|
|
156
|
+
fieldValue.value = fieldValue.value.filter((_, i) => i !== index);
|
|
157
|
+
if (itemId) {
|
|
158
|
+
openRepeaterItems.value = openRepeaterItems.value.filter((item) => item !== itemId);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function moveItemUp(index: number) {
|
|
163
|
+
if (index > 0) {
|
|
164
|
+
const newArray = [...fieldValue.value];
|
|
165
|
+
const [item] = newArray.splice(index, 1);
|
|
166
|
+
newArray.splice(index - 1, 0, item);
|
|
167
|
+
// Create a new array reference to ensure reactivity
|
|
168
|
+
fieldValue.value = newArray;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function moveItemDown(index: number) {
|
|
173
|
+
if (index < fieldValue.value.length - 1) {
|
|
174
|
+
const newArray = [...fieldValue.value];
|
|
175
|
+
const [item] = newArray.splice(index, 1);
|
|
176
|
+
newArray.splice(index + 1, 0, item);
|
|
177
|
+
// Create a new array reference to ensure reactivity
|
|
178
|
+
fieldValue.value = newArray;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function toggleRepeaterItem(itemId: string) {
|
|
183
|
+
if (openRepeaterItems.value.includes(itemId)) {
|
|
184
|
+
openRepeaterItems.value = openRepeaterItems.value.filter((item) => item !== itemId);
|
|
185
|
+
} else {
|
|
186
|
+
openRepeaterItems.value.push(itemId);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const canAddItem = computed(() => {
|
|
191
|
+
return !props.editable || !!(props.fieldConfig.maxItems && fieldValue.value.length >= props.fieldConfig.maxItems);
|
|
192
|
+
});
|
|
193
|
+
</script>
|
|
194
|
+
|
|
195
|
+
<style scoped lang="scss">
|
|
196
|
+
.repeater-field {
|
|
197
|
+
width: 100%;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.empty-state {
|
|
201
|
+
border: 2px dashed #e5e7eb;
|
|
202
|
+
border-radius: 0.5rem;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.repeater-item {
|
|
206
|
+
.repeater-item-fields {
|
|
207
|
+
height: 0;
|
|
208
|
+
overflow: hidden;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
&.is-open {
|
|
212
|
+
.repeater-item-fields {
|
|
213
|
+
@apply p-3 h-auto border-t;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
</style>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!-- URL bar -->
|
|
3
|
+
<div class="browser-navigation-bar border-b bg-zinc-600 py-3 !font-sans">
|
|
4
|
+
<div class="mx-auto flex max-w-5xl items-center justify-between px-5">
|
|
5
|
+
<div class="flex w-full items-center gap-2 rounded-md bg-zinc-700 px-4 py-1.5 text-sm text-zinc-300">
|
|
6
|
+
<span class="block flex-1 truncate">{{ url }}</span>
|
|
7
|
+
|
|
8
|
+
<a
|
|
9
|
+
:href="url"
|
|
10
|
+
target="_blank"
|
|
11
|
+
title="Open in new tab"
|
|
12
|
+
class="inline-block rounded-md p-1.5 text-xs text-zinc-300 hover:bg-zinc-500 hover:text-white"
|
|
13
|
+
>
|
|
14
|
+
<ArrowUpRightIcon class="size-4" />
|
|
15
|
+
</a>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
</template>
|
|
20
|
+
|
|
21
|
+
<script setup lang="ts">
|
|
22
|
+
import { ArrowUpRightIcon } from "@heroicons/vue/24/outline";
|
|
23
|
+
|
|
24
|
+
defineProps<{
|
|
25
|
+
url: string;
|
|
26
|
+
}>();
|
|
27
|
+
</script>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
v-if="editable"
|
|
4
|
+
class="relative p-5 py-12 text-center transition-all duration-200"
|
|
5
|
+
:class="{ 'dropzone-active': isDraggingOver }"
|
|
6
|
+
@dragover.prevent="handleDragOver"
|
|
7
|
+
@dragenter.prevent="handleDragEnter"
|
|
8
|
+
@dragleave="handleDragLeave"
|
|
9
|
+
@drop.prevent="handleDrop"
|
|
10
|
+
>
|
|
11
|
+
<!-- Dropzone overlay -->
|
|
12
|
+
<div
|
|
13
|
+
v-if="isDraggingOver"
|
|
14
|
+
class="absolute inset-0 z-10 flex items-center justify-center rounded-lg border-2 border-dashed border-blue-500 bg-blue-50/80 backdrop-blur-sm"
|
|
15
|
+
>
|
|
16
|
+
<div class="text-center">
|
|
17
|
+
<p class="text-lg font-semibold text-blue-700">Drop block here</p>
|
|
18
|
+
<p class="mt-1 text-sm text-blue-600">Release to add your first block</p>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<h2 class="mb-3 text-xl font-bold">No blocks found</h2>
|
|
23
|
+
<template v-if="editable">
|
|
24
|
+
<button
|
|
25
|
+
v-if="!showAddBlockMenu"
|
|
26
|
+
class="mb-9 inline-flex items-center gap-1.5 rounded-md border bg-zinc-50 px-3 py-2 text-sm text-zinc-500 hover:border-zinc-400 hover:text-zinc-900 active:border-blue-600 active:bg-blue-50 active:text-blue-600"
|
|
27
|
+
@click="showAddBlockMenu = true"
|
|
28
|
+
>
|
|
29
|
+
Add a block to get started
|
|
30
|
+
</button>
|
|
31
|
+
<span
|
|
32
|
+
v-else
|
|
33
|
+
class="mb-9 inline-flex items-center gap-1.5 border border-transparent px-3 py-2 text-sm text-zinc-500"
|
|
34
|
+
>
|
|
35
|
+
Select a block from the sidebar
|
|
36
|
+
</span>
|
|
37
|
+
</template>
|
|
38
|
+
<!-- empty state image from assets -->
|
|
39
|
+
<img :src="emptyStateImage" alt="Empty state" class="mx-auto h-auto w-full max-w-xs" />
|
|
40
|
+
</div>
|
|
41
|
+
<div v-else class="p-5 py-12 text-center">
|
|
42
|
+
<h2 class="mb-3 text-xl font-bold">No blocks found</h2>
|
|
43
|
+
<!-- empty state image from assets -->
|
|
44
|
+
<img :src="emptyStateImage" alt="Empty state" class="mx-auto h-auto w-full max-w-xs" />
|
|
45
|
+
</div>
|
|
46
|
+
</template>
|
|
47
|
+
|
|
48
|
+
<script setup lang="ts">
|
|
49
|
+
import { ref } from "vue";
|
|
50
|
+
import emptyStateImage from "../../assets/images/empty-state.jpg";
|
|
51
|
+
|
|
52
|
+
const showAddBlockMenu = defineModel<boolean>("showAddBlockMenu");
|
|
53
|
+
const isDraggingOver = ref(false);
|
|
54
|
+
const emit = defineEmits<{
|
|
55
|
+
(e: "blockAdded", blockType: string): void;
|
|
56
|
+
}>();
|
|
57
|
+
|
|
58
|
+
defineProps<{
|
|
59
|
+
editable: boolean;
|
|
60
|
+
}>();
|
|
61
|
+
|
|
62
|
+
function handleDragOver(event: DragEvent) {
|
|
63
|
+
if (!event.dataTransfer) return;
|
|
64
|
+
event.dataTransfer.dropEffect = "move";
|
|
65
|
+
isDraggingOver.value = true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function handleDragEnter(event: DragEvent) {
|
|
69
|
+
if (!event.dataTransfer) return;
|
|
70
|
+
event.dataTransfer.dropEffect = "move";
|
|
71
|
+
isDraggingOver.value = true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function handleDragLeave(event: DragEvent) {
|
|
75
|
+
const relatedTarget = event.relatedTarget as HTMLElement | null;
|
|
76
|
+
const currentTarget = event.currentTarget as HTMLElement | null;
|
|
77
|
+
|
|
78
|
+
if (!currentTarget?.contains(relatedTarget)) {
|
|
79
|
+
isDraggingOver.value = false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function handleDrop(event: DragEvent) {
|
|
84
|
+
isDraggingOver.value = false;
|
|
85
|
+
|
|
86
|
+
if (!event.dataTransfer) return;
|
|
87
|
+
|
|
88
|
+
// Get block type from dataTransfer
|
|
89
|
+
const blockType = event.dataTransfer.getData("block-type");
|
|
90
|
+
if (blockType) {
|
|
91
|
+
emit("blockAdded", blockType);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
</script>
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div id="page-blocks-list" class="flex flex-col gap-1 p-5">
|
|
3
|
+
<template v-if="pageBlocks?.length">
|
|
4
|
+
<div
|
|
5
|
+
v-for="block in pageBlocks"
|
|
6
|
+
:key="block.id"
|
|
7
|
+
:class="{ 'hovered-block': hoveredBlockId === block.id }"
|
|
8
|
+
class="block-item -mx-2.5 flex cursor-pointer items-center gap-1 rounded-md p-2.5 text-sm text-neutral-900"
|
|
9
|
+
@mouseenter="setHoveredBlockId(block.id)"
|
|
10
|
+
@mouseleave="setHoveredBlockId(null)"
|
|
11
|
+
@click="emit('block-click', block)"
|
|
12
|
+
>
|
|
13
|
+
<p>{{ block.label || block.type }}</p>
|
|
14
|
+
<span v-if="!block.__file" class="ml-auto rounded-full bg-zinc-100 px-2 py-0.5 text-xs text-zinc-500"
|
|
15
|
+
>Not registered</span
|
|
16
|
+
>
|
|
17
|
+
</div>
|
|
18
|
+
</template>
|
|
19
|
+
<template v-else>
|
|
20
|
+
<p class="bg-zinc-100 p-5 text-center text-sm text-zinc-500">No blocks added yet.</p>
|
|
21
|
+
</template>
|
|
22
|
+
</div>
|
|
23
|
+
</template>
|
|
24
|
+
|
|
25
|
+
<script setup lang="ts">
|
|
26
|
+
import { computed, defineEmits, onMounted, ref } from "vue";
|
|
27
|
+
import { pageBuilderBlocks } from "../../util/registry";
|
|
28
|
+
import type { Block } from "../../types/Block";
|
|
29
|
+
import { toCamelCase, toNiceName } from "../../util/helpers";
|
|
30
|
+
import Sortable from "sortablejs";
|
|
31
|
+
|
|
32
|
+
const emit = defineEmits<{
|
|
33
|
+
(e: "block-click", block: Block): void;
|
|
34
|
+
}>();
|
|
35
|
+
|
|
36
|
+
const pageData = defineModel<any>();
|
|
37
|
+
const hoveredBlockId = defineModel<string | null>("hoveredBlockId");
|
|
38
|
+
const isSorting = ref(false);
|
|
39
|
+
const props = defineProps<{
|
|
40
|
+
blocksKey: string;
|
|
41
|
+
settingsKey: string;
|
|
42
|
+
}>();
|
|
43
|
+
|
|
44
|
+
const pageBlocks = computed(() => {
|
|
45
|
+
if (!pageData.value?.[props.blocksKey]) return [];
|
|
46
|
+
// loop through pageData[blocksKey] and get the block data from registry or return a default block data
|
|
47
|
+
return pageData.value[props.blocksKey].map((block: any) => {
|
|
48
|
+
if (pageBuilderBlocks.value[toCamelCase(block.type)]) {
|
|
49
|
+
return {
|
|
50
|
+
...pageBuilderBlocks.value[toCamelCase(block.type)],
|
|
51
|
+
...block,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// Fallback to default block data
|
|
55
|
+
return {
|
|
56
|
+
id: block.id,
|
|
57
|
+
name: block.type,
|
|
58
|
+
label: toNiceName(block.type),
|
|
59
|
+
icon: "question-mark",
|
|
60
|
+
thumbnail: "https://via.placeholder.com/150",
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
function setHoveredBlockId(id: string | null) {
|
|
66
|
+
if (isSorting.value) return;
|
|
67
|
+
hoveredBlockId.value = id;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function initSortable() {
|
|
71
|
+
const sortableListWrapper = document.getElementById("page-blocks-list");
|
|
72
|
+
if (!sortableListWrapper) return;
|
|
73
|
+
new Sortable(sortableListWrapper, {
|
|
74
|
+
animation: 150,
|
|
75
|
+
ghostClass: "sortable-ghost",
|
|
76
|
+
chosenClass: "sortable-chosen",
|
|
77
|
+
dragClass: "sortable-drag",
|
|
78
|
+
onStart: () => {
|
|
79
|
+
isSorting.value = true;
|
|
80
|
+
},
|
|
81
|
+
onEnd: (event: any) => {
|
|
82
|
+
isSorting.value = false;
|
|
83
|
+
const { oldIndex, newIndex } = event;
|
|
84
|
+
if (oldIndex !== newIndex) {
|
|
85
|
+
const movedBlock = pageData.value?.[props.blocksKey]?.splice(oldIndex, 1)[0];
|
|
86
|
+
pageData.value?.[props.blocksKey]?.splice(newIndex, 0, movedBlock);
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
onMounted(() => {
|
|
93
|
+
initSortable();
|
|
94
|
+
});
|
|
95
|
+
</script>
|
|
96
|
+
|
|
97
|
+
<style scoped lang="scss">
|
|
98
|
+
.block-item {
|
|
99
|
+
&.hovered-block {
|
|
100
|
+
@apply bg-blue-100 text-blue-600;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
</style>
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div id="page-builder-sidebar" class="page-builder-sidebar">
|
|
3
|
+
<!-- Page settings -->
|
|
4
|
+
<PageSettings v-if="showPageSettings" v-model="pageData" :editable="editable" @close="showPageSettings = false" />
|
|
5
|
+
<!-- Active section-->
|
|
6
|
+
<div v-else-if="activeBlock">
|
|
7
|
+
<!-- back header -->
|
|
8
|
+
<div class="flex items-start justify-between border-b bg-white p-5">
|
|
9
|
+
<div>
|
|
10
|
+
<button
|
|
11
|
+
class="cursor-pointer text-sm text-zinc-500 hover:text-zinc-900 hover:underline"
|
|
12
|
+
@click="activeBlock = null"
|
|
13
|
+
>
|
|
14
|
+
← Back
|
|
15
|
+
</button>
|
|
16
|
+
<h4 class="mt-1 text-lg font-bold">{{ computedActiveBlock.label || computedActiveBlock.type }}</h4>
|
|
17
|
+
</div>
|
|
18
|
+
<!-- delete section button -->
|
|
19
|
+
<button
|
|
20
|
+
v-if="activeBlock && editable"
|
|
21
|
+
class="inline-flex size-7 cursor-pointer items-center justify-center rounded-md border bg-zinc-100 text-zinc-500 hover:border-red-200 hover:bg-red-100 hover:text-red-600"
|
|
22
|
+
title="Delete block"
|
|
23
|
+
@click="handleDeleteBlock"
|
|
24
|
+
>
|
|
25
|
+
<TrashIcon class="size-4" />
|
|
26
|
+
</button>
|
|
27
|
+
</div>
|
|
28
|
+
<BlockEditorFields
|
|
29
|
+
v-model="computedActiveBlock.data"
|
|
30
|
+
:fields="computedActiveBlock.fields"
|
|
31
|
+
:editable="editable"
|
|
32
|
+
/>
|
|
33
|
+
</div>
|
|
34
|
+
<!-- Add block menu -->
|
|
35
|
+
<div v-else-if="showAddBlockMenu">
|
|
36
|
+
<div class="flex items-center justify-between border-b bg-white p-5">
|
|
37
|
+
<div>
|
|
38
|
+
<button
|
|
39
|
+
class="cursor-pointer text-sm text-zinc-500 hover:text-zinc-900 hover:underline"
|
|
40
|
+
@click="showAddBlockMenu = false"
|
|
41
|
+
>
|
|
42
|
+
← Back
|
|
43
|
+
</button>
|
|
44
|
+
<h4 class="mt-1 text-lg font-bold">Add block</h4>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
<!-- Blocks list -->
|
|
48
|
+
<BlockBrowser />
|
|
49
|
+
</div>
|
|
50
|
+
<!-- No active block -->
|
|
51
|
+
<div v-else>
|
|
52
|
+
<div class="flex items-center justify-between border-b bg-white p-5">
|
|
53
|
+
<h4 class="text-lg font-bold">Blocks ({{ pageData?.[blocksKey]?.length }})</h4>
|
|
54
|
+
<button
|
|
55
|
+
v-if="editable"
|
|
56
|
+
class="inline-flex items-center gap-1.5 rounded-md border bg-zinc-50 px-3 py-2 text-xs text-zinc-500 hover:border-zinc-400 hover:text-zinc-900 active:border-blue-600 active:bg-blue-50 active:text-blue-600"
|
|
57
|
+
title="Add block"
|
|
58
|
+
@click="handleShowAddBlockMenu"
|
|
59
|
+
>
|
|
60
|
+
<span>Add block</span>
|
|
61
|
+
<PlusIcon class="size-3" />
|
|
62
|
+
</button>
|
|
63
|
+
</div>
|
|
64
|
+
<!-- Blocks list -->
|
|
65
|
+
<PageBlockList
|
|
66
|
+
v-model="pageData"
|
|
67
|
+
v-model:hoveredBlockId="hoveredBlockId"
|
|
68
|
+
:editable="editable"
|
|
69
|
+
:blocksKey="blocksKey"
|
|
70
|
+
:settingsKey="settingsKey"
|
|
71
|
+
@block-click="handleBlockClick"
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
</template>
|
|
76
|
+
|
|
77
|
+
<script setup lang="ts">
|
|
78
|
+
import { computed } from "vue";
|
|
79
|
+
import { pageBuilderBlocks } from "../../util/registry";
|
|
80
|
+
import type { Block } from "../../types/Block";
|
|
81
|
+
import { toCamelCase } from "../../util/helpers";
|
|
82
|
+
import BlockBrowser from "../BlockBrowser/BlockBrowser.vue";
|
|
83
|
+
import BlockEditorFields from "../BlockEditorFields/BlockEditorFields.vue";
|
|
84
|
+
import PageBlockList from "../PageBlockList/PageBlockList.vue";
|
|
85
|
+
import PageSettings from "../PageSettings/PageSettings.vue";
|
|
86
|
+
import { TrashIcon, PlusIcon } from "@heroicons/vue/24/outline";
|
|
87
|
+
// Models
|
|
88
|
+
const pageData = defineModel<any>();
|
|
89
|
+
const activeBlock = defineModel<any>("activeBlock");
|
|
90
|
+
const hoveredBlockId = defineModel<string | null>("hoveredBlockId");
|
|
91
|
+
const showPageSettings = defineModel<boolean>("showPageSettings");
|
|
92
|
+
const showAddBlockMenu = defineModel<boolean>("showAddBlockMenu");
|
|
93
|
+
|
|
94
|
+
// Editable prop
|
|
95
|
+
const props = withDefaults(
|
|
96
|
+
defineProps<{
|
|
97
|
+
editable?: boolean;
|
|
98
|
+
blocksKey?: string;
|
|
99
|
+
settingsKey?: string;
|
|
100
|
+
}>(),
|
|
101
|
+
{
|
|
102
|
+
editable: true,
|
|
103
|
+
blocksKey: "blocks",
|
|
104
|
+
settingsKey: "settings",
|
|
105
|
+
}
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// Reactive merged block data that syncs with activeBlock
|
|
109
|
+
const computedActiveBlock = computed(() => {
|
|
110
|
+
if (!activeBlock.value) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Get the block type from activeBlock (e.g., "heroSection", "faqSection")
|
|
115
|
+
const blockType = activeBlock.value.type;
|
|
116
|
+
if (!blockType) {
|
|
117
|
+
return activeBlock.value;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Find the corresponding block in the registry
|
|
121
|
+
const registryBlock = pageBuilderBlocks.value[toCamelCase(blockType)];
|
|
122
|
+
|
|
123
|
+
if (!registryBlock) {
|
|
124
|
+
// If no registry block found, return activeBlock as-is
|
|
125
|
+
return activeBlock.value;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Find the actual block object in pageData[blocksKey] to ensure we're updating the source
|
|
129
|
+
const blockId = activeBlock.value.id;
|
|
130
|
+
const actualBlock = pageData.value?.[props.blocksKey]?.find((b: any) => b.id === blockId) || activeBlock.value;
|
|
131
|
+
|
|
132
|
+
// Create a proxy that nests all instance data under a 'data' property
|
|
133
|
+
// Registry metadata (name, label, icon, directory, fields) stays at the top level
|
|
134
|
+
return new Proxy({} as any, {
|
|
135
|
+
get(_, prop) {
|
|
136
|
+
// If accessing 'data', return a proxy to the actual block object
|
|
137
|
+
if (prop === "data") {
|
|
138
|
+
return new Proxy(actualBlock, {
|
|
139
|
+
get(target, dataProp) {
|
|
140
|
+
return target[dataProp as keyof typeof target];
|
|
141
|
+
},
|
|
142
|
+
set(target, dataProp, value) {
|
|
143
|
+
target[dataProp as keyof typeof target] = value;
|
|
144
|
+
// Also update activeBlock.value to keep it in sync
|
|
145
|
+
if (activeBlock.value) {
|
|
146
|
+
activeBlock.value[dataProp as keyof typeof activeBlock.value] = value;
|
|
147
|
+
}
|
|
148
|
+
return true;
|
|
149
|
+
},
|
|
150
|
+
has(target, dataProp) {
|
|
151
|
+
return dataProp in target;
|
|
152
|
+
},
|
|
153
|
+
ownKeys(target) {
|
|
154
|
+
return Object.keys(target);
|
|
155
|
+
},
|
|
156
|
+
getOwnPropertyDescriptor(target, dataProp) {
|
|
157
|
+
return Object.getOwnPropertyDescriptor(target, dataProp);
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
// For all other properties, return registry metadata
|
|
162
|
+
return registryBlock[prop as keyof typeof registryBlock];
|
|
163
|
+
},
|
|
164
|
+
set(_, prop) {
|
|
165
|
+
// Only allow setting registry metadata properties (though this shouldn't happen)
|
|
166
|
+
// Instance data should be set via the 'data' property
|
|
167
|
+
if (prop in registryBlock) {
|
|
168
|
+
// Silently ignore attempts to set registry properties
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
},
|
|
173
|
+
has(_, prop) {
|
|
174
|
+
// Check if property exists in registry or if it's 'data'
|
|
175
|
+
return prop === "data" || prop in registryBlock;
|
|
176
|
+
},
|
|
177
|
+
ownKeys() {
|
|
178
|
+
// Return registry keys plus 'data'
|
|
179
|
+
const registryKeys = Object.keys(registryBlock);
|
|
180
|
+
return ["data", ...registryKeys];
|
|
181
|
+
},
|
|
182
|
+
getOwnPropertyDescriptor(_, prop) {
|
|
183
|
+
if (prop === "data") {
|
|
184
|
+
return {
|
|
185
|
+
enumerable: true,
|
|
186
|
+
configurable: true,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (prop in registryBlock) {
|
|
190
|
+
return Object.getOwnPropertyDescriptor(registryBlock, prop);
|
|
191
|
+
}
|
|
192
|
+
return undefined;
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
function handleBlockClick(block: Block) {
|
|
198
|
+
activeBlock.value = block;
|
|
199
|
+
hoveredBlockId.value = null;
|
|
200
|
+
showAddBlockMenu.value = false;
|
|
201
|
+
showPageSettings.value = false;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function handleShowAddBlockMenu() {
|
|
205
|
+
if (!props.editable) return;
|
|
206
|
+
showAddBlockMenu.value = true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function handleDeleteBlock() {
|
|
210
|
+
if (!props.editable) return;
|
|
211
|
+
// confirm delete block (native browser confirm)
|
|
212
|
+
const blockName = computedActiveBlock.value?.label || computedActiveBlock.value?.type;
|
|
213
|
+
const confirm = window.confirm(
|
|
214
|
+
`Are you sure you want to delete this ${blockName} block?\n\nThis action cannot be undone.`
|
|
215
|
+
);
|
|
216
|
+
if (!confirm) return;
|
|
217
|
+
|
|
218
|
+
// Find the index of the block by matching its id
|
|
219
|
+
const blockId = activeBlock.value?.id;
|
|
220
|
+
if (!blockId || !pageData.value?.[props.blocksKey]) return;
|
|
221
|
+
|
|
222
|
+
const blocks = pageData.value[props.blocksKey];
|
|
223
|
+
const blockIndex = blocks.findIndex((block: any) => block.id === blockId);
|
|
224
|
+
|
|
225
|
+
if (blockIndex !== -1) {
|
|
226
|
+
blocks.splice(blockIndex, 1);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
activeBlock.value = null;
|
|
230
|
+
hoveredBlockId.value = null;
|
|
231
|
+
showAddBlockMenu.value = false;
|
|
232
|
+
}
|
|
233
|
+
</script>
|
|
234
|
+
|
|
235
|
+
<style scoped lang="scss">
|
|
236
|
+
.page-builder-sidebar {
|
|
237
|
+
height: 100%;
|
|
238
|
+
overflow-y: auto;
|
|
239
|
+
background: #fff;
|
|
240
|
+
}
|
|
241
|
+
</style>
|