feat: improves navigation by implementing horizontal scrolling cards (#3498)
2
assets/components.d.ts
vendored
@@ -18,6 +18,8 @@ declare module 'vue' {
|
|||||||
'Carbon:starFilled': typeof import('~icons/carbon/star-filled')['default']
|
'Carbon:starFilled': typeof import('~icons/carbon/star-filled')['default']
|
||||||
'Carbon:stopFilledAlt': typeof import('~icons/carbon/stop-filled-alt')['default']
|
'Carbon:stopFilledAlt': typeof import('~icons/carbon/stop-filled-alt')['default']
|
||||||
'Carbon:warning': typeof import('~icons/carbon/warning')['default']
|
'Carbon:warning': typeof import('~icons/carbon/warning')['default']
|
||||||
|
Carousel: typeof import('./components/common/Carousel.vue')['default']
|
||||||
|
CarouselItem: typeof import('./components/common/CarouselItem.vue')['default']
|
||||||
'Cil:checkCircle': typeof import('~icons/cil/check-circle')['default']
|
'Cil:checkCircle': typeof import('~icons/cil/check-circle')['default']
|
||||||
'Cil:circle': typeof import('~icons/cil/circle')['default']
|
'Cil:circle': typeof import('~icons/cil/circle')['default']
|
||||||
'Cil:columns': typeof import('~icons/cil/columns')['default']
|
'Cil:columns': typeof import('~icons/cil/columns')['default']
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="ready" data-testid="side-menu">
|
<div v-if="ready" data-testid="side-menu" class="flex min-h-0 flex-col">
|
||||||
<Toggle v-model="showSwarm" v-if="services.length > 0 || customGroups.length > 0">
|
<Carousel v-model="selectedCard" class="flex-1">
|
||||||
<div class="text-lg font-light">{{ $t("label.swarm-mode") }}</div>
|
<CarouselItem title="Hosts and Containers" id="host">
|
||||||
</Toggle>
|
|
||||||
|
|
||||||
<SlideTransition :slide-right="showSwarm">
|
|
||||||
<template #left>
|
|
||||||
<HostMenu />
|
<HostMenu />
|
||||||
</template>
|
</CarouselItem>
|
||||||
<template #right>
|
<CarouselItem title="Services and Stacks" v-if="services.length > 0" id="swarm">
|
||||||
<SwarmMenu />
|
<SwarmMenu />
|
||||||
</template>
|
</CarouselItem>
|
||||||
</SlideTransition>
|
</Carousel>
|
||||||
</div>
|
</div>
|
||||||
<div role="status" class="flex animate-pulse flex-col gap-4" v-else>
|
<div role="status" class="flex animate-pulse flex-col gap-4" v-else>
|
||||||
<div class="h-3 w-full rounded-full bg-base-content/50 opacity-50" v-for="_ in 9"></div>
|
<div class="h-3 w-full rounded-full bg-base-content/50 opacity-50" v-for="_ in 9"></div>
|
||||||
@@ -24,17 +20,16 @@ const containerStore = useContainerStore();
|
|||||||
const { ready } = storeToRefs(containerStore);
|
const { ready } = storeToRefs(containerStore);
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const swarmStore = useSwarmStore();
|
const swarmStore = useSwarmStore();
|
||||||
const { services, customGroups } = storeToRefs(swarmStore);
|
const { services } = storeToRefs(swarmStore);
|
||||||
|
const selectedCard = ref<"host" | "swarm">("host");
|
||||||
const showSwarm = useSessionStorage<boolean>("DOZZLE_SWARM_MODE", false);
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
route,
|
route,
|
||||||
() => {
|
() => {
|
||||||
if (route.meta.swarmMode) {
|
if (route.meta.swarmMode) {
|
||||||
showSwarm.value = true;
|
selectedCard.value = "swarm";
|
||||||
} else if (route.meta.containerMode) {
|
} else if (route.meta.containerMode) {
|
||||||
showSwarm.value = false;
|
selectedCard.value = "host";
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<aside class="fixed h-screen w-[inherit] overflow-auto p-3" data-testid="navigation">
|
<aside class="fixed flex h-screen w-[inherit] flex-col gap-4 p-3" data-testid="navigation">
|
||||||
<h1>
|
<h1>
|
||||||
<router-link :to="{ name: '/' }">
|
<router-link :to="{ name: '/' }">
|
||||||
<LogoWithText class="logo h-16 w-40" />
|
<LogoWithText class="logo h-16 w-40" />
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="input input-sm mt-4 inline-flex cursor-pointer items-center gap-2 font-light hover:border-primary"
|
class="input input-sm inline-flex cursor-pointer items-center gap-2 self-start font-light hover:border-primary"
|
||||||
@click="$emit('search')"
|
@click="$emit('search')"
|
||||||
:title="$t('tooltip.search')"
|
:title="$t('tooltip.search')"
|
||||||
data-testid="search"
|
data-testid="search"
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
<key-shortcut char="k" class="text-base-content/70"></key-shortcut>
|
<key-shortcut char="k" class="text-base-content/70"></key-shortcut>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<side-menu class="mt-4"></side-menu>
|
<SideMenu class="mt-2 flex-1" />
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
87
assets/components/common/Carousel.vue
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex min-h-0 flex-col gap-2">
|
||||||
|
<div class="flex min-h-0 flex-1 flex-col overflow-auto overscroll-y-contain">
|
||||||
|
<div
|
||||||
|
ref="container"
|
||||||
|
class="scrollbar-hide flex shrink-0 grow snap-x snap-mandatory overflow-x-auto overscroll-x-contain scroll-smooth"
|
||||||
|
>
|
||||||
|
<component v-for="(card, index) in providedCards" :key="index" :is="card" ref="cards" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<h3 class="text-center text-sm font-thin">
|
||||||
|
{{ cards?.[activeIndex].title }}
|
||||||
|
</h3>
|
||||||
|
<div class="flex flex-none justify-center gap-2" v-if="providedCards.length > 1">
|
||||||
|
<button
|
||||||
|
v-for="(c, index) in providedCards"
|
||||||
|
:key="c.props?.id"
|
||||||
|
@click="scrollToItem(index)"
|
||||||
|
:class="[
|
||||||
|
'size-2 rounded-full transition-all duration-700',
|
||||||
|
activeIndex === index ? 'scale-125 bg-primary' : 'bg-base-content/50 hover:bg-base-content',
|
||||||
|
]"
|
||||||
|
:aria-label="c.props?.title"
|
||||||
|
:title="c.props?.title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import CarouselItem from "./CarouselItem.vue";
|
||||||
|
const container = useTemplateRef<HTMLDivElement>("container");
|
||||||
|
const activeIndex = ref(0);
|
||||||
|
const activeId = defineModel<string>();
|
||||||
|
const slots = defineSlots<{ default(): VNode[] }>();
|
||||||
|
const providedCards = computed(() => slots.default().filter(({ type }) => type === CarouselItem));
|
||||||
|
const cards = useTemplateRef<InstanceType<typeof CarouselItem>[]>("cards");
|
||||||
|
|
||||||
|
const scrollToItem = (index: number) => {
|
||||||
|
cards.value?.[index].$el.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
inline: "start",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const { pause, resume } = watchPausable(activeId, (v) => {
|
||||||
|
if (activeId.value) {
|
||||||
|
const index = cards.value?.map((c) => c.id).indexOf(activeId.value) ?? -1;
|
||||||
|
if (index !== -1) {
|
||||||
|
scrollToItem(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useIntersectionObserver(
|
||||||
|
cards as Ref<InstanceType<typeof CarouselItem>[]>,
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach(({ isIntersecting, target }) => {
|
||||||
|
if (isIntersecting) {
|
||||||
|
const index = cards.value?.map((c) => c.$el).indexOf(target as HTMLDivElement) ?? -1;
|
||||||
|
if (index !== -1) {
|
||||||
|
pause();
|
||||||
|
activeIndex.value = index;
|
||||||
|
activeId.value = cards.value?.[index].id;
|
||||||
|
nextTick(() => resume());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: container,
|
||||||
|
threshold: 0.5,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.scrollbar-hide {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
16
assets/components/common/CarouselItem.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full min-w-full flex-shrink-0 snap-start snap-always">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
const { id, title } = defineProps<{ id: string; title?: string }>();
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -10,7 +10,5 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
const { modelValue } = defineModels<{
|
const modelValue = defineModel<boolean>();
|
||||||
modelValue: boolean;
|
|
||||||
}>();
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 16 KiB |