quasar-ui-danx 0.4.92 → 0.4.94
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/dist/danx.es.js +7149 -6414
- package/dist/danx.es.js.map +1 -1
- package/dist/danx.umd.js +90 -90
- package/dist/danx.umd.js.map +1 -1
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/components/ActionTable/Form/Fields/NumberField.vue +36 -8
- package/src/components/Utility/Dialogs/FullscreenCarouselDialog.vue +123 -136
- package/src/components/Utility/Files/CarouselHeader.vue +80 -0
- package/src/components/Utility/Files/FilePreview.vue +76 -21
- package/src/components/Utility/Files/FileRenderer.vue +111 -0
- package/src/components/Utility/Files/ThumbnailStrip.vue +64 -0
- package/src/components/Utility/Files/TranscodeNavigator.vue +175 -0
- package/src/components/Utility/Files/VirtualCarousel.vue +237 -0
- package/src/components/Utility/Files/index.ts +5 -0
- package/src/components/Utility/Widgets/LabelPillWidget.vue +10 -0
- package/src/composables/index.ts +4 -0
- package/src/composables/useFileNavigation.ts +129 -0
- package/src/composables/useKeyboardNavigation.ts +58 -0
- package/src/composables/useThumbnailScroll.ts +43 -0
- package/src/composables/useVirtualCarousel.ts +93 -0
- package/src/helpers/filePreviewHelpers.ts +107 -0
- package/src/helpers/formats/parsers.ts +5 -2
- package/src/helpers/index.ts +1 -0
- package/src/types/files.d.ts +38 -0
- package/src/types/widgets.d.ts +1 -1
- package/src/vue-plugin.ts +1 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="file-renderer flex items-center justify-center w-full h-full">
|
|
3
|
+
<!-- Video -->
|
|
4
|
+
<template v-if="isVideo(file)">
|
|
5
|
+
<video
|
|
6
|
+
class="max-h-full w-full"
|
|
7
|
+
controls
|
|
8
|
+
:autoplay="autoplay"
|
|
9
|
+
>
|
|
10
|
+
<source
|
|
11
|
+
:src="getPreviewUrl(file) + '#t=0.1'"
|
|
12
|
+
:type="file.mime"
|
|
13
|
+
>
|
|
14
|
+
</video>
|
|
15
|
+
</template>
|
|
16
|
+
|
|
17
|
+
<!-- Image -->
|
|
18
|
+
<img
|
|
19
|
+
v-else-if="getPreviewUrl(file)"
|
|
20
|
+
:alt="file.filename || file.name"
|
|
21
|
+
:src="getPreviewUrl(file)"
|
|
22
|
+
class="max-h-full max-w-full object-contain"
|
|
23
|
+
>
|
|
24
|
+
|
|
25
|
+
<!-- Text File (lazy loaded) -->
|
|
26
|
+
<div
|
|
27
|
+
v-else-if="isText(file)"
|
|
28
|
+
class="w-[60vw] min-w-96 max-h-[80vh] bg-slate-800 rounded-lg overflow-auto"
|
|
29
|
+
>
|
|
30
|
+
<div class="whitespace-pre-wrap p-4 text-slate-200">
|
|
31
|
+
<template v-if="textContent">
|
|
32
|
+
{{ textContent }}
|
|
33
|
+
</template>
|
|
34
|
+
<div
|
|
35
|
+
v-else
|
|
36
|
+
class="flex items-center justify-center py-8"
|
|
37
|
+
>
|
|
38
|
+
<QSpinnerPie
|
|
39
|
+
class="text-slate-400"
|
|
40
|
+
size="48px"
|
|
41
|
+
/>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<!-- No Preview -->
|
|
47
|
+
<div
|
|
48
|
+
v-else
|
|
49
|
+
class="text-center"
|
|
50
|
+
>
|
|
51
|
+
<h3 class="text-slate-300 mb-4">
|
|
52
|
+
No Preview Available
|
|
53
|
+
</h3>
|
|
54
|
+
<a
|
|
55
|
+
:href="file.url"
|
|
56
|
+
target="_blank"
|
|
57
|
+
class="text-blue-400 hover:text-blue-300"
|
|
58
|
+
>
|
|
59
|
+
{{ file.url }}
|
|
60
|
+
</a>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</template>
|
|
64
|
+
|
|
65
|
+
<script setup lang="ts">
|
|
66
|
+
import { QSpinnerPie } from "quasar";
|
|
67
|
+
import { onMounted, ref, watch } from "vue";
|
|
68
|
+
import { getPreviewUrl, isText, isVideo } from "../../../helpers/filePreviewHelpers";
|
|
69
|
+
import { UploadedFile } from "../../../types";
|
|
70
|
+
|
|
71
|
+
const props = withDefaults(defineProps<{
|
|
72
|
+
file: UploadedFile;
|
|
73
|
+
autoplay?: boolean;
|
|
74
|
+
loadText?: boolean;
|
|
75
|
+
}>(), {
|
|
76
|
+
autoplay: false,
|
|
77
|
+
loadText: true
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const textContent = ref<string>("");
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Load text file content from URL
|
|
84
|
+
*/
|
|
85
|
+
async function loadFileText() {
|
|
86
|
+
if (!isText(props.file) || !props.loadText) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (textContent.value) {
|
|
91
|
+
return; // Already loaded
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const text = await fetch(props.file.url || "").then((res) => res.text());
|
|
96
|
+
textContent.value = text;
|
|
97
|
+
} catch (e) {
|
|
98
|
+
textContent.value = "Error loading file content";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Load text content on mount and when file changes
|
|
103
|
+
onMounted(() => {
|
|
104
|
+
loadFileText();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
watch(() => props.file.id, () => {
|
|
108
|
+
textContent.value = ""; // Reset
|
|
109
|
+
loadFileText();
|
|
110
|
+
});
|
|
111
|
+
</script>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
v-if="files.length > 1"
|
|
4
|
+
class="bg-slate-900 bg-opacity-90 p-3 flex-shrink-0"
|
|
5
|
+
>
|
|
6
|
+
<div
|
|
7
|
+
ref="containerRef"
|
|
8
|
+
class="flex items-center justify-start gap-2 overflow-x-auto overflow-y-hidden px-4"
|
|
9
|
+
>
|
|
10
|
+
<div
|
|
11
|
+
v-for="(file, index) in files"
|
|
12
|
+
:key="file.id"
|
|
13
|
+
:class="[
|
|
14
|
+
'thumbnail cursor-pointer rounded border-2 transition-all flex-shrink-0 relative',
|
|
15
|
+
index === currentIndex ? 'border-blue-500 scale-110' : 'border-transparent opacity-60 hover:opacity-100'
|
|
16
|
+
]"
|
|
17
|
+
@click="onThumbnailClick(index)"
|
|
18
|
+
>
|
|
19
|
+
<img
|
|
20
|
+
:src="getThumbUrl(file)"
|
|
21
|
+
:alt="file.filename || file.name"
|
|
22
|
+
class="w-16 h-16 object-cover rounded"
|
|
23
|
+
>
|
|
24
|
+
<div class="absolute bottom-0 left-0 bg-slate-900 bg-opacity-80 text-slate-200 text-xs px-1.5 py-0.5 rounded-br rounded-tl font-semibold">
|
|
25
|
+
{{ index + 1 }}
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
</template>
|
|
31
|
+
|
|
32
|
+
<script setup lang="ts">
|
|
33
|
+
import { ref, toRef } from "vue";
|
|
34
|
+
import { useThumbnailScroll } from "../../../composables/useThumbnailScroll";
|
|
35
|
+
import { getThumbUrl } from "../../../helpers/filePreviewHelpers";
|
|
36
|
+
import { UploadedFile } from "../../../types";
|
|
37
|
+
|
|
38
|
+
const emit = defineEmits<{
|
|
39
|
+
'navigate': [index: number];
|
|
40
|
+
}>();
|
|
41
|
+
|
|
42
|
+
const props = defineProps<{
|
|
43
|
+
files: UploadedFile[];
|
|
44
|
+
currentIndex: number;
|
|
45
|
+
}>();
|
|
46
|
+
|
|
47
|
+
const containerRef = ref<HTMLElement | null>(null);
|
|
48
|
+
|
|
49
|
+
// Auto-scroll active thumbnail into view
|
|
50
|
+
useThumbnailScroll({
|
|
51
|
+
containerRef,
|
|
52
|
+
currentIndex: toRef(props, "currentIndex")
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function onThumbnailClick(index: number) {
|
|
56
|
+
emit("navigate", index);
|
|
57
|
+
}
|
|
58
|
+
</script>
|
|
59
|
+
|
|
60
|
+
<style scoped lang="scss">
|
|
61
|
+
.thumbnail {
|
|
62
|
+
transition: all 0.2s ease;
|
|
63
|
+
}
|
|
64
|
+
</style>
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="transcode-navigator">
|
|
3
|
+
<!-- Desktop: Popover Menu -->
|
|
4
|
+
<QMenu
|
|
5
|
+
v-if="!isMobile"
|
|
6
|
+
v-model="isOpen"
|
|
7
|
+
anchor="top right"
|
|
8
|
+
self="bottom right"
|
|
9
|
+
:offset="[0, 8]"
|
|
10
|
+
>
|
|
11
|
+
<div class="bg-slate-800 rounded-lg shadow-xl p-3 min-w-[300px] max-w-[400px]">
|
|
12
|
+
<div class="text-slate-200 font-semibold mb-2 px-2">
|
|
13
|
+
Transcodes ({{ transcodes.length }})
|
|
14
|
+
</div>
|
|
15
|
+
<div class="flex flex-wrap gap-2">
|
|
16
|
+
<div
|
|
17
|
+
v-for="(transcode, index) in transcodes"
|
|
18
|
+
:key="transcode.id"
|
|
19
|
+
class="transcode-thumb cursor-pointer rounded border-2 transition-all hover:scale-105"
|
|
20
|
+
:class="index === selectedIndex ? 'border-purple-500' : 'border-transparent opacity-70 hover:opacity-100'"
|
|
21
|
+
@click="onSelectTranscode(transcode, index)"
|
|
22
|
+
>
|
|
23
|
+
<div class="relative">
|
|
24
|
+
<img
|
|
25
|
+
:src="getThumbUrl(transcode)"
|
|
26
|
+
:alt="transcode.filename || transcode.name"
|
|
27
|
+
class="w-20 h-20 object-cover rounded"
|
|
28
|
+
>
|
|
29
|
+
<div
|
|
30
|
+
v-if="isTranscoding(transcode)"
|
|
31
|
+
class="absolute inset-0 bg-slate-900 bg-opacity-70 flex items-center justify-center rounded"
|
|
32
|
+
>
|
|
33
|
+
<QSpinnerPie
|
|
34
|
+
class="text-purple-400"
|
|
35
|
+
size="24px"
|
|
36
|
+
/>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
<div class="text-xs text-slate-400 text-center mt-1 truncate w-20">
|
|
40
|
+
{{ transcode.filename || transcode.name }}
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
</QMenu>
|
|
46
|
+
|
|
47
|
+
<!-- Mobile: Full Dialog -->
|
|
48
|
+
<QDialog
|
|
49
|
+
v-else
|
|
50
|
+
v-model="isOpen"
|
|
51
|
+
position="bottom"
|
|
52
|
+
>
|
|
53
|
+
<div class="bg-slate-800 rounded-t-2xl p-4">
|
|
54
|
+
<div class="flex items-center justify-between mb-4">
|
|
55
|
+
<div class="text-slate-200 font-semibold text-lg">
|
|
56
|
+
Transcodes ({{ transcodes.length }})
|
|
57
|
+
</div>
|
|
58
|
+
<QBtn
|
|
59
|
+
flat
|
|
60
|
+
round
|
|
61
|
+
dense
|
|
62
|
+
@click="isOpen = false"
|
|
63
|
+
>
|
|
64
|
+
<CloseIcon class="w-5 text-slate-400" />
|
|
65
|
+
</QBtn>
|
|
66
|
+
</div>
|
|
67
|
+
<div class="grid grid-cols-3 gap-3">
|
|
68
|
+
<div
|
|
69
|
+
v-for="(transcode, index) in transcodes"
|
|
70
|
+
:key="transcode.id"
|
|
71
|
+
class="transcode-thumb cursor-pointer rounded border-2 transition-all"
|
|
72
|
+
:class="index === selectedIndex ? 'border-purple-500' : 'border-transparent'"
|
|
73
|
+
@click="onSelectTranscode(transcode, index)"
|
|
74
|
+
>
|
|
75
|
+
<div class="relative">
|
|
76
|
+
<img
|
|
77
|
+
:src="getThumbUrl(transcode)"
|
|
78
|
+
:alt="transcode.filename || transcode.name"
|
|
79
|
+
class="w-full aspect-square object-cover rounded"
|
|
80
|
+
>
|
|
81
|
+
<div
|
|
82
|
+
v-if="isTranscoding(transcode)"
|
|
83
|
+
class="absolute inset-0 bg-slate-900 bg-opacity-70 flex items-center justify-center rounded"
|
|
84
|
+
>
|
|
85
|
+
<QSpinnerPie
|
|
86
|
+
class="text-purple-400"
|
|
87
|
+
size="32px"
|
|
88
|
+
/>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
<div class="text-xs text-slate-400 text-center mt-1 truncate">
|
|
92
|
+
{{ transcode.filename || transcode.name }}
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
</QDialog>
|
|
98
|
+
</div>
|
|
99
|
+
</template>
|
|
100
|
+
|
|
101
|
+
<script setup lang="ts">
|
|
102
|
+
import { XIcon as CloseIcon } from "@heroicons/vue/outline";
|
|
103
|
+
import { QSpinnerPie } from "quasar";
|
|
104
|
+
import { computed, ref } from "vue";
|
|
105
|
+
import { UploadedFile } from "../../../types";
|
|
106
|
+
|
|
107
|
+
interface TranscodeNavigatorProps {
|
|
108
|
+
transcodes: UploadedFile[];
|
|
109
|
+
modelValue?: boolean;
|
|
110
|
+
selectedIndex?: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const emit = defineEmits<{
|
|
114
|
+
'update:modelValue': [value: boolean];
|
|
115
|
+
'select': [transcode: UploadedFile, index: number];
|
|
116
|
+
}>();
|
|
117
|
+
|
|
118
|
+
const props = withDefaults(defineProps<TranscodeNavigatorProps>(), {
|
|
119
|
+
modelValue: false,
|
|
120
|
+
selectedIndex: -1
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const isOpen = computed({
|
|
124
|
+
get: () => props.modelValue,
|
|
125
|
+
set: (value) => emit('update:modelValue', value)
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Detect mobile (simple breakpoint check)
|
|
129
|
+
const isMobile = computed(() => {
|
|
130
|
+
if (typeof window === 'undefined') return false;
|
|
131
|
+
return window.innerWidth < 768;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
function onSelectTranscode(transcode: UploadedFile, index: number) {
|
|
135
|
+
emit('select', transcode, index);
|
|
136
|
+
isOpen.value = false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function getThumbUrl(file: UploadedFile): string {
|
|
140
|
+
if (file.thumb?.url) {
|
|
141
|
+
return file.thumb.url;
|
|
142
|
+
}
|
|
143
|
+
if (isVideo(file)) {
|
|
144
|
+
// Placeholder for video
|
|
145
|
+
return `data:image/svg+xml;base64,${btoa(
|
|
146
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path d="M8 5v14l11-7z"/></svg>'
|
|
147
|
+
)}`;
|
|
148
|
+
}
|
|
149
|
+
return file.optimized?.url || file.blobUrl || file.url || 'https://placehold.co/80x80?text=?';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isVideo(file: UploadedFile): boolean {
|
|
153
|
+
return !!file.mime?.startsWith('video') || !!file.type?.startsWith('video');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isTranscoding(file: UploadedFile): boolean {
|
|
157
|
+
// Check if file is still being transcoded based on meta
|
|
158
|
+
const metaTranscodes = file.meta?.transcodes || {};
|
|
159
|
+
for (const transcodeName of Object.keys(metaTranscodes)) {
|
|
160
|
+
const transcode = metaTranscodes[transcodeName];
|
|
161
|
+
if (transcode?.status && !['Complete', 'Timeout'].includes(transcode.status)) {
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
</script>
|
|
168
|
+
|
|
169
|
+
<style scoped lang="scss">
|
|
170
|
+
.transcode-navigator {
|
|
171
|
+
.transcode-thumb {
|
|
172
|
+
transition: all 0.2s ease;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
</style>
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="virtual-carousel relative w-full h-full">
|
|
3
|
+
<!-- Carousel Container -->
|
|
4
|
+
<div
|
|
5
|
+
ref="carouselContainer"
|
|
6
|
+
class="carousel-slides-container w-full h-full flex items-center justify-center relative overflow-hidden"
|
|
7
|
+
@touchstart="onTouchStart"
|
|
8
|
+
@touchmove="onTouchMove"
|
|
9
|
+
@touchend="onTouchEnd"
|
|
10
|
+
>
|
|
11
|
+
<!-- Render only visible slides -->
|
|
12
|
+
<transition-group
|
|
13
|
+
name="slide"
|
|
14
|
+
tag="div"
|
|
15
|
+
class="w-full h-full relative"
|
|
16
|
+
>
|
|
17
|
+
<div
|
|
18
|
+
v-for="slide in visibleSlides"
|
|
19
|
+
:key="slide.file.id"
|
|
20
|
+
:class="[
|
|
21
|
+
'carousel-slide absolute inset-0 flex items-center justify-center',
|
|
22
|
+
{ 'active': slide.isActive, 'inactive': !slide.isActive }
|
|
23
|
+
]"
|
|
24
|
+
>
|
|
25
|
+
<slot
|
|
26
|
+
name="slide"
|
|
27
|
+
:file="slide.file"
|
|
28
|
+
:index="slide.index"
|
|
29
|
+
:is-active="slide.isActive"
|
|
30
|
+
>
|
|
31
|
+
<!-- Default slide content -->
|
|
32
|
+
<FileRenderer :file="slide.file" />
|
|
33
|
+
</slot>
|
|
34
|
+
</div>
|
|
35
|
+
</transition-group>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<!-- Navigation Arrows -->
|
|
39
|
+
<div
|
|
40
|
+
v-if="canNavigatePrevious"
|
|
41
|
+
class="absolute left-2 top-1/2 -translate-y-1/2 z-10"
|
|
42
|
+
>
|
|
43
|
+
<QBtn
|
|
44
|
+
round
|
|
45
|
+
size="lg"
|
|
46
|
+
class="bg-slate-800 text-white opacity-70 hover:opacity-100"
|
|
47
|
+
@click="onPrevious"
|
|
48
|
+
>
|
|
49
|
+
<ChevronLeftIcon class="w-6" />
|
|
50
|
+
</QBtn>
|
|
51
|
+
</div>
|
|
52
|
+
<div
|
|
53
|
+
v-if="canNavigateNext"
|
|
54
|
+
class="absolute right-2 top-1/2 -translate-y-1/2 z-10"
|
|
55
|
+
>
|
|
56
|
+
<QBtn
|
|
57
|
+
round
|
|
58
|
+
size="lg"
|
|
59
|
+
class="bg-slate-800 text-white opacity-70 hover:opacity-100"
|
|
60
|
+
@click="onNext"
|
|
61
|
+
>
|
|
62
|
+
<ChevronRightIcon class="w-6" />
|
|
63
|
+
</QBtn>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<!-- Thumbnail Navigation (using ThumbnailStrip component) -->
|
|
67
|
+
<div
|
|
68
|
+
v-if="showThumbnails && files.length > 1"
|
|
69
|
+
class="absolute bottom-0 left-0 right-0"
|
|
70
|
+
>
|
|
71
|
+
<ThumbnailStrip
|
|
72
|
+
:files="files"
|
|
73
|
+
:current-index="currentIndex"
|
|
74
|
+
@navigate="navigateTo"
|
|
75
|
+
/>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<!-- Slide Counter -->
|
|
79
|
+
<div
|
|
80
|
+
v-if="showCounter && files.length > 1"
|
|
81
|
+
class="absolute top-2 right-2 bg-slate-900 bg-opacity-70 text-slate-200 px-3 py-1 rounded-full text-sm"
|
|
82
|
+
>
|
|
83
|
+
{{ currentIndex + 1 }} / {{ files.length }}
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</template>
|
|
87
|
+
|
|
88
|
+
<script setup lang="ts">
|
|
89
|
+
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/vue/outline";
|
|
90
|
+
import { computed, ref, toRef, watch } from "vue";
|
|
91
|
+
import { useKeyboardNavigation } from "../../../composables/useKeyboardNavigation";
|
|
92
|
+
import { useVirtualCarousel } from "../../../composables/useVirtualCarousel";
|
|
93
|
+
import { UploadedFile } from "../../../types";
|
|
94
|
+
import FileRenderer from "./FileRenderer.vue";
|
|
95
|
+
import ThumbnailStrip from "./ThumbnailStrip.vue";
|
|
96
|
+
|
|
97
|
+
interface VirtualCarouselProps {
|
|
98
|
+
files: UploadedFile[];
|
|
99
|
+
modelValue?: number;
|
|
100
|
+
showThumbnails?: boolean;
|
|
101
|
+
showCounter?: boolean;
|
|
102
|
+
enableKeyboard?: boolean;
|
|
103
|
+
enableSwipe?: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const emit = defineEmits<{
|
|
107
|
+
'update:modelValue': [index: number];
|
|
108
|
+
'change': [file: UploadedFile, index: number];
|
|
109
|
+
}>();
|
|
110
|
+
|
|
111
|
+
const props = withDefaults(defineProps<VirtualCarouselProps>(), {
|
|
112
|
+
modelValue: 0,
|
|
113
|
+
showThumbnails: true,
|
|
114
|
+
showCounter: true,
|
|
115
|
+
enableKeyboard: true,
|
|
116
|
+
enableSwipe: true
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const carouselContainer = ref<HTMLElement | null>(null);
|
|
120
|
+
const currentIndex = ref(props.modelValue);
|
|
121
|
+
const files = computed(() => props.files);
|
|
122
|
+
|
|
123
|
+
// Use virtual carousel composable
|
|
124
|
+
const { visibleSlides } = useVirtualCarousel(files, currentIndex);
|
|
125
|
+
|
|
126
|
+
// Navigation
|
|
127
|
+
const canNavigatePrevious = computed(() => currentIndex.value > 0);
|
|
128
|
+
const canNavigateNext = computed(() => currentIndex.value < props.files.length - 1);
|
|
129
|
+
|
|
130
|
+
function navigateTo(index: number) {
|
|
131
|
+
if (index >= 0 && index < props.files.length) {
|
|
132
|
+
currentIndex.value = index;
|
|
133
|
+
emit('update:modelValue', index);
|
|
134
|
+
emit('change', props.files[index], index);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function onPrevious() {
|
|
139
|
+
if (canNavigatePrevious.value) {
|
|
140
|
+
navigateTo(currentIndex.value - 1);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function onNext() {
|
|
145
|
+
if (canNavigateNext.value) {
|
|
146
|
+
navigateTo(currentIndex.value + 1);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Keyboard navigation
|
|
151
|
+
useKeyboardNavigation({
|
|
152
|
+
onPrevious,
|
|
153
|
+
onNext
|
|
154
|
+
}, {
|
|
155
|
+
enabled: toRef(props, 'enableKeyboard')
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Touch/swipe support
|
|
159
|
+
const touchStart = ref<{ x: number; y: number } | null>(null);
|
|
160
|
+
const SWIPE_THRESHOLD = 50;
|
|
161
|
+
|
|
162
|
+
function onTouchStart(e: TouchEvent) {
|
|
163
|
+
if (!props.enableSwipe) return;
|
|
164
|
+
touchStart.value = {
|
|
165
|
+
x: e.touches[0].clientX,
|
|
166
|
+
y: e.touches[0].clientY
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function onTouchMove(e: TouchEvent) {
|
|
171
|
+
if (!props.enableSwipe || !touchStart.value) return;
|
|
172
|
+
|
|
173
|
+
const deltaX = Math.abs(e.touches[0].clientX - touchStart.value.x);
|
|
174
|
+
const deltaY = Math.abs(e.touches[0].clientY - touchStart.value.y);
|
|
175
|
+
|
|
176
|
+
// Prevent vertical scroll if horizontal swipe detected
|
|
177
|
+
if (deltaX > deltaY) {
|
|
178
|
+
e.preventDefault();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function onTouchEnd(e: TouchEvent) {
|
|
183
|
+
if (!props.enableSwipe || !touchStart.value) return;
|
|
184
|
+
|
|
185
|
+
const deltaX = e.changedTouches[0].clientX - touchStart.value.x;
|
|
186
|
+
|
|
187
|
+
if (Math.abs(deltaX) > SWIPE_THRESHOLD) {
|
|
188
|
+
if (deltaX > 0) {
|
|
189
|
+
onPrevious();
|
|
190
|
+
} else {
|
|
191
|
+
onNext();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
touchStart.value = null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Watch for external changes to modelValue
|
|
199
|
+
watch(() => props.modelValue, (newIndex) => {
|
|
200
|
+
if (newIndex !== currentIndex.value) {
|
|
201
|
+
currentIndex.value = newIndex;
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
</script>
|
|
205
|
+
|
|
206
|
+
<style scoped lang="scss">
|
|
207
|
+
.virtual-carousel {
|
|
208
|
+
.carousel-slide {
|
|
209
|
+
transition: opacity 0.3s ease;
|
|
210
|
+
|
|
211
|
+
&.inactive {
|
|
212
|
+
opacity: 0;
|
|
213
|
+
pointer-events: none;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
&.active {
|
|
217
|
+
opacity: 1;
|
|
218
|
+
pointer-events: auto;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.thumbnail {
|
|
223
|
+
flex-shrink: 0;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Slide transitions
|
|
228
|
+
.slide-enter-active,
|
|
229
|
+
.slide-leave-active {
|
|
230
|
+
transition: opacity 0.3s ease;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.slide-enter-from,
|
|
234
|
+
.slide-leave-to {
|
|
235
|
+
opacity: 0;
|
|
236
|
+
}
|
|
237
|
+
</style>
|
|
@@ -1,2 +1,7 @@
|
|
|
1
|
+
export { default as CarouselHeader } from "./CarouselHeader.vue";
|
|
1
2
|
export { default as FilePreview } from "./FilePreview.vue";
|
|
3
|
+
export { default as FileRenderer } from "./FileRenderer.vue";
|
|
2
4
|
export { default as SvgImg } from "./SvgImg.vue";
|
|
5
|
+
export { default as ThumbnailStrip } from "./ThumbnailStrip.vue";
|
|
6
|
+
export { default as TranscodeNavigator } from "./TranscodeNavigator.vue";
|
|
7
|
+
export { default as VirtualCarousel } from "./VirtualCarousel.vue";
|
|
@@ -20,9 +20,19 @@ const colorClasses = {
|
|
|
20
20
|
amber: "bg-amber-950 text-amber-400",
|
|
21
21
|
yellow: "bg-yellow-950 text-yellow-400",
|
|
22
22
|
blue: "bg-blue-950 text-blue-400",
|
|
23
|
+
purple: "bg-purple-950 text-purple-400",
|
|
23
24
|
slate: "bg-slate-950 text-slate-400",
|
|
24
25
|
"slate-mid": "bg-slate-800 text-slate-400",
|
|
25
26
|
gray: "bg-slate-700 text-gray-300",
|
|
27
|
+
emerald: "bg-emerald-950 text-emerald-400",
|
|
28
|
+
orange: "bg-orange-950 text-orange-400",
|
|
29
|
+
lime: "bg-lime-950 text-lime-400",
|
|
30
|
+
teal: "bg-teal-950 text-teal-400",
|
|
31
|
+
cyan: "bg-cyan-950 text-cyan-400",
|
|
32
|
+
rose: "bg-rose-950 text-rose-400",
|
|
33
|
+
indigo: "bg-indigo-950 text-indigo-400",
|
|
34
|
+
violet: "bg-violet-950 text-violet-400",
|
|
35
|
+
fuchsia: "bg-fuchsia-950 text-fuchsia-400",
|
|
26
36
|
none: ""
|
|
27
37
|
};
|
|
28
38
|
|