quasar-ui-danx 0.4.91 → 0.4.93
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 +7369 -6609
- 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/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/datetime.ts +285 -0
- package/src/helpers/formats/index.ts +4 -0
- package/src/helpers/formats/numbers.ts +127 -0
- package/src/helpers/formats/parsers.ts +74 -0
- package/src/helpers/formats/strings.ts +65 -0
- package/src/helpers/formats.ts +1 -489
- 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,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,129 @@
|
|
|
1
|
+
import { computed, ref, Ref } from "vue";
|
|
2
|
+
import { FileNavigationParent, FileNavigationState, UploadedFile } from "../types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Composable for managing file navigation state with parent stack support
|
|
6
|
+
* Enables diving into related files (like transcodes) and navigating back to parent
|
|
7
|
+
*/
|
|
8
|
+
export function useFileNavigation(initialFile: Ref<UploadedFile | null>, initialRelatedFiles: Ref<UploadedFile[]> = ref([])) {
|
|
9
|
+
// Current navigation state
|
|
10
|
+
const currentFile = ref<UploadedFile | null>(initialFile.value);
|
|
11
|
+
const relatedFiles = ref<UploadedFile[]>(initialRelatedFiles.value);
|
|
12
|
+
const parentStack = ref<FileNavigationParent[]>([]);
|
|
13
|
+
const currentIndex = ref(0);
|
|
14
|
+
|
|
15
|
+
// Computed properties
|
|
16
|
+
const hasParent = computed(() => parentStack.value.length > 0);
|
|
17
|
+
const canNavigatePrevious = computed(() => currentIndex.value > 0);
|
|
18
|
+
const canNavigateNext = computed(() => currentIndex.value < relatedFiles.value.length - 1);
|
|
19
|
+
const totalFiles = computed(() => relatedFiles.value.length);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Navigate to a specific file by index
|
|
23
|
+
*/
|
|
24
|
+
function navigateTo(index: number) {
|
|
25
|
+
if (index >= 0 && index < relatedFiles.value.length) {
|
|
26
|
+
currentIndex.value = index;
|
|
27
|
+
currentFile.value = relatedFiles.value[index];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Navigate to the next file
|
|
33
|
+
*/
|
|
34
|
+
function navigateNext() {
|
|
35
|
+
if (canNavigateNext.value) {
|
|
36
|
+
navigateTo(currentIndex.value + 1);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Navigate to the previous file
|
|
42
|
+
*/
|
|
43
|
+
function navigatePrevious() {
|
|
44
|
+
if (canNavigatePrevious.value) {
|
|
45
|
+
navigateTo(currentIndex.value - 1);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Dive into a related file (e.g., transcodes)
|
|
51
|
+
* Pushes current state onto parent stack
|
|
52
|
+
*/
|
|
53
|
+
function diveInto(file: UploadedFile, newRelatedFiles: UploadedFile[]) {
|
|
54
|
+
// Push current state onto parent stack
|
|
55
|
+
parentStack.value.push({
|
|
56
|
+
file: currentFile.value!,
|
|
57
|
+
relatedFiles: relatedFiles.value,
|
|
58
|
+
index: currentIndex.value
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Update to new state
|
|
62
|
+
currentFile.value = file;
|
|
63
|
+
relatedFiles.value = newRelatedFiles;
|
|
64
|
+
currentIndex.value = newRelatedFiles.findIndex(f => f.id === file.id);
|
|
65
|
+
|
|
66
|
+
// Fallback to 0 if not found
|
|
67
|
+
if (currentIndex.value === -1) {
|
|
68
|
+
currentIndex.value = 0;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Navigate back to parent file
|
|
74
|
+
* Pops state from parent stack
|
|
75
|
+
*/
|
|
76
|
+
function navigateToParent() {
|
|
77
|
+
if (hasParent.value) {
|
|
78
|
+
const parent = parentStack.value.pop()!;
|
|
79
|
+
currentFile.value = parent.file;
|
|
80
|
+
relatedFiles.value = parent.relatedFiles;
|
|
81
|
+
currentIndex.value = parent.index;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Reset navigation state
|
|
87
|
+
*/
|
|
88
|
+
function reset(file: UploadedFile | null = null, files: UploadedFile[] = []) {
|
|
89
|
+
currentFile.value = file;
|
|
90
|
+
relatedFiles.value = files;
|
|
91
|
+
parentStack.value = [];
|
|
92
|
+
currentIndex.value = 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get navigation state snapshot
|
|
97
|
+
*/
|
|
98
|
+
function getState(): FileNavigationState {
|
|
99
|
+
return {
|
|
100
|
+
currentFile: currentFile.value,
|
|
101
|
+
relatedFiles: relatedFiles.value,
|
|
102
|
+
parentStack: [...parentStack.value],
|
|
103
|
+
currentIndex: currentIndex.value
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
// State
|
|
109
|
+
currentFile,
|
|
110
|
+
relatedFiles,
|
|
111
|
+
parentStack,
|
|
112
|
+
currentIndex,
|
|
113
|
+
|
|
114
|
+
// Computed
|
|
115
|
+
hasParent,
|
|
116
|
+
canNavigatePrevious,
|
|
117
|
+
canNavigateNext,
|
|
118
|
+
totalFiles,
|
|
119
|
+
|
|
120
|
+
// Methods
|
|
121
|
+
navigateTo,
|
|
122
|
+
navigateNext,
|
|
123
|
+
navigatePrevious,
|
|
124
|
+
diveInto,
|
|
125
|
+
navigateToParent,
|
|
126
|
+
reset,
|
|
127
|
+
getState
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { onMounted, onUnmounted } from "vue";
|
|
2
|
+
|
|
3
|
+
export interface KeyboardNavigationCallbacks {
|
|
4
|
+
onPrevious: () => void;
|
|
5
|
+
onNext: () => void;
|
|
6
|
+
onEscape?: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface UseKeyboardNavigationOptions {
|
|
10
|
+
enabled?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Composable for keyboard navigation (arrow keys + escape)
|
|
15
|
+
* Handles left/right arrow keys for navigation and optional escape key
|
|
16
|
+
*/
|
|
17
|
+
export function useKeyboardNavigation(
|
|
18
|
+
callbacks: KeyboardNavigationCallbacks,
|
|
19
|
+
options: UseKeyboardNavigationOptions = {}
|
|
20
|
+
) {
|
|
21
|
+
const { enabled = true } = options;
|
|
22
|
+
|
|
23
|
+
function onKeydown(e: KeyboardEvent) {
|
|
24
|
+
if (!enabled) return;
|
|
25
|
+
|
|
26
|
+
switch (e.key) {
|
|
27
|
+
case "ArrowLeft":
|
|
28
|
+
e.preventDefault();
|
|
29
|
+
callbacks.onPrevious();
|
|
30
|
+
break;
|
|
31
|
+
case "ArrowRight":
|
|
32
|
+
e.preventDefault();
|
|
33
|
+
callbacks.onNext();
|
|
34
|
+
break;
|
|
35
|
+
case "Escape":
|
|
36
|
+
if (callbacks.onEscape) {
|
|
37
|
+
callbacks.onEscape();
|
|
38
|
+
}
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
onMounted(() => {
|
|
44
|
+
if (enabled) {
|
|
45
|
+
window.addEventListener("keydown", onKeydown);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
onUnmounted(() => {
|
|
50
|
+
if (enabled) {
|
|
51
|
+
window.removeEventListener("keydown", onKeydown);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
onKeydown
|
|
57
|
+
};
|
|
58
|
+
}
|