@fy-/fws-vue 0.0.916 → 0.0.918

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.
@@ -0,0 +1,348 @@
1
+ <script setup lang="ts">
2
+ import { Dialog, DialogPanel, TransitionRoot } from "@headlessui/vue";
3
+ import { ref, onMounted, reactive, onUnmounted, h, computed } from "vue";
4
+ import type { APIPaging } from "../../composables/rest";
5
+ import { useEventBus } from "../../composables/event-bus";
6
+ import {
7
+ XCircleIcon,
8
+ ChevronDoubleRightIcon,
9
+ ArrowLeftCircleIcon,
10
+ ArrowRightCircleIcon,
11
+ ChevronDoubleLeftIcon,
12
+ } from "@heroicons/vue/24/solid";
13
+ import DefaultPaging from "./DefaultPaging.vue";
14
+ import { Component } from "vue";
15
+ const isOpen = ref<boolean>(false);
16
+ const eventBus = useEventBus();
17
+ const sidePanel = ref<boolean>(true);
18
+ const props = withDefaults(
19
+ defineProps<{
20
+ id: string;
21
+ images: Array<any>;
22
+ title?: string;
23
+ getImageUrl?: Function;
24
+ getThumbnailUrl?: Function;
25
+ onOpen?: Function;
26
+ onClose?: Function;
27
+ closeIcon?: Object;
28
+ gridHeight?: number;
29
+ mode: "mason" | "grid" | "button" | "hidden";
30
+ paging?: APIPaging | undefined;
31
+ buttonText?: string;
32
+ buttonType?: string;
33
+ modelValue: number;
34
+ borderColor?: Function;
35
+ imageLoader: string;
36
+ videoComponent?: Component;
37
+ isVideo?: Function;
38
+ }>(),
39
+ {
40
+ modelValue: 0,
41
+ mode: "grid",
42
+ gridHeight: 4,
43
+ closeIcon: () => h(XCircleIcon),
44
+ images: () => [],
45
+ isVideo: (image: any) => false,
46
+ getImageUrl: (image: any) => image.image_url,
47
+ getThumbnailUrl: (image: any) => `${image.image_url}?s=250x250&m=autocrop`,
48
+ paging: undefined,
49
+ borderColor: undefined,
50
+ },
51
+ );
52
+ const emit = defineEmits(["update:modelValue"]);
53
+ const modelValue = computed({
54
+ get: () => props.modelValue,
55
+ set: (i) => {
56
+ emit("update:modelValue", i);
57
+ },
58
+ });
59
+ const setModal = (value: boolean) => {
60
+ if (value === true) {
61
+ if (props.onOpen) props.onOpen();
62
+ } else {
63
+ if (props.onClose) props.onClose();
64
+ }
65
+ isOpen.value = value;
66
+ };
67
+ const openGalleryImage = (index: number | undefined) => {
68
+ if (index === undefined) modelValue.value = 0;
69
+ else {
70
+ modelValue.value = index;
71
+ }
72
+ setModal(true);
73
+ };
74
+ const goNextImage = () => {
75
+ if (modelValue.value < props.images.length - 1) {
76
+ modelValue.value++;
77
+ } else {
78
+ modelValue.value = 0;
79
+ }
80
+ };
81
+ const goPrevImage = () => {
82
+ if (modelValue.value > 0) {
83
+ modelValue.value--;
84
+ } else {
85
+ modelValue.value = props.images.length - 1;
86
+ }
87
+ };
88
+ const modelValueSrc = computed(() => {
89
+ if (props.images.length == 0) return false;
90
+ if (props.images[modelValue.value] == undefined) return false;
91
+ return props.getImageUrl(props.images[modelValue.value]);
92
+ });
93
+ const start = reactive({ x: 0, y: 0 });
94
+
95
+ const touchStart = (event: TouchEvent) => {
96
+ const touch = event.touches[0];
97
+ start.x = touch.screenX;
98
+ start.y = touch.screenY;
99
+ };
100
+
101
+ const touchEnd = (event: TouchEvent) => {
102
+ const touch = event.changedTouches[0];
103
+ const end = { x: touch.screenX, y: touch.screenY };
104
+
105
+ const diffX = start.x - end.x;
106
+ const diffY = start.y - end.y;
107
+
108
+ if (Math.abs(diffX) > Math.abs(diffY)) {
109
+ if (diffX > 0) {
110
+ goNextImage();
111
+ } else {
112
+ goPrevImage();
113
+ }
114
+ }
115
+ };
116
+ const getBorderColor = (i: any) => {
117
+ if (props.borderColor !== undefined) {
118
+ return props.borderColor(i);
119
+ }
120
+ return "";
121
+ };
122
+ const isKeyPressed = ref<boolean>(false);
123
+ const handleKeyboardInput = (event: KeyboardEvent) => {
124
+ if (isKeyPressed.value) return;
125
+ switch (event.key) {
126
+ case "ArrowRight":
127
+ isKeyPressed.value = true;
128
+ goNextImage();
129
+ break;
130
+ case "ArrowLeft":
131
+ isKeyPressed.value = true;
132
+ goPrevImage();
133
+ break;
134
+ default:
135
+ break;
136
+ }
137
+ };
138
+ const handleKeyboardRelease = (event: KeyboardEvent) => {
139
+ if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
140
+ isKeyPressed.value = false;
141
+ }
142
+ };
143
+ onMounted(() => {
144
+ eventBus.on(`${props.id}GalleryImage`, openGalleryImage);
145
+ eventBus.on(`${props.id}Gallery`, openGalleryImage);
146
+ if (window !== undefined && !import.meta.env.SSR) {
147
+ window.addEventListener("keydown", handleKeyboardInput);
148
+ window.addEventListener("keyup", handleKeyboardRelease);
149
+ }
150
+ });
151
+ onUnmounted(() => {
152
+ eventBus.off(`${props.id}Gallery`, openGalleryImage);
153
+ eventBus.off(`${props.id}GalleryImage`, openGalleryImage);
154
+ if (window !== undefined && !import.meta.env.SSR) {
155
+ window.removeEventListener("keydown", handleKeyboardInput);
156
+ window.removeEventListener("keyup", handleKeyboardRelease);
157
+ }
158
+ });
159
+ </script>
160
+ <template>
161
+ <div>
162
+ <TransitionRoot
163
+ :show="isOpen"
164
+ as="template"
165
+ enter="duration-300 ease-out"
166
+ enter-from="opacity-0"
167
+ enter-to="opacity-100"
168
+ leave="duration-200 ease-in"
169
+ leave-from="opacity-100"
170
+ leave-to="opacity-0"
171
+ >
172
+ <Dialog
173
+ :open="isOpen"
174
+ @close="setModal"
175
+ class="fixed bg-fv-neutral-900 text-white inset-0 max-w-[100vw] overflow-y-auto overflow-x-hidden"
176
+ style="z-index: 37"
177
+ >
178
+ <DialogPanel
179
+ class="relative w-full max-w-full flex flex-col justify-center items-center"
180
+ style="z-index: 38"
181
+ >
182
+ <div class="flex flex-grow gap-4 w-full max-w-full">
183
+ <div class="flex-grow h-[100vh] flex items-center relative">
184
+ <button
185
+ class="btn w-9 h-9 rounded-full absolute top-4 left-2"
186
+ @click="setModal(false)"
187
+ style="z-index: 39"
188
+ >
189
+ <component :is="closeIcon" class="w-8 h-8" />
190
+ </button>
191
+
192
+ <div
193
+ class="flex h-[100vh] relative flex-grow items-center justify-center gap-2"
194
+ >
195
+ <div
196
+ class="hidden lg:relative lg:flex w-10 flex-shrink-0 items-center justify-center"
197
+ >
198
+ <button
199
+ class="btn p-1 rounded-full"
200
+ v-if="images.length > 1"
201
+ @click="goPrevImage()"
202
+ >
203
+ <ArrowLeftCircleIcon class="w-8 h-8" />
204
+ </button>
205
+ </div>
206
+ <div
207
+ class="flex-1 flex flex-col items-center justify-center max-w-full lg:max-w-[calc(100vw - 256px)]"
208
+ >
209
+ <div
210
+ class="flex-1 w-full max-w-full flex items-center justify-center"
211
+ >
212
+ <template
213
+ v-if="videoComponent && isVideo(images[modelValue])"
214
+ >
215
+ <ClientOnly>
216
+ <component
217
+ :is="videoComponent"
218
+ :src="isVideo(images[modelValue])"
219
+ class="shadow max-w-full h-auto object-contain max-h-[85vh]"
220
+ @touchstart="touchStart"
221
+ @touchend="touchEnd"
222
+ />
223
+ </ClientOnly>
224
+ </template>
225
+ <template v-else>
226
+ <img
227
+ class="shadow max-w-full h-auto object-contain max-h-[85vh]"
228
+ :src="modelValueSrc"
229
+ v-if="modelValueSrc"
230
+ @touchstart="touchStart"
231
+ @touchend="touchEnd"
232
+ />
233
+ </template>
234
+ </div>
235
+ <div class="flex-0 py-2 flex items-center justify-center">
236
+ <slot></slot>
237
+ </div>
238
+ </div>
239
+ <div
240
+ class="hidden lg:flex w-10 flex-shrink-0 items-center justify-center"
241
+ >
242
+ <button
243
+ class="btn w-9 h-9 rounded-full hidden lg:block absolute top-4"
244
+ :class="{
245
+ '-right-4': sidePanel,
246
+ 'right-2': !sidePanel,
247
+ }"
248
+ style="z-index: 39"
249
+ @click="() => (sidePanel = !sidePanel)"
250
+ >
251
+ <ChevronDoubleRightIcon class="w-7 h-7" v-if="sidePanel" />
252
+ <ChevronDoubleLeftIcon class="w-7 h-7" v-else />
253
+ </button>
254
+ <button
255
+ class="btn p-1 rounded-full"
256
+ @click="goNextImage()"
257
+ v-if="images.length > 1"
258
+ >
259
+ <ArrowRightCircleIcon class="w-8 h-8" />
260
+ </button>
261
+ </div>
262
+ </div>
263
+ </div>
264
+
265
+ <TransitionRoot
266
+ :show="sidePanel"
267
+ as="div"
268
+ enter="transform transition ease-in-out duration-300"
269
+ enter-from="translate-x-full"
270
+ enter-to="translate-x-0"
271
+ leave="transform transition ease-in-out duration-300"
272
+ leave-from="translate-x-0"
273
+ leave-to="translate-x-full"
274
+ class="hidden lg:block flex-shrink-0 w-64 bg-fv-neutral-800 h-[100vh] max-h-[100vh] overflow-y-auto"
275
+ >
276
+ <div v-if="paging" class="flex items-center justify-center">
277
+ <DefaultPaging :items="paging" :id="id" />
278
+ </div>
279
+ <div class="grid grid-cols-2 gap-2 p-2">
280
+ <div
281
+ v-for="i in images.length"
282
+ :key="`bg_${id}_${i}`"
283
+ class="hover:!brightness-100"
284
+ :style="`${
285
+ i - 1 == modelValue
286
+ ? 'filter: brightness(1)'
287
+ : 'filter: brightness(0.5)'
288
+ }`"
289
+ >
290
+ <img
291
+ @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
292
+ :class="`h-auto max-w-full rounded-lg cursor-pointer shadow ${getBorderColor(
293
+ images[i - 1],
294
+ )}`"
295
+ :src="getThumbnailUrl(images[i - 1])"
296
+ />
297
+ </div>
298
+ </div>
299
+ </TransitionRoot>
300
+ </div>
301
+ </DialogPanel>
302
+ </Dialog>
303
+ </TransitionRoot>
304
+ <template v-if="mode == 'grid' || mode == 'mason'">
305
+ <div
306
+ class="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-6 gap-4"
307
+ :class="{
308
+ 'items-start': mode == 'mason',
309
+ 'items-center': mode == 'grid',
310
+ }"
311
+ >
312
+ <template v-for="i in images.length" :key="`g_${id}_${i}`">
313
+ <template v-if="mode == 'mason'">
314
+ <div
315
+ class="grid gap-4 items-start"
316
+ v-if="i + (1 % gridHeight) == 0"
317
+ >
318
+ <template v-for="j in gridHeight" :key="`gi_${id}_${i + j}`">
319
+ <div>
320
+ <img
321
+ @click="$eventBus.emit(`${id}GalleryImage`, i + j - 2)"
322
+ class="h-auto max-w-full rounded-lg cursor-pointer"
323
+ v-if="i + j - 2 < images.length"
324
+ :src="getThumbnailUrl(images[i + j - 2])"
325
+ />
326
+ </div>
327
+ </template>
328
+ </div>
329
+ </template>
330
+ <div v-else>
331
+ <img
332
+ @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
333
+ class="h-auto max-w-full rounded-lg cursor-pointer"
334
+ :src="getThumbnailUrl(images[i - 1])"
335
+ />
336
+ </div>
337
+ </template>
338
+ </div>
339
+ </template>
340
+ <button
341
+ v-if="mode == 'button'"
342
+ :class="`btn ${buttonType ? buttonType : 'primary'} defaults`"
343
+ @click="openGalleryImage(0)"
344
+ >
345
+ {{ buttonText ? buttonText : $t("open_gallery_cta") }}
346
+ </button>
347
+ </div>
348
+ </template>
@@ -0,0 +1,164 @@
1
+ <template>
2
+ <div>
3
+ <label class="tag-label" :for="`tags_${id}`">{{ label }}</label>
4
+ <div
5
+ class="tags-input"
6
+ @click="focusInput"
7
+ @keydown.enter.prevent="addTag"
8
+ @keydown.delete="removeLastTag"
9
+ >
10
+ <span v-for="(tag, index) in tags" :key="index" :class="`tag ${color}`">
11
+ {{ tag }}
12
+ <button @click.stop="removeTag(index)">
13
+ <svg
14
+ xmlns="http://www.w3.org/2000/svg"
15
+ fill="none"
16
+ viewBox="0 0 24 24"
17
+ stroke-width="1.5"
18
+ stroke="currentColor"
19
+ class="w-3 h-3 text-red-600"
20
+ >
21
+ <path
22
+ stroke-linecap="round"
23
+ stroke-linejoin="round"
24
+ d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
25
+ />
26
+ </svg>
27
+ </button>
28
+ </span>
29
+ <div
30
+ contenteditable
31
+ class="input"
32
+ :id="`tags_${id}`"
33
+ ref="textInput"
34
+ @input="updateInput"
35
+ @paste.prevent="handlePaste"
36
+ placeholder="Add a tag..."
37
+ ></div>
38
+ </div>
39
+ </div>
40
+ </template>
41
+
42
+ <script setup>
43
+ import { ref, watch, onMounted } from "vue";
44
+
45
+ const props = defineProps({
46
+ modelValue: {
47
+ type: Array,
48
+ default: () => [],
49
+ },
50
+ color: {
51
+ type: String,
52
+ default: "blue",
53
+ },
54
+ label: {
55
+ type: String,
56
+ default: "Tags",
57
+ },
58
+ id: {
59
+ type: String,
60
+ required: true,
61
+ },
62
+ autofocus: {
63
+ type: Boolean,
64
+ default: false,
65
+ },
66
+ });
67
+
68
+ const emit = defineEmits(["update:modelValue"]);
69
+ const tags = ref([...props.modelValue]);
70
+ const textInput = ref(null);
71
+
72
+ watch(
73
+ tags,
74
+ (newTags) => {
75
+ emit("update:modelValue", newTags);
76
+ },
77
+ { deep: true },
78
+ );
79
+
80
+ onMounted(() => {
81
+ if (props.autofocus) {
82
+ focusInput();
83
+ }
84
+ });
85
+
86
+ const updateInput = (event) => {
87
+ const text = event.target.innerText;
88
+ if (text.includes(",")) {
89
+ addTag();
90
+ }
91
+ };
92
+
93
+ const addTag = () => {
94
+ const newTags = textInput.value.innerText
95
+ .split(",")
96
+ .map((tag) => tag.trim())
97
+ .filter((tag) => tag.length > 0);
98
+ tags.value.push(...newTags);
99
+ textInput.value.innerText = "";
100
+ };
101
+
102
+ const removeTag = (index) => {
103
+ tags.value.splice(index, 1);
104
+ focusInput();
105
+ };
106
+
107
+ const removeLastTag = () => {
108
+ if (textInput.value.innerText === "") {
109
+ tags.value.pop();
110
+ }
111
+ };
112
+
113
+ const focusInput = () => {
114
+ textInput.value.focus();
115
+ };
116
+
117
+ const handlePaste = (e) => {
118
+ const text = (e.clipboardData || window.clipboardData).getData("text");
119
+ textInput.value.innerText += text;
120
+ e.preventDefault();
121
+ };
122
+ </script>
123
+
124
+ <style scoped>
125
+ .tags-input {
126
+ cursor: text;
127
+ @apply flex flex-wrap gap-2 items-center shadow-sm bg-fv-neutral-50 border border-fv-neutral-300 text-fv-neutral-900 text-sm rounded-sm focus:ring-fv-primary-500 focus:border-fv-primary-500 w-full p-2.5 dark:bg-fv-neutral-700 dark:border-fv-neutral-600 dark:placeholder-fv-neutral-400 dark:text-white dark:focus:ring-fv-primary-500 dark:focus:border-fv-primary-500;
128
+ }
129
+ .tag-label {
130
+ @apply block mb-2 text-sm font-medium text-fv-neutral-900 dark:text-white;
131
+
132
+ &.error {
133
+ @apply text-red-700 dark:text-red-500;
134
+ }
135
+ }
136
+ .tag {
137
+ @apply inline-flex gap-1 font-medium px-2.5 py-0.5 rounded;
138
+ &.blue {
139
+ @apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300;
140
+ }
141
+ &.purple {
142
+ @apply bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300;
143
+ }
144
+ &.red {
145
+ @apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300;
146
+ }
147
+ &.orange {
148
+ @apply bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300;
149
+ }
150
+ &.neutral {
151
+ @apply bg-fv-neutral-100 text-fv-neutral-800 dark:bg-fv-neutral-900 dark:text-fv-neutral-300;
152
+ }
153
+ &.green {
154
+ @apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300;
155
+ }
156
+ }
157
+
158
+ .input {
159
+ flex-grow: 1;
160
+ min-width: 100px;
161
+ outline: none;
162
+ border: none;
163
+ }
164
+ </style>
package/index.ts CHANGED
@@ -38,6 +38,8 @@ import DefaultPaging from "./components/ui/DefaultPaging.vue";
38
38
  import DefaultBreadcrumb from "./components/ui/DefaultBreadcrumb.vue";
39
39
  import DefaultLoader from "./components/ui/DefaultLoader.vue";
40
40
  import DefaultSidebar from "./components/ui/DefaultSidebar.vue";
41
+ import DefaultTagInput from "./components/ui/DefaultTagInput.vue";
42
+ import DefaultGallery from "./components/ui/DefaultGallery.vue";
41
43
 
42
44
  // Components/FWS
43
45
  import UserFlow from "./components/fws/UserFlow.vue";
@@ -119,6 +121,8 @@ export {
119
121
  DefaultBreadcrumb,
120
122
  DefaultLoader,
121
123
  DefaultSidebar,
124
+ DefaultTagInput,
125
+ DefaultGallery,
122
126
 
123
127
  // FWS
124
128
  UserFlow,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fy-/fws-vue",
3
- "version": "0.0.916",
3
+ "version": "0.0.918",
4
4
  "author": "Florian 'Fy' Gasquez <m@fy.to>",
5
5
  "license": "MIT",
6
6
  "repository": {