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.
@@ -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
 
@@ -0,0 +1,4 @@
1
+ export * from "./useFileNavigation";
2
+ export * from "./useKeyboardNavigation";
3
+ export * from "./useThumbnailScroll";
4
+ export * from "./useVirtualCarousel";