vue-wswg-editor 0.0.10 → 0.0.11
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 +493 -20
- package/dist/style.css +1 -1
- package/dist/vue-wswg-editor.es.js +2249 -1782
- package/package.json +15 -8
- package/src/assets/styles/_mixins.scss +12 -17
- package/src/components/BlockEditorFieldNode/BlockEditorFieldNode.vue +39 -4
- package/src/components/BlockEditorFields/BlockEditorFields.vue +12 -3
- package/src/components/BlockImageFieldNode/BlockImageNode.vue +373 -0
- package/src/components/PageBuilderSidebar/PageBuilderSidebar.vue +1 -5
- package/src/components/PageRenderer/PageRenderer.vue +40 -41
- package/src/components/PageRenderer/blockModules.ts +32 -3
- package/src/components/PageRenderer/layoutModules.ts +32 -3
- package/src/components/WswgJsonEditor/WswgJsonEditor.vue +230 -62
- package/src/index.ts +2 -3
- package/src/style.css +10 -3
- package/src/util/fieldConfig.ts +22 -0
- package/src/util/helpers.ts +1 -1
- package/src/util/registry.ts +30 -23
- package/src/util/validation.ts +178 -23
- package/types/vue-wswg-editor.d.ts +161 -0
- package/dist/types/components/AddBlockItem/AddBlockItem.vue.d.ts +0 -6
- package/dist/types/components/BlockBrowser/BlockBrowser.vue.d.ts +0 -2
- package/dist/types/components/BlockComponent/BlockComponent.vue.d.ts +0 -15
- package/dist/types/components/BlockEditorFieldNode/BlockEditorFieldNode.vue.d.ts +0 -15
- package/dist/types/components/BlockEditorFields/BlockEditorFields.vue.d.ts +0 -15
- package/dist/types/components/BlockMarginFieldNode/BlockMarginNode.vue.d.ts +0 -23
- package/dist/types/components/BlockRepeaterFieldNode/BlockRepeaterNode.vue.d.ts +0 -15
- package/dist/types/components/BrowserNavigation/BrowserNavigation.vue.d.ts +0 -5
- package/dist/types/components/EmptyState/EmptyState.vue.d.ts +0 -15
- package/dist/types/components/PageBlockList/PageBlockList.vue.d.ts +0 -19
- package/dist/types/components/PageBuilderSidebar/PageBuilderSidebar.vue.d.ts +0 -35
- package/dist/types/components/PageBuilderToolbar/PageBuilderToolbar.vue.d.ts +0 -28
- package/dist/types/components/PageRenderer/PageRenderer.vue.d.ts +0 -13
- package/dist/types/components/PageRenderer/blockModules.d.ts +0 -1
- package/dist/types/components/PageRenderer/layoutModules.d.ts +0 -1
- package/dist/types/components/PageSettings/PageSettings.vue.d.ts +0 -17
- package/dist/types/components/ResizeHandle/ResizeHandle.vue.d.ts +0 -6
- package/dist/types/components/WswgJsonEditor/WswgJsonEditor.test.d.ts +0 -1
- package/dist/types/components/WswgJsonEditor/WswgJsonEditor.vue.d.ts +0 -36
- package/dist/types/index.d.ts +0 -8
- package/dist/types/util/fieldConfig.d.ts +0 -83
- package/dist/types/util/helpers.d.ts +0 -28
- package/dist/types/util/registry.d.ts +0 -20
- package/dist/types/util/validation.d.ts +0 -15
- package/dist/types/vite-plugin.d.ts +0 -9
- package/dist/vite-plugin.js +0 -76
package/package.json
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vue-wswg-editor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.11",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"main": "
|
|
6
|
-
"module": "
|
|
7
|
-
"typings": "
|
|
5
|
+
"main": "./dist/vue-wswg-editor.es.js",
|
|
6
|
+
"module": "./dist/vue-wswg-editor.es.js",
|
|
7
|
+
"typings": "./dist/types/index.d.ts",
|
|
8
|
+
"types": "./dist/types/index.d.ts",
|
|
8
9
|
"exports": {
|
|
9
10
|
".": {
|
|
10
|
-
"import": "./
|
|
11
|
-
"require": "./
|
|
12
|
-
"types": "./
|
|
11
|
+
"import": "./dist/vue-wswg-editor.es.js",
|
|
12
|
+
"require": "./dist/vue-wswg-editor.es.js",
|
|
13
|
+
"types": "./dist/types/index.d.ts"
|
|
13
14
|
},
|
|
14
15
|
"./PageRenderer": {
|
|
15
16
|
"import": "./src/components/PageRenderer/PageRenderer.vue",
|
|
@@ -24,12 +25,16 @@
|
|
|
24
25
|
},
|
|
25
26
|
"files": [
|
|
26
27
|
"dist",
|
|
27
|
-
"src"
|
|
28
|
+
"src",
|
|
29
|
+
"types"
|
|
28
30
|
],
|
|
29
31
|
"scripts": {
|
|
30
32
|
"dev": "concurrently \"vite build --watch\" \"vite build --config vite-plugin.config.ts --watch\"",
|
|
31
33
|
"build": "vite build && vite build --config vite-plugin.config.ts && vue-tsc --declaration --emitDeclarationOnly",
|
|
32
34
|
"preview": "vite preview",
|
|
35
|
+
"docs:dev": "vitepress dev docs",
|
|
36
|
+
"docs:build": "vitepress build docs",
|
|
37
|
+
"docs:preview": "vitepress preview docs",
|
|
33
38
|
"test:unit": "vitest",
|
|
34
39
|
"test:unit:ui": "vitest --ui",
|
|
35
40
|
"test:unit:ci": "vitest run",
|
|
@@ -82,6 +87,8 @@
|
|
|
82
87
|
"tailwindcss": "^3.4.10",
|
|
83
88
|
"typescript": "~5.8.3",
|
|
84
89
|
"vite": "^5.4.19",
|
|
90
|
+
"vitepress": "^1.6.4",
|
|
91
|
+
"vitepress-demo-plugin": "^1.5.1",
|
|
85
92
|
"vitest": "^2.1.9",
|
|
86
93
|
"vue-tsc": "^2.2.12",
|
|
87
94
|
"yup": "^1.7.1"
|
|
@@ -3,64 +3,59 @@
|
|
|
3
3
|
// Usage: @include block-margin-classes;
|
|
4
4
|
|
|
5
5
|
@mixin block-margin-classes {
|
|
6
|
-
// Margin size variables
|
|
7
|
-
$sm-size: 2rem;
|
|
8
|
-
$md-size: 4rem;
|
|
9
|
-
$lg-size: 6rem;
|
|
10
|
-
|
|
11
6
|
// Top margin classes
|
|
12
7
|
&.margin-top-sm {
|
|
13
|
-
padding-top:
|
|
8
|
+
padding-top: var(--block-margin-sm, 2rem);
|
|
14
9
|
|
|
15
10
|
&::before {
|
|
16
11
|
top: 0;
|
|
17
|
-
height:
|
|
12
|
+
height: var(--block-margin-sm, 2rem);
|
|
18
13
|
}
|
|
19
14
|
}
|
|
20
15
|
|
|
21
16
|
&.margin-top-md {
|
|
22
|
-
padding-top:
|
|
17
|
+
padding-top: var(--block-margin-md, 4rem);
|
|
23
18
|
|
|
24
19
|
&::before {
|
|
25
20
|
top: 0;
|
|
26
|
-
height:
|
|
21
|
+
height: var(--block-margin-md, 4rem);
|
|
27
22
|
}
|
|
28
23
|
}
|
|
29
24
|
|
|
30
25
|
&.margin-top-lg {
|
|
31
|
-
padding-top:
|
|
26
|
+
padding-top: var(--block-margin-lg, 6rem);
|
|
32
27
|
|
|
33
28
|
&::before {
|
|
34
29
|
top: 0;
|
|
35
|
-
height:
|
|
30
|
+
height: var(--block-margin-lg, 6rem);
|
|
36
31
|
}
|
|
37
32
|
}
|
|
38
33
|
|
|
39
34
|
// Bottom margin classes
|
|
40
35
|
&.margin-bottom-sm {
|
|
41
|
-
padding-bottom:
|
|
36
|
+
padding-bottom: var(--block-margin-sm, 2rem);
|
|
42
37
|
|
|
43
38
|
&::after {
|
|
44
39
|
bottom: 0;
|
|
45
|
-
height:
|
|
40
|
+
height: var(--block-margin-sm, 2rem);
|
|
46
41
|
}
|
|
47
42
|
}
|
|
48
43
|
|
|
49
44
|
&.margin-bottom-md {
|
|
50
|
-
padding-bottom:
|
|
45
|
+
padding-bottom: var(--block-margin-md, 4rem);
|
|
51
46
|
|
|
52
47
|
&::after {
|
|
53
48
|
bottom: 0;
|
|
54
|
-
height:
|
|
49
|
+
height: var(--block-margin-md, 4rem);
|
|
55
50
|
}
|
|
56
51
|
}
|
|
57
52
|
|
|
58
53
|
&.margin-bottom-lg {
|
|
59
|
-
padding-bottom:
|
|
54
|
+
padding-bottom: var(--block-margin-lg, 6rem);
|
|
60
55
|
|
|
61
56
|
&::after {
|
|
62
57
|
bottom: 0;
|
|
63
|
-
height:
|
|
58
|
+
height: var(--block-margin-lg, 6rem);
|
|
64
59
|
}
|
|
65
60
|
}
|
|
66
61
|
|
|
@@ -62,7 +62,9 @@
|
|
|
62
62
|
|
|
63
63
|
<!-- Range input -->
|
|
64
64
|
<div v-else-if="fieldConfig.type === 'range'" class="flex items-center gap-2">
|
|
65
|
-
<span class="rounded-full bg-zinc-100 px-2 py-1 text-sm font-bold text-zinc-600"
|
|
65
|
+
<span class="rounded-full bg-zinc-100 px-2 py-1 text-sm font-bold text-zinc-600"
|
|
66
|
+
>{{ fieldValue }}{{ fieldConfig.valueSuffix || "" }}</span
|
|
67
|
+
>
|
|
66
68
|
<input
|
|
67
69
|
v-model="fieldValue"
|
|
68
70
|
type="range"
|
|
@@ -139,6 +141,23 @@
|
|
|
139
141
|
/>
|
|
140
142
|
</div>
|
|
141
143
|
|
|
144
|
+
<!-- Object Input -->
|
|
145
|
+
<div v-else-if="fieldConfig.type === 'object' && fieldConfig.objectFields">
|
|
146
|
+
<div class="mt-3 border-t border-gray-300 pt-3">
|
|
147
|
+
<BlockEditorFields
|
|
148
|
+
v-model="objectFieldValue"
|
|
149
|
+
:fields="fieldConfig.objectFields"
|
|
150
|
+
:editable="editable"
|
|
151
|
+
:nested="true"
|
|
152
|
+
/>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
<!-- Image -->
|
|
157
|
+
<div v-else-if="fieldConfig.type === 'image'">
|
|
158
|
+
<BlockImageNode v-model="fieldValue" :fieldConfig="fieldConfig" :fieldName="fieldName" :editable="editable" />
|
|
159
|
+
</div>
|
|
160
|
+
|
|
142
161
|
<!-- Margin -->
|
|
143
162
|
<div v-else-if="fieldConfig.type === 'margin'">
|
|
144
163
|
<BlockMarginNode v-model="fieldValue" :fieldConfig="fieldConfig" :fieldName="fieldName" :editable="editable" />
|
|
@@ -169,7 +188,10 @@ import { ref, computed, watch } from "vue";
|
|
|
169
188
|
import type { EditorFieldConfig } from "../../util/fieldConfig";
|
|
170
189
|
import BlockRepeaterNode from "../BlockRepeaterFieldNode/BlockRepeaterNode.vue";
|
|
171
190
|
import BlockMarginNode from "../BlockMarginFieldNode/BlockMarginNode.vue";
|
|
191
|
+
import BlockImageNode from "../BlockImageFieldNode/BlockImageNode.vue";
|
|
192
|
+
import BlockEditorFields from "../BlockEditorFields/BlockEditorFields.vue";
|
|
172
193
|
import { InformationCircleIcon } from "@heroicons/vue/24/outline";
|
|
194
|
+
import { validateField as validateFieldUtil } from "../../util/validation";
|
|
173
195
|
import * as yup from "yup";
|
|
174
196
|
|
|
175
197
|
const fieldValue = defineModel<any>();
|
|
@@ -202,6 +224,20 @@ const checkboxValues = computed({
|
|
|
202
224
|
},
|
|
203
225
|
});
|
|
204
226
|
|
|
227
|
+
// Computed property for object field values - ensure it's always an object
|
|
228
|
+
const objectFieldValue = computed({
|
|
229
|
+
get: () => {
|
|
230
|
+
// Ensure fieldValue is always an object for object fields
|
|
231
|
+
if (typeof fieldValue.value !== "object" || fieldValue.value === null || Array.isArray(fieldValue.value)) {
|
|
232
|
+
return {};
|
|
233
|
+
}
|
|
234
|
+
return fieldValue.value;
|
|
235
|
+
},
|
|
236
|
+
set: (newValue: Record<string, any>) => {
|
|
237
|
+
fieldValue.value = newValue;
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
|
|
205
241
|
// Computed property to handle object/array conversion for text inputs
|
|
206
242
|
const textFieldValue = computed({
|
|
207
243
|
get: () => {
|
|
@@ -257,12 +293,12 @@ const canClearFieldValue = computed(() => {
|
|
|
257
293
|
* minItems // For repeater fields
|
|
258
294
|
* maxItems // For repeater fields
|
|
259
295
|
* required // For all fields
|
|
296
|
+
* validator // Custom validation function
|
|
260
297
|
*/
|
|
261
298
|
|
|
262
299
|
async function validateField(): Promise<void> {
|
|
263
|
-
if (!props.fieldConfig.validator) return;
|
|
264
300
|
try {
|
|
265
|
-
const result = await
|
|
301
|
+
const result = await validateFieldUtil(fieldValue.value, props.fieldConfig);
|
|
266
302
|
// True = valid, false = invalid, string = error message
|
|
267
303
|
if (result === true) {
|
|
268
304
|
validationErrorMessage.value = null;
|
|
@@ -280,7 +316,6 @@ async function validateField(): Promise<void> {
|
|
|
280
316
|
function clearFieldValue(): void {
|
|
281
317
|
fieldValue.value = null;
|
|
282
318
|
validationErrorMessage.value = null;
|
|
283
|
-
// validateField();
|
|
284
319
|
}
|
|
285
320
|
|
|
286
321
|
// Watch the field value and validate it
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="section-editor-fields">
|
|
3
3
|
<!-- Field group tabs-->
|
|
4
|
-
<div
|
|
4
|
+
<div
|
|
5
|
+
v-if="editorFieldGroups.length"
|
|
6
|
+
class="field-group-tabs flex gap-2 border-b border-gray-300"
|
|
7
|
+
:class="nested ? 'px-0 pt-0' : 'px-5 pt-3'"
|
|
8
|
+
>
|
|
5
9
|
<button
|
|
6
10
|
v-for="fieldGroupName in editorFieldGroups"
|
|
7
11
|
:key="`fg_${fieldGroupName}`"
|
|
@@ -14,7 +18,11 @@
|
|
|
14
18
|
</div>
|
|
15
19
|
|
|
16
20
|
<!-- Fields -->
|
|
17
|
-
<div
|
|
21
|
+
<div
|
|
22
|
+
v-if="blockData && Object.keys(editorFields).length > 0"
|
|
23
|
+
class="flex flex-col gap-3"
|
|
24
|
+
:class="nested ? 'p-0' : 'p-5'"
|
|
25
|
+
>
|
|
18
26
|
<div v-for="(fieldConfig, fieldName) in editorFields" :key="fieldName" class="prop-field">
|
|
19
27
|
<BlockEditorFieldNode
|
|
20
28
|
v-model="blockData[fieldName]"
|
|
@@ -26,7 +34,7 @@
|
|
|
26
34
|
</div>
|
|
27
35
|
|
|
28
36
|
<!-- No fields -->
|
|
29
|
-
<div v-else class="p-5">
|
|
37
|
+
<div v-else :class="nested ? 'p-0' : 'p-5'">
|
|
30
38
|
<div class="rounded-lg bg-zinc-100 px-4 py-3 text-sm font-medium text-zinc-600">
|
|
31
39
|
<p>
|
|
32
40
|
{{ isLayoutBlock ? "No settings available for this layout." : "No options available for this block." }}
|
|
@@ -47,6 +55,7 @@ const { fields, editable } = defineProps<{
|
|
|
47
55
|
fields?: Record<string, EditorFieldConfig>;
|
|
48
56
|
editable: boolean;
|
|
49
57
|
isLayoutBlock?: boolean;
|
|
58
|
+
nested?: boolean;
|
|
50
59
|
}>();
|
|
51
60
|
|
|
52
61
|
const editorFields = computed(() => {
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="image-upload-field mb-2">
|
|
3
|
+
<!-- Image Preview with Upload Area (Compact Layout) -->
|
|
4
|
+
<div
|
|
5
|
+
v-if="imageUrl && editable"
|
|
6
|
+
class="flex gap-2"
|
|
7
|
+
@dragover.prevent="handleDragOver"
|
|
8
|
+
@dragleave.prevent="handleDragLeave"
|
|
9
|
+
@drop.prevent="handleDrop"
|
|
10
|
+
>
|
|
11
|
+
<!-- Hidden file input -->
|
|
12
|
+
<input
|
|
13
|
+
ref="fileInputRef"
|
|
14
|
+
type="file"
|
|
15
|
+
accept="image/*"
|
|
16
|
+
class="sr-only"
|
|
17
|
+
:disabled="isUploading || !editable"
|
|
18
|
+
@change="handleFileSelect"
|
|
19
|
+
/>
|
|
20
|
+
|
|
21
|
+
<!-- Thumbnail Preview -->
|
|
22
|
+
<div class="relative shrink-0">
|
|
23
|
+
<img :src="imageUrl" alt="Uploaded image" class="size-16 rounded border border-gray-300 object-cover" />
|
|
24
|
+
<button
|
|
25
|
+
type="button"
|
|
26
|
+
class="absolute -right-1 -top-1 rounded-full bg-red-500 p-0.5 text-white shadow-sm hover:bg-red-600"
|
|
27
|
+
title="Remove image"
|
|
28
|
+
@click.stop="removeImage"
|
|
29
|
+
>
|
|
30
|
+
<svg
|
|
31
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
32
|
+
fill="none"
|
|
33
|
+
viewBox="0 0 24 24"
|
|
34
|
+
stroke-width="2.5"
|
|
35
|
+
stroke="currentColor"
|
|
36
|
+
class="size-3"
|
|
37
|
+
>
|
|
38
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
39
|
+
</svg>
|
|
40
|
+
</button>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<!-- Upload Button -->
|
|
44
|
+
<button
|
|
45
|
+
type="button"
|
|
46
|
+
:class="[
|
|
47
|
+
'flex-1rounded w-full rounded border px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors',
|
|
48
|
+
isUploading && 'pointer-events-none opacity-50',
|
|
49
|
+
isDragging && 'border-blue-500 bg-blue-50',
|
|
50
|
+
!isDragging && 'border-gray-300 bg-white hover:bg-gray-50',
|
|
51
|
+
]"
|
|
52
|
+
@click="triggerFileInput"
|
|
53
|
+
@dragover.prevent="handleDragOver"
|
|
54
|
+
@dragleave.prevent="handleDragLeave"
|
|
55
|
+
@drop.prevent="handleDrop"
|
|
56
|
+
>
|
|
57
|
+
<span v-if="!isUploading" class="flex items-center justify-center gap-1.5">
|
|
58
|
+
<svg
|
|
59
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
60
|
+
fill="none"
|
|
61
|
+
viewBox="0 0 24 24"
|
|
62
|
+
stroke-width="1.5"
|
|
63
|
+
stroke="currentColor"
|
|
64
|
+
class="size-3.5"
|
|
65
|
+
>
|
|
66
|
+
<path
|
|
67
|
+
stroke-linecap="round"
|
|
68
|
+
stroke-linejoin="round"
|
|
69
|
+
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
|
|
70
|
+
/>
|
|
71
|
+
</svg>
|
|
72
|
+
Replace image
|
|
73
|
+
</span>
|
|
74
|
+
<span v-else class="flex items-center justify-center gap-1.5">
|
|
75
|
+
<svg
|
|
76
|
+
class="size-3.5 animate-spin text-blue-500"
|
|
77
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
78
|
+
fill="none"
|
|
79
|
+
viewBox="0 0 24 24"
|
|
80
|
+
>
|
|
81
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
82
|
+
<path
|
|
83
|
+
class="opacity-75"
|
|
84
|
+
fill="currentColor"
|
|
85
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
86
|
+
></path>
|
|
87
|
+
</svg>
|
|
88
|
+
Uploading...
|
|
89
|
+
</span>
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<!-- Upload Area (No Image) -->
|
|
94
|
+
<div
|
|
95
|
+
v-else-if="editable"
|
|
96
|
+
:class="[
|
|
97
|
+
'relative cursor-pointer rounded border border-dashed transition-colors',
|
|
98
|
+
isDragging
|
|
99
|
+
? 'border-blue-500 bg-blue-50'
|
|
100
|
+
: 'border-gray-300 bg-gray-50 hover:border-gray-400 hover:bg-gray-100',
|
|
101
|
+
isUploading && 'pointer-events-none opacity-50',
|
|
102
|
+
]"
|
|
103
|
+
@click="triggerFileInput"
|
|
104
|
+
@dragover.prevent="handleDragOver"
|
|
105
|
+
@dragleave.prevent="handleDragLeave"
|
|
106
|
+
@drop.prevent="handleDrop"
|
|
107
|
+
>
|
|
108
|
+
<input
|
|
109
|
+
ref="fileInputRef"
|
|
110
|
+
type="file"
|
|
111
|
+
accept="image/*"
|
|
112
|
+
class="sr-only"
|
|
113
|
+
:disabled="isUploading || !editable"
|
|
114
|
+
@change="handleFileSelect"
|
|
115
|
+
/>
|
|
116
|
+
|
|
117
|
+
<div class="flex items-center justify-center gap-2 p-3">
|
|
118
|
+
<!-- Upload Icon -->
|
|
119
|
+
<svg
|
|
120
|
+
v-if="!isUploading"
|
|
121
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
122
|
+
fill="none"
|
|
123
|
+
viewBox="0 0 24 24"
|
|
124
|
+
stroke-width="1.5"
|
|
125
|
+
stroke="currentColor"
|
|
126
|
+
class="size-4 text-gray-400"
|
|
127
|
+
>
|
|
128
|
+
<path
|
|
129
|
+
stroke-linecap="round"
|
|
130
|
+
stroke-linejoin="round"
|
|
131
|
+
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
|
|
132
|
+
/>
|
|
133
|
+
</svg>
|
|
134
|
+
|
|
135
|
+
<!-- Loading Spinner -->
|
|
136
|
+
<svg
|
|
137
|
+
v-else
|
|
138
|
+
class="size-4 animate-spin text-blue-500"
|
|
139
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
140
|
+
fill="none"
|
|
141
|
+
viewBox="0 0 24 24"
|
|
142
|
+
>
|
|
143
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
144
|
+
<path
|
|
145
|
+
class="opacity-75"
|
|
146
|
+
fill="currentColor"
|
|
147
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
148
|
+
></path>
|
|
149
|
+
</svg>
|
|
150
|
+
|
|
151
|
+
<!-- Upload Text -->
|
|
152
|
+
<span v-if="!isUploading" class="text-xs font-medium text-gray-700">Click to upload image</span>
|
|
153
|
+
<span v-else class="text-xs font-medium text-blue-600">Uploading...</span>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<!-- Read-only display -->
|
|
158
|
+
<div v-else-if="imageUrl" class="flex items-center gap-2">
|
|
159
|
+
<img :src="imageUrl" alt="Image" class="size-16 rounded border border-gray-300 object-cover" />
|
|
160
|
+
<span class="text-xs text-gray-500">Image uploaded</span>
|
|
161
|
+
</div>
|
|
162
|
+
<div v-else class="rounded border border-gray-200 bg-gray-50 p-2 text-center text-xs text-gray-400">No image</div>
|
|
163
|
+
|
|
164
|
+
<!-- Error Message -->
|
|
165
|
+
<div v-if="errorMessage" class="mt-1.5 rounded bg-red-50 px-2 py-1 text-xs text-red-600">
|
|
166
|
+
{{ errorMessage }}
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</template>
|
|
170
|
+
|
|
171
|
+
<script setup lang="ts">
|
|
172
|
+
import { ref, watch } from "vue";
|
|
173
|
+
|
|
174
|
+
interface Props {
|
|
175
|
+
modelValue?: string | null;
|
|
176
|
+
editable?: boolean;
|
|
177
|
+
uploadUrl?: string; // Optional: custom upload endpoint URL
|
|
178
|
+
maxSizeMB?: number; // Maximum file size in MB
|
|
179
|
+
acceptedTypes?: string[]; // Accepted MIME types
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
183
|
+
modelValue: null,
|
|
184
|
+
editable: true,
|
|
185
|
+
uploadUrl: undefined,
|
|
186
|
+
maxSizeMB: 10,
|
|
187
|
+
acceptedTypes: () => ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"],
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const emit = defineEmits<{
|
|
191
|
+
"update:modelValue": [value: string | null];
|
|
192
|
+
}>();
|
|
193
|
+
|
|
194
|
+
const fileInputRef = ref<HTMLInputElement | null>(null);
|
|
195
|
+
const imageUrl = ref<string | null>(props.modelValue || null);
|
|
196
|
+
const isUploading = ref(false);
|
|
197
|
+
const isDragging = ref(false);
|
|
198
|
+
const errorMessage = ref<string | null>(null);
|
|
199
|
+
const selectedFile = ref<File | null>(null);
|
|
200
|
+
|
|
201
|
+
// Watch for external changes to modelValue
|
|
202
|
+
watch(
|
|
203
|
+
() => props.modelValue,
|
|
204
|
+
(newValue) => {
|
|
205
|
+
imageUrl.value = newValue || null;
|
|
206
|
+
}
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// Watch for internal changes and emit updates
|
|
210
|
+
watch(imageUrl, (newValue) => {
|
|
211
|
+
emit("update:modelValue", newValue);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
function triggerFileInput() {
|
|
215
|
+
if (!props.editable || isUploading.value) return;
|
|
216
|
+
fileInputRef.value?.click();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function handleFileSelect(event: Event) {
|
|
220
|
+
const target = event.target as HTMLInputElement;
|
|
221
|
+
const file = target.files?.[0];
|
|
222
|
+
if (file) {
|
|
223
|
+
processFile(file);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function handleDragOver(event: DragEvent) {
|
|
228
|
+
if (!props.editable || isUploading.value) return;
|
|
229
|
+
event.preventDefault();
|
|
230
|
+
isDragging.value = true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function handleDragLeave() {
|
|
234
|
+
isDragging.value = false;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function handleDrop(event: DragEvent) {
|
|
238
|
+
if (!props.editable || isUploading.value) return;
|
|
239
|
+
event.preventDefault();
|
|
240
|
+
isDragging.value = false;
|
|
241
|
+
|
|
242
|
+
const file = event.dataTransfer?.files[0];
|
|
243
|
+
if (file) {
|
|
244
|
+
processFile(file);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function validateFile(file: File): boolean {
|
|
249
|
+
errorMessage.value = null;
|
|
250
|
+
|
|
251
|
+
// Check file type
|
|
252
|
+
if (!props.acceptedTypes.includes(file.type)) {
|
|
253
|
+
errorMessage.value = `File type not supported. Accepted types: ${props.acceptedTypes.join(", ")}`;
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Check file size
|
|
258
|
+
const fileSizeMB = file.size / (1024 * 1024);
|
|
259
|
+
if (fileSizeMB > props.maxSizeMB) {
|
|
260
|
+
errorMessage.value = `File size exceeds ${props.maxSizeMB}MB limit`;
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function processFile(file: File) {
|
|
268
|
+
if (!validateFile(file)) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
selectedFile.value = file;
|
|
273
|
+
isUploading.value = true;
|
|
274
|
+
errorMessage.value = null;
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
// Create preview URL immediately
|
|
278
|
+
const previewUrl = URL.createObjectURL(file);
|
|
279
|
+
imageUrl.value = previewUrl;
|
|
280
|
+
|
|
281
|
+
// Upload the file
|
|
282
|
+
const uploadedUrl = await uploadFile(file);
|
|
283
|
+
|
|
284
|
+
// Replace preview URL with uploaded URL
|
|
285
|
+
if (uploadedUrl) {
|
|
286
|
+
// Clean up preview URL
|
|
287
|
+
URL.revokeObjectURL(previewUrl);
|
|
288
|
+
imageUrl.value = uploadedUrl;
|
|
289
|
+
} else {
|
|
290
|
+
// If upload fails, keep preview URL but show error
|
|
291
|
+
errorMessage.value = "Upload failed. Please try again.";
|
|
292
|
+
}
|
|
293
|
+
} catch (error) {
|
|
294
|
+
console.error("File upload error:", error);
|
|
295
|
+
errorMessage.value = error instanceof Error ? error.message : "Upload failed. Please try again.";
|
|
296
|
+
// Keep preview URL on error
|
|
297
|
+
} finally {
|
|
298
|
+
isUploading.value = false;
|
|
299
|
+
// Reset file input
|
|
300
|
+
if (fileInputRef.value) {
|
|
301
|
+
fileInputRef.value.value = "";
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function uploadFile(file: File): Promise<string | null> {
|
|
307
|
+
// If a custom upload URL is provided, use it
|
|
308
|
+
if (props.uploadUrl) {
|
|
309
|
+
return uploadToCustomEndpoint(file, props.uploadUrl);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Default implementation: Create a data URL (for basic use cases)
|
|
313
|
+
// In production, you should replace this with actual API call
|
|
314
|
+
return new Promise((resolve) => {
|
|
315
|
+
const reader = new FileReader();
|
|
316
|
+
reader.onload = (e) => {
|
|
317
|
+
const result = e.target?.result as string;
|
|
318
|
+
resolve(result);
|
|
319
|
+
};
|
|
320
|
+
reader.onerror = () => {
|
|
321
|
+
resolve(null);
|
|
322
|
+
};
|
|
323
|
+
reader.readAsDataURL(file);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function uploadToCustomEndpoint(file: File, endpoint: string): Promise<string | null> {
|
|
328
|
+
try {
|
|
329
|
+
const formData = new FormData();
|
|
330
|
+
formData.append("file", file);
|
|
331
|
+
|
|
332
|
+
const response = await fetch(endpoint, {
|
|
333
|
+
method: "POST",
|
|
334
|
+
body: formData,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
if (!response.ok) {
|
|
338
|
+
throw new Error(`Upload failed: ${response.statusText}`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const data = await response.json();
|
|
342
|
+
// Expect response to have a 'url' property
|
|
343
|
+
return data.url || data.imageUrl || data.path || null;
|
|
344
|
+
} catch (error) {
|
|
345
|
+
console.error("Custom upload error:", error);
|
|
346
|
+
throw error;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function removeImage() {
|
|
351
|
+
if (!props.editable || isUploading.value) return;
|
|
352
|
+
|
|
353
|
+
// Clean up object URL if it's a preview
|
|
354
|
+
if (imageUrl.value && imageUrl.value.startsWith("blob:")) {
|
|
355
|
+
URL.revokeObjectURL(imageUrl.value);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
imageUrl.value = null;
|
|
359
|
+
selectedFile.value = null;
|
|
360
|
+
errorMessage.value = null;
|
|
361
|
+
|
|
362
|
+
// Reset file input
|
|
363
|
+
if (fileInputRef.value) {
|
|
364
|
+
fileInputRef.value.value = "";
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
</script>
|
|
368
|
+
|
|
369
|
+
<style scoped>
|
|
370
|
+
.image-upload-field {
|
|
371
|
+
width: 100%;
|
|
372
|
+
}
|
|
373
|
+
</style>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div
|
|
2
|
+
<div class="page-builder-sidebar">
|
|
3
3
|
<!-- Toolbar -->
|
|
4
4
|
<PageBuilderToolbar
|
|
5
5
|
v-model:editorViewport="editorViewport"
|
|
@@ -256,9 +256,5 @@ $toolbar-height: 0px;
|
|
|
256
256
|
.page-builder-sidebar {
|
|
257
257
|
min-width: 300px;
|
|
258
258
|
background: #fff;
|
|
259
|
-
|
|
260
|
-
// position: sticky;
|
|
261
|
-
// top: 0;
|
|
262
|
-
// z-index: 12;
|
|
263
259
|
}
|
|
264
260
|
</style>
|