Merge branch 'main' into resolve-infinite-reactivity-loops-in-models-panel
This commit is contained in:
@@ -27,8 +27,10 @@ jobs:
|
||||
image: speckle/pre-commit-runner:latest
|
||||
env:
|
||||
IMAGE_VERSION_TAG: ${{ inputs.IMAGE_VERSION_TAG }}
|
||||
DOCKER_REG_USER: ${{ inputs.DOCKERHUB_USERNAME }}
|
||||
DOCKER_REG_PASS: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
REGISTRY_USERNAME: ${{ inputs.DOCKERHUB_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
HELM_REGISTRY_DOMAIN: registry-1.docker.io
|
||||
HELM_REPOSITORY_PATH: speckle
|
||||
steps:
|
||||
- uses: actions/checkout@v4.2.2
|
||||
with:
|
||||
|
||||
@@ -6,14 +6,26 @@ if [[ -z "${IMAGE_VERSION_TAG}" ]]; then
|
||||
echo "IMAGE_VERSION_TAG is not set"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "${DOCKER_REG_USER}" ]]; then
|
||||
echo "DOCKER_REG_USER is not set"
|
||||
if [[ -z "${REGISTRY_USERNAME}" ]]; then
|
||||
echo "REGISTRY_USERNAME is not set"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "${DOCKER_REG_PASS}" ]]; then
|
||||
echo "DOCKER_REG_PASS is not set"
|
||||
if [[ -z "${REGISTRY_PASSWORD}" ]]; then
|
||||
echo "REGISTRY_PASSWORD is not set"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "${HELM_REGISTRY_DOMAIN}" ]]; then
|
||||
echo "HELM_REGISTRY_DOMAIN is not set"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "${HELM_REPOSITORY_PATH}" ]]; then
|
||||
echo "HELM_REPOSITORY_PATH is not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RELEASE_VERSION="${IMAGE_VERSION_TAG}"
|
||||
HELM_STABLE_BRANCH="${HELM_STABLE_BRANCH:-"main"}"
|
||||
CHART_NAME="${CHART_NAME:-"speckle-server-chart"}"
|
||||
|
||||
echo "🏷️ Preparing envs"
|
||||
|
||||
@@ -22,16 +34,11 @@ SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
# shellcheck disable=SC1090,SC1091
|
||||
source "${SCRIPT_DIR}/common.sh"
|
||||
|
||||
RELEASE_VERSION="${IMAGE_VERSION_TAG}-chart"
|
||||
HELM_STABLE_BRANCH="${HELM_STABLE_BRANCH:-"main"}"
|
||||
DOCKER_HELM_REG_URL="${DOCKER_HELM_REG_URL:-"registry-1.docker.io"}"
|
||||
DOCKER_HELM_REG_ORG="${DOCKER_HELM_REG_ORG:-"speckle"}"
|
||||
CHART_NAME="${CHART_NAME:-"speckle-server"}"
|
||||
|
||||
echo "📌 Releasing Helm Chart version ${RELEASE_VERSION} for application version ${IMAGE_VERSION_TAG}"
|
||||
echo "📌 Releasing Helm Chart for application version ${IMAGE_VERSION_TAG} to 'oci://${HELM_REGISTRY_DOMAIN}/${HELM_REPOSITORY_PATH}/${CHART_NAME}:${RELEASE_VERSION}'"
|
||||
|
||||
yq e -i ".docker_image_tag = \"${IMAGE_VERSION_TAG}\"" "${GIT_REPO}/utils/helm/speckle-server/values.yaml"
|
||||
yq e -i ".name = \"${CHART_NAME}\"" "${GIT_REPO}/utils/helm/speckle-server/Chart.yaml"
|
||||
|
||||
echo "${DOCKER_REG_PASS}" | helm registry login "${DOCKER_HELM_REG_URL}" --username "${DOCKER_REG_USER}" --password-stdin
|
||||
echo "${REGISTRY_PASSWORD}" | helm registry login "${HELM_REGISTRY_DOMAIN}" --username "${REGISTRY_USERNAME}" --password-stdin
|
||||
helm package "${GIT_REPO}/utils/helm/speckle-server" --version "${RELEASE_VERSION}" --app-version "${IMAGE_VERSION_TAG}" --destination "/tmp"
|
||||
helm push "/tmp/${CHART_NAME}-${RELEASE_VERSION}.tgz" "oci://${DOCKER_HELM_REG_URL}/${DOCKER_HELM_REG_ORG}"
|
||||
helm push "/tmp/${CHART_NAME}-${RELEASE_VERSION}.tgz" "oci://${HELM_REGISTRY_DOMAIN}/${HELM_REPOSITORY_PATH}"
|
||||
|
||||
+49
-20
@@ -37,8 +37,8 @@ import {
|
||||
ExclamationTriangleIcon
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
|
||||
import { useSelectionUtilities } from '~~/lib/viewer/composables/ui'
|
||||
import { useFilterUtilities } from '~/lib/viewer/composables/filtering/filtering'
|
||||
import { useFilterColoringHelpers } from '~/lib/viewer/composables/filtering/coloringHelpers'
|
||||
import type { NumericPropertyInfo } from '@speckle/viewer'
|
||||
import { containsAll } from '~~/lib/common/helpers/utils'
|
||||
import type { Automate } from '@speckle/shared'
|
||||
@@ -55,29 +55,32 @@ const props = defineProps<{
|
||||
|
||||
const {
|
||||
viewer: {
|
||||
metadata: { filteringState, filteringDataStore }
|
||||
metadata: { filteringDataStore }
|
||||
}
|
||||
} = useInjectedViewerState()
|
||||
|
||||
const { isolateObjects, resetFilters, addActiveFilter, toggleFilterApplied, filters } =
|
||||
const { isolateObjects, resetFilters, resetIsolations, addActiveFilter, filters } =
|
||||
useFilterUtilities()
|
||||
const { setSelectionFromObjectIds, clearSelection } = useSelectionUtilities()
|
||||
const { setColorFilter, removeColorFilter } = useFilterColoringHelpers()
|
||||
const logger = useLogger()
|
||||
|
||||
const hasMetadataGradient = computed(() => {
|
||||
if (props.result.metadata?.gradient) return true
|
||||
return false
|
||||
})
|
||||
|
||||
const isolatedObjects = computed(() => filteringState.value?.isolatedObjects)
|
||||
const isIsolated = computed(() => {
|
||||
if (!isolatedObjects.value?.length) return false
|
||||
if (
|
||||
props.functionId &&
|
||||
filteringState.value?.activePropFilterKey === props.functionId
|
||||
)
|
||||
return false
|
||||
// Gradient results show active via metadataGradientIsSet
|
||||
if (hasMetadataGradient.value) {
|
||||
return metadataGradientIsSet.value
|
||||
}
|
||||
|
||||
// Non-gradient results show active if their objects are isolated
|
||||
const isolatedIds = filters.isolatedObjectIds.value
|
||||
const ids = resultObjectIds.value
|
||||
return containsAll(ids, isolatedObjects.value)
|
||||
|
||||
if (!isolatedIds?.length) return false
|
||||
return containsAll(ids, isolatedIds)
|
||||
})
|
||||
|
||||
const resultObjectIds = computed(() => {
|
||||
@@ -90,20 +93,36 @@ const handleClick = () => {
|
||||
setOrUnsetGradient()
|
||||
return
|
||||
}
|
||||
|
||||
isolateOrUnisolateObjects()
|
||||
}
|
||||
|
||||
const isolateOrUnisolateObjects = () => {
|
||||
const ids = resultObjectIds.value
|
||||
const isCurrentlyIsolated = isIsolated.value
|
||||
const wasIsolated = containsAll(ids, filters.isolatedObjectIds.value || [])
|
||||
|
||||
resetFilters()
|
||||
if (isCurrentlyIsolated) {
|
||||
clearSelection()
|
||||
} else {
|
||||
logger.debug('Isolation toggle:', {
|
||||
resultCategory: props.result.category,
|
||||
objectCount: ids.length,
|
||||
wasIsolated,
|
||||
currentIsolatedCount: filters.isolatedObjectIds.value?.length || 0
|
||||
})
|
||||
|
||||
// Always clear existing isolation (radio button behavior)
|
||||
resetIsolations()
|
||||
logger.debug(
|
||||
'After reset, isolated count:',
|
||||
filters.isolatedObjectIds.value?.length || 0
|
||||
)
|
||||
|
||||
// If wasn't isolated before, isolate now (toggle behavior)
|
||||
if (!wasIsolated) {
|
||||
isolateObjects(ids)
|
||||
setSelectionFromObjectIds(ids)
|
||||
logger.debug(
|
||||
'After isolate, isolated count:',
|
||||
filters.isolatedObjectIds.value?.length || 0
|
||||
)
|
||||
} else {
|
||||
logger.debug('Result was isolated, now deactivated')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,11 +173,20 @@ const computedFilterData = computed((): NumericFilterData | undefined => {
|
||||
})
|
||||
|
||||
const setOrUnsetGradient = () => {
|
||||
logger.debug('Gradient toggle:', {
|
||||
resultCategory: props.result.category,
|
||||
isCurrentlySet: metadataGradientIsSet.value
|
||||
})
|
||||
|
||||
if (metadataGradientIsSet.value) {
|
||||
logger.debug('Removing gradient filter')
|
||||
resetFilters()
|
||||
removeColorFilter()
|
||||
metadataGradientIsSet.value = false
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug('Setting gradient filter')
|
||||
resetFilters()
|
||||
if (!props.result.metadata) return
|
||||
if (!computedPropInfo.value) return
|
||||
@@ -169,7 +197,8 @@ const setOrUnsetGradient = () => {
|
||||
|
||||
metadataGradientIsSet.value = true
|
||||
const filterId = addActiveFilter(computedPropInfo.value)
|
||||
toggleFilterApplied(filterId)
|
||||
|
||||
setColorFilter(filterId)
|
||||
}
|
||||
|
||||
const iconAndColor = computed(() => {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div class="h-screen w-screen flex flex-col md:flex-row relative">
|
||||
<PresentationCloseMessage
|
||||
class="absolute z-50 top-4 left-1/2 -translate-x-1/2"
|
||||
:show-close-message="showCloseMessage"
|
||||
@hide-close-message="hideCloseMessage"
|
||||
/>
|
||||
|
||||
<PresentationHeader
|
||||
v-if="!hideUi"
|
||||
v-model:is-sidebar-open="isLeftSidebarOpen"
|
||||
class="absolute top-4 z-40"
|
||||
:class="[
|
||||
isLeftSidebarOpen ? 'left-[calc(50%+0.75rem)] md:left-[15.75rem]' : 'left-4'
|
||||
]"
|
||||
:title="presentation?.title"
|
||||
@toggle-sidebar="isLeftSidebarOpen = !isLeftSidebarOpen"
|
||||
/>
|
||||
|
||||
<PresentationActions
|
||||
v-if="!hideUi"
|
||||
v-model:is-sidebar-open="isInfoSidebarOpen"
|
||||
v-model:is-present-mode="isPresentMode"
|
||||
:presentation-id="presentation?.id"
|
||||
class="absolute top-4 right-4 z-20"
|
||||
:class="{ 'lg:right-[21rem]': isInfoSidebarOpen }"
|
||||
@toggle-sidebar="isInfoSidebarOpen = !isInfoSidebarOpen"
|
||||
@toggle-present-mode="isPresentMode = !isPresentMode"
|
||||
/>
|
||||
|
||||
<PresentationSlideIndicator
|
||||
class="absolute top-1/2 -translate-y-1/2 z-20"
|
||||
:current-slide-index="currentVisibleIndex"
|
||||
:slide-count="slideCount || 0"
|
||||
:class="[isLeftSidebarOpen ? 'lg:left-[15.75rem] hidden md:block' : 'left-4']"
|
||||
/>
|
||||
|
||||
<PresentationLeftSidebar
|
||||
v-if="isLeftSidebarOpen"
|
||||
class="absolute left-0 top-0 md:relative flex-shrink-0 z-30"
|
||||
:slides="presentation"
|
||||
:workspace-logo="workspace?.logo"
|
||||
:workspace-name="workspace?.name"
|
||||
:current-slide-id="currentView?.id"
|
||||
:is-present-mode="isPresentMode"
|
||||
@select-slide="onSelectSlide"
|
||||
/>
|
||||
|
||||
<div class="flex-1 z-0">
|
||||
<Component
|
||||
:is="presentation ? ViewerWrapper : 'div'"
|
||||
:group="presentation"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PresentationInfoSidebar
|
||||
v-if="isInfoSidebarOpen"
|
||||
class="flex-shrink-0"
|
||||
:title="currentView?.name"
|
||||
:description="currentView?.description"
|
||||
:view-id="currentView?.id"
|
||||
:project-id="projectId"
|
||||
:is-present-mode="isPresentMode"
|
||||
/>
|
||||
|
||||
<PresentationControls
|
||||
v-if="!hideUi"
|
||||
class="z-10 absolute left-1/2 -translate-x-1/2"
|
||||
:disable-previous="disablePrevious"
|
||||
:disable-next="disableNext"
|
||||
:class="[
|
||||
isInfoSidebarOpen ? 'bottom-52 md:bottom-4' : 'bottom-4',
|
||||
isLeftSidebarOpen ? 'hidden md:flex' : ''
|
||||
]"
|
||||
@on-previous="onPrevious"
|
||||
@on-next="onNext"
|
||||
/>
|
||||
|
||||
<PresentationSpeckleLogo
|
||||
class="absolute right-4 z-20"
|
||||
:class="[isInfoSidebarOpen ? 'bottom-52 md:bottom-4' : 'bottom-4']"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useInjectedPresentationState } from '~/lib/presentations/composables/setup'
|
||||
import { clamp } from 'lodash-es'
|
||||
|
||||
const {
|
||||
projectId,
|
||||
response: { presentation, workspace, visibleSlides },
|
||||
ui: { slideIdx: currentVisibleIndex, slide: currentView }
|
||||
} = useInjectedPresentationState()
|
||||
|
||||
const isInfoSidebarOpen = ref(true)
|
||||
const isLeftSidebarOpen = ref(true)
|
||||
const hideUi = ref(false)
|
||||
const isPresentMode = ref(false)
|
||||
const showCloseMessage = ref(false)
|
||||
const closeMessageTimeout = ref<NodeJS.Timeout | undefined>()
|
||||
|
||||
const ViewerWrapper = resolveComponent('PresentationViewerWrapper')
|
||||
|
||||
const slideCount = computed(() => visibleSlides.value?.length || 0)
|
||||
const disablePrevious = computed(() => currentVisibleIndex.value === 0)
|
||||
const disableNext = computed(() =>
|
||||
slideCount.value ? currentVisibleIndex.value === slideCount.value - 1 : false
|
||||
)
|
||||
|
||||
const onSelectSlide = (slideId: string) => {
|
||||
currentVisibleIndex.value = visibleSlides.value.findIndex((s) => s.id === slideId)
|
||||
}
|
||||
|
||||
const onPrevious = () => {
|
||||
currentVisibleIndex.value = clamp(currentVisibleIndex.value - 1, 0, slideCount.value)
|
||||
}
|
||||
|
||||
const onNext = () => {
|
||||
currentVisibleIndex.value = clamp(currentVisibleIndex.value + 1, 0, slideCount.value)
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'i' || event.key === 'I') {
|
||||
hideUi.value = !hideUi.value
|
||||
isLeftSidebarOpen.value = false
|
||||
isInfoSidebarOpen.value = false
|
||||
} else if (event.key === 'Escape' && isPresentMode.value) {
|
||||
isPresentMode.value = false
|
||||
} else if (event.key === 'ArrowLeft' && !disablePrevious.value) {
|
||||
onPrevious()
|
||||
} else if (event.key === 'ArrowRight' && !disableNext.value) {
|
||||
onNext()
|
||||
}
|
||||
}
|
||||
|
||||
const hideCloseMessage = () => {
|
||||
showCloseMessage.value = false
|
||||
if (closeMessageTimeout.value) {
|
||||
clearTimeout(closeMessageTimeout.value)
|
||||
closeMessageTimeout.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
watch(isPresentMode, (newVal, oldVal) => {
|
||||
if (newVal && !oldVal) {
|
||||
isLeftSidebarOpen.value = false
|
||||
isInfoSidebarOpen.value = false
|
||||
showCloseMessage.value = true
|
||||
closeMessageTimeout.value = setTimeout(() => {
|
||||
showCloseMessage.value = false
|
||||
}, 3000)
|
||||
} else if (!newVal && oldVal) {
|
||||
isLeftSidebarOpen.value = true
|
||||
isInfoSidebarOpen.value = true
|
||||
hideCloseMessage()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
hideCloseMessage()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="presentation-state-setup">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { writableAsyncComputed } from '~/lib/common/composables/async'
|
||||
import {
|
||||
useSetupPresentationState,
|
||||
type InjectablePresentationState,
|
||||
type UseSetupPresentationParams
|
||||
} from '~/lib/presentations/composables/setup'
|
||||
|
||||
const emit = defineEmits<{
|
||||
setup: [InjectablePresentationState]
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useSafeRouter()
|
||||
const projectId = writableAsyncComputed({
|
||||
get: () => route.params.id as string,
|
||||
set: async (value) => {
|
||||
await router.push(() => ({
|
||||
params: { id: value }
|
||||
}))
|
||||
},
|
||||
initialState: route.params.id as string,
|
||||
asyncRead: false
|
||||
})
|
||||
|
||||
const presentationId = writableAsyncComputed({
|
||||
get: () => route.params.presentationId as string,
|
||||
set: async (value) => {
|
||||
await router.push(() => ({
|
||||
params: { presentationId: value }
|
||||
}))
|
||||
},
|
||||
initialState: route.params.presentationId as string,
|
||||
asyncRead: false
|
||||
})
|
||||
|
||||
const initParams = computed(
|
||||
(): UseSetupPresentationParams => ({
|
||||
projectId,
|
||||
presentationId
|
||||
})
|
||||
)
|
||||
|
||||
const state = useSetupPresentationState(initParams.value)
|
||||
emit('setup', state)
|
||||
</script>
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<div class="presentation-viewer-setup">
|
||||
<ViewerCoreSetup viewer-host-classes="h-[100dvh]" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts"></script>
|
||||
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<ViewerStateSetup :init-params="initParams">
|
||||
<PresentationViewerSetup />
|
||||
</ViewerStateSetup>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { resourceBuilder } from '@speckle/shared/viewer/route'
|
||||
import { writableAsyncComputed } from '~/lib/common/composables/async'
|
||||
import { graphql } from '~/lib/common/generated/gql/gql'
|
||||
import { useInjectedPresentationState } from '~/lib/presentations/composables/setup'
|
||||
import { ViewerRenderPageType } from '~/lib/viewer/helpers/state'
|
||||
import type { UseSetupViewerParams } from '~~/lib/viewer/composables/setup'
|
||||
|
||||
/**
|
||||
* Don't put much logic here, the viewer state is unavailable here so do as much as u can in PresentationViewerSetup instead
|
||||
*/
|
||||
|
||||
graphql(`
|
||||
fragment PresentationViewerPageWrapper_SavedViewGroup on SavedViewGroup {
|
||||
id
|
||||
views(input: $input) {
|
||||
totalCount
|
||||
items {
|
||||
id
|
||||
resourceIdString
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const {
|
||||
projectId,
|
||||
viewer: { resourceIdString: coreResourceIdString },
|
||||
ui: { slide }
|
||||
} = useInjectedPresentationState()
|
||||
const route = useRoute()
|
||||
|
||||
// Resolved from the presentation's views, but also has to be at least somewhat mutable
|
||||
// cause some viewer internals try to change it
|
||||
const resourceIdString = writableAsyncComputed({
|
||||
get: () => coreResourceIdString.value,
|
||||
set: async (newVal) => {
|
||||
const newResources = resourceBuilder().addResources(newVal).toString()
|
||||
const currentResources = coreResourceIdString.value
|
||||
if (newResources === currentResources) return
|
||||
|
||||
// we don't want this to happen, so lets log it
|
||||
// to see the cause and make it not do that
|
||||
devTrace('Unexpected resourceIdString mutation', {
|
||||
newVal: newResources,
|
||||
oldVal: currentResources
|
||||
})
|
||||
},
|
||||
initialState: route.params.modelId as string,
|
||||
asyncRead: false
|
||||
})
|
||||
|
||||
const savedViewId = computed({
|
||||
get: () => slide.value?.id || null,
|
||||
set: (newVal) => {
|
||||
// we don't want this to happen, so lets log it
|
||||
// to see the cause and make it not do that
|
||||
devTrace('Unexpected savedViewId mutation', { newVal, oldVal: slide.value?.id })
|
||||
}
|
||||
})
|
||||
|
||||
const loadOriginal = ref(false)
|
||||
|
||||
const initParams = computed(
|
||||
(): UseSetupViewerParams => ({
|
||||
projectId,
|
||||
resourceIdString,
|
||||
pageType: ViewerRenderPageType.Presentation,
|
||||
savedView: {
|
||||
id: savedViewId,
|
||||
loadOriginal
|
||||
}
|
||||
})
|
||||
)
|
||||
</script>
|
||||
@@ -7,7 +7,7 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useInjectedViewer } from '~~/lib/viewer/composables/setup'
|
||||
import { useCommentContext } from '~~/lib/viewer/composables/commentManagement'
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
|
||||
const rendererparent = ref<HTMLElement>()
|
||||
const {
|
||||
@@ -16,8 +16,6 @@ const {
|
||||
init: { promise: isInitializedPromise }
|
||||
} = useInjectedViewer()
|
||||
|
||||
const { cleanupThreadContext } = useCommentContext()
|
||||
|
||||
onMounted(async () => {
|
||||
if (!import.meta.client) return
|
||||
|
||||
@@ -26,14 +24,16 @@ onMounted(async () => {
|
||||
rendererparent.value?.appendChild(container)
|
||||
|
||||
viewer.resize()
|
||||
// Not needed
|
||||
// viewer.cameraHandler.onWindowResize()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (!import.meta.client) return
|
||||
container.style.display = 'none'
|
||||
cleanupThreadContext()
|
||||
document.body.appendChild(container)
|
||||
})
|
||||
|
||||
useResizeObserver(rendererparent, () => {
|
||||
if (!import.meta.client) return
|
||||
viewer.resize()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div class="viewer-core-setup flex-1 relative">
|
||||
<div
|
||||
id="viewer"
|
||||
class="viewer special-gradient absolute z-10 overflow-hidden left-0 right-0"
|
||||
:class="viewerHostClasses"
|
||||
>
|
||||
<ClientOnly><ViewerBase /></ClientOnly>
|
||||
<slot name="after-viewer-base" />
|
||||
</div>
|
||||
|
||||
<!-- Global loading bar -->
|
||||
<ViewerLoadingBar :class="loadingBarClasses" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Sets up the actual core viewer renderer (core UI that's shared across various viewer rendering pages like
|
||||
* the viewer, saved view presentations etc.)
|
||||
*/
|
||||
|
||||
defineProps<{
|
||||
viewerHostClasses?: string
|
||||
loadingBarClasses?: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,11 +1,6 @@
|
||||
<template>
|
||||
<div :class="`${loadProgress < 1 && loading ? 'mt-0' : '-mt-5'} transition-all`">
|
||||
<div
|
||||
v-show="loading"
|
||||
:class="`absolute w-full max-w-screen flex justify-center ${
|
||||
!isEmbedEnabled ? 'mt-14' : 'mt-0'
|
||||
} z-50`"
|
||||
>
|
||||
<div :class="containerClasses">
|
||||
<div v-show="loading" class="absolute w-full max-w-screen flex justify-center z-50">
|
||||
<div
|
||||
class="relative bg-blue-500/50 mt-0 h-4 rounded-b-lg select-none px-2 py-1 w-2/3 lg:w-1/3 overflow-hidden"
|
||||
>
|
||||
@@ -23,8 +18,19 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
|
||||
import { useInjectedViewerInterfaceState } from '~~/lib/viewer/composables/setup'
|
||||
const { isEnabled: isEmbedEnabled } = useEmbed()
|
||||
|
||||
const { loading, loadProgress } = useInjectedViewerInterfaceState()
|
||||
|
||||
const containerClasses = computed(() => {
|
||||
const classParts = ['absolute left-0 right-0 z-40 h-30', 'transition-all']
|
||||
|
||||
if (loadProgress.value < 1 && loading.value) {
|
||||
classParts.push('mt-0')
|
||||
} else {
|
||||
classParts.push('-mt-5')
|
||||
}
|
||||
|
||||
return classParts.join(' ')
|
||||
})
|
||||
</script>
|
||||
|
||||
+82
-116
@@ -1,108 +1,98 @@
|
||||
<template>
|
||||
<div>
|
||||
<ViewerPostSetupWrapper>
|
||||
<div class="flex-1">
|
||||
<!-- Nav -->
|
||||
<Portal to="navigation">
|
||||
<ViewerScope :state="state">
|
||||
<template v-if="project?.workspace && isWorkspacesEnabled">
|
||||
<HeaderNavLink
|
||||
:to="workspaceRoute(project?.workspace.slug)"
|
||||
name="Projects"
|
||||
:separator="false"
|
||||
/>
|
||||
</template>
|
||||
<div class="flex-1">
|
||||
<!-- Nav -->
|
||||
<Portal to="navigation">
|
||||
<ViewerScope :state="state">
|
||||
<template v-if="project?.workspace && isWorkspacesEnabled">
|
||||
<HeaderNavLink
|
||||
v-else
|
||||
:to="projectsRoute"
|
||||
:to="workspaceRoute(project?.workspace.slug)"
|
||||
name="Projects"
|
||||
:separator="false"
|
||||
/>
|
||||
<HeaderNavLink :to="`/projects/${project?.id}`" :name="project?.name" />
|
||||
<ViewerExplorerNavbarLink />
|
||||
</ViewerScope>
|
||||
</Portal>
|
||||
|
||||
<ClientOnly>
|
||||
<!-- Viewer host -->
|
||||
<div
|
||||
id="viewer"
|
||||
class="viewer special-gradient absolute z-10 overflow-hidden w-screen"
|
||||
:class="
|
||||
isEmbedEnabled
|
||||
? isTransparent
|
||||
? 'viewer-transparent h-[100dvh]'
|
||||
: 'h-[calc(100dvh-3.5rem)]'
|
||||
: 'h-[100dvh]'
|
||||
"
|
||||
>
|
||||
<ViewerBase />
|
||||
<Transition
|
||||
enter-from-class="opacity-0"
|
||||
enter-active-class="transition duration-1000"
|
||||
>
|
||||
<ViewerAnchoredPoints
|
||||
ref="anchoredPoints"
|
||||
@force-close-panels="() => closeAllPanels('threads')"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Global loading bar -->
|
||||
<ViewerLoadingBar
|
||||
class="absolute left-0 w-full z-40 h-30"
|
||||
:class="isEmbedEnabled ? 'top-0' : ' -top-2'"
|
||||
/>
|
||||
|
||||
<!-- Controls -->
|
||||
<!-- <ViewerControls v-if="showControls" class="relative z-20" /> -->
|
||||
<template v-if="showControls">
|
||||
<ViewerControlsLeft
|
||||
ref="leftControls"
|
||||
@force-close-panels="() => closeAllPanels('left')"
|
||||
/>
|
||||
<ViewerControlsBottom
|
||||
ref="bottomControls"
|
||||
@force-close-panels="() => closeAllPanels('bottom')"
|
||||
/>
|
||||
<ViewerControlsRight v-if="isMobile" />
|
||||
</template>
|
||||
|
||||
<ViewerLimitsDialog
|
||||
v-if="project"
|
||||
v-model:open="showLimitsDialog"
|
||||
:project="project"
|
||||
:resource-id-string="resourceIdString"
|
||||
:limit-type="limitsDialogType"
|
||||
<HeaderNavLink
|
||||
v-else
|
||||
:to="projectsRoute"
|
||||
name="Projects"
|
||||
:separator="false"
|
||||
/>
|
||||
<HeaderNavLink :to="`/projects/${project?.id}`" :name="project?.name" />
|
||||
<ViewerExplorerNavbarLink />
|
||||
</ViewerScope>
|
||||
</Portal>
|
||||
|
||||
<!-- Viewer Object Selection Info Display -->
|
||||
<ViewerCoreSetup
|
||||
:viewer-host-classes="
|
||||
isEmbedEnabled
|
||||
? isTransparent
|
||||
? 'viewer-transparent h-[100dvh]'
|
||||
: 'h-[calc(100dvh-3.5rem)]'
|
||||
: 'h-[100dvh]'
|
||||
"
|
||||
:loading-bar-classes="isEmbedEnabled ? 'top-0' : 'top-12'"
|
||||
>
|
||||
<template #after-viewer-base>
|
||||
<Transition
|
||||
v-if="!hideSelectionInfo"
|
||||
enter-from-class="opacity-0"
|
||||
enter-active-class="transition duration-1000"
|
||||
>
|
||||
<ViewerSelectionSidebar ref="selectionSidebar" class="z-20" />
|
||||
<ViewerAnchoredPoints
|
||||
ref="anchoredPoints"
|
||||
@force-close-panels="() => closeAllPanels('threads')"
|
||||
/>
|
||||
</Transition>
|
||||
<div
|
||||
class="absolute z-10 w-screen px-8 grid grid-cols-1 sm:grid-cols-3 gap-2 top-[3.75rem]"
|
||||
>
|
||||
<div class="flex items-end justify-center sm:justify-start">
|
||||
<PortalTarget name="pocket-left"></PortalTarget>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 items-center justify-end">
|
||||
<PortalTarget name="pocket-tip"></PortalTarget>
|
||||
<div class="flex gap-3">
|
||||
<PortalTarget name="pocket-actions"></PortalTarget>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-end justify-center sm:justify-end">
|
||||
<PortalTarget name="pocket-right"></PortalTarget>
|
||||
</template>
|
||||
</ViewerCoreSetup>
|
||||
|
||||
<ClientOnly>
|
||||
<!-- Controls -->
|
||||
<template v-if="showControls">
|
||||
<ViewerControlsLeft
|
||||
ref="leftControls"
|
||||
@force-close-panels="() => closeAllPanels('left')"
|
||||
/>
|
||||
<ViewerControlsBottom
|
||||
ref="bottomControls"
|
||||
@force-close-panels="() => closeAllPanels('bottom')"
|
||||
/>
|
||||
<ViewerControlsRight v-if="isMobile" />
|
||||
</template>
|
||||
|
||||
<ViewerLimitsDialog
|
||||
v-if="project"
|
||||
v-model:open="showLimitsDialog"
|
||||
:project="project"
|
||||
:resource-id-string="resourceIdString"
|
||||
:limit-type="limitsDialogType"
|
||||
/>
|
||||
|
||||
<!-- Viewer Object Selection Info Display -->
|
||||
<Transition
|
||||
v-if="!hideSelectionInfo"
|
||||
enter-from-class="opacity-0"
|
||||
enter-active-class="transition duration-1000"
|
||||
>
|
||||
<ViewerSelectionSidebar ref="selectionSidebar" class="z-20" />
|
||||
</Transition>
|
||||
<div
|
||||
class="absolute z-10 w-screen px-8 grid grid-cols-1 sm:grid-cols-3 gap-2 top-[3.75rem]"
|
||||
>
|
||||
<div class="flex items-end justify-center sm:justify-start">
|
||||
<PortalTarget name="pocket-left"></PortalTarget>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 items-center justify-end">
|
||||
<PortalTarget name="pocket-tip"></PortalTarget>
|
||||
<div class="flex gap-3">
|
||||
<PortalTarget name="pocket-actions"></PortalTarget>
|
||||
</div>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</ViewerPostSetupWrapper>
|
||||
<div class="flex items-end justify-center sm:justify-end">
|
||||
<PortalTarget name="pocket-right"></PortalTarget>
|
||||
</div>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
<ViewerEmbedFooter
|
||||
:name="modelName || 'Loading...'"
|
||||
:date="lastUpdate"
|
||||
@@ -120,16 +110,12 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
useSetupViewer,
|
||||
type InjectableViewerState
|
||||
} from '~~/lib/viewer/composables/setup'
|
||||
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
|
||||
import dayjs from 'dayjs'
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
|
||||
import { projectsRoute, workspaceRoute } from '~~/lib/common/helpers/route'
|
||||
import { useMixpanel } from '~/lib/core/composables/mp'
|
||||
import { writableAsyncComputed } from '~/lib/common/composables/async'
|
||||
import { parseUrlParameters, resourceBuilder } from '@speckle/shared/viewer/route'
|
||||
import { ViewerLimitsDialogType } from '~/lib/projects/helpers/limits'
|
||||
import { TailwindBreakpoints } from '~~/lib/common/helpers/tailwind'
|
||||
@@ -155,11 +141,6 @@ graphql(`
|
||||
}
|
||||
`)
|
||||
|
||||
const emit = defineEmits<{
|
||||
setup: [InjectableViewerState]
|
||||
}>()
|
||||
|
||||
const router = useSafeRouter()
|
||||
const route = useRoute()
|
||||
const isWorkspacesEnabled = useIsWorkspacesEnabled()
|
||||
const breakpoints = useBreakpoints(TailwindBreakpoints)
|
||||
@@ -170,22 +151,9 @@ const bottomControls = ref()
|
||||
const selectionSidebar = ref()
|
||||
const anchoredPoints = ref()
|
||||
|
||||
const resourceIdString = computed(() => route.params.modelId as string)
|
||||
const projectId = writableAsyncComputed({
|
||||
get: () => route.params.id as string,
|
||||
set: async (value: string) => {
|
||||
// Just rewrite route id param
|
||||
await router.push(() => ({
|
||||
params: { id: value }
|
||||
}))
|
||||
},
|
||||
initialState: route.params.id as string,
|
||||
asyncRead: false
|
||||
})
|
||||
const state = useInjectedViewerState()
|
||||
const resourceIdString = computed(() => state.resources.request.resourceIdString.value)
|
||||
|
||||
const state = useSetupViewer({
|
||||
projectId
|
||||
})
|
||||
const {
|
||||
isEnabled: isEmbedEnabled,
|
||||
hideSelectionInfo,
|
||||
@@ -196,8 +164,6 @@ const {
|
||||
} = useEmbed()
|
||||
const mp = useMixpanel()
|
||||
|
||||
emit('setup', state)
|
||||
|
||||
const {
|
||||
resources: {
|
||||
response: { project, modelsAndVersionIds }
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<ViewerStateSetup :init-params="initParams" @setup="emit('setup', $event)">
|
||||
<ViewerPageSetup />
|
||||
</ViewerStateSetup>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { resourceBuilder } from '@speckle/shared/viewer/route'
|
||||
import { writableAsyncComputed } from '~/lib/common/composables/async'
|
||||
import { ViewerRenderPageType } from '~/lib/viewer/helpers/state'
|
||||
import type {
|
||||
InjectableViewerState,
|
||||
UseSetupViewerParams
|
||||
} from '~~/lib/viewer/composables/setup'
|
||||
|
||||
/**
|
||||
* Only point of this is to prepare state so that ViewerPageSetup can inject and use it properly
|
||||
*/
|
||||
|
||||
const emit = defineEmits<{
|
||||
setup: [InjectableViewerState]
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useSafeRouter()
|
||||
|
||||
// Resolve viewer init params
|
||||
const projectId = writableAsyncComputed({
|
||||
get: () => route.params.id as string,
|
||||
set: async (value: string) => {
|
||||
await router.push(() => ({
|
||||
params: { id: value }
|
||||
}))
|
||||
},
|
||||
initialState: route.params.id as string,
|
||||
asyncRead: false
|
||||
})
|
||||
|
||||
const resourceIdString = writableAsyncComputed({
|
||||
get: () =>
|
||||
resourceBuilder()
|
||||
.addResources(route.params.modelId as string)
|
||||
.toString(),
|
||||
set: async (newVal) => {
|
||||
const newResources = resourceBuilder().addResources(newVal)
|
||||
const currentResources = resourceBuilder().addResources(
|
||||
route.params.modelId as string
|
||||
)
|
||||
if (newResources.toString() === currentResources.toString()) return
|
||||
|
||||
const modelId = newResources.toString()
|
||||
await router.push(
|
||||
() => ({
|
||||
params: { modelId },
|
||||
query: route.query,
|
||||
hash: route.hash
|
||||
}),
|
||||
{
|
||||
skipIf: (to) => {
|
||||
if (to.params.modelId !== route.params.modelId) return false
|
||||
if (to.query !== route.query) return false
|
||||
if (to.hash !== route.hash) return false
|
||||
return true
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
initialState: route.params.modelId as string,
|
||||
asyncRead: false
|
||||
})
|
||||
|
||||
const initParams = computed(
|
||||
(): UseSetupViewerParams => ({
|
||||
projectId,
|
||||
resourceIdString,
|
||||
pageType: ViewerRenderPageType.Viewer
|
||||
})
|
||||
)
|
||||
</script>
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div><slot /></div>
|
||||
<div class="viewer-core-post-setup"><slot /></div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useViewerPostSetup } from '~~/lib/viewer/composables/setup/postSetup'
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div class="viewer-state-setup">
|
||||
<ViewerStatePostSetup><slot /></ViewerStatePostSetup>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
useSetupViewer,
|
||||
type UseSetupViewerParams
|
||||
} from '~/lib/viewer/composables/setup'
|
||||
import type { InjectableViewerState } from '~/lib/viewer/composables/setup/core'
|
||||
|
||||
const emit = defineEmits<{
|
||||
setup: [InjectableViewerState]
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
// Passing in a wrapper object so that the refs don't get unwrapped. We want the full
|
||||
// AsyncWritableComputed objects here
|
||||
initParams: UseSetupViewerParams
|
||||
}>()
|
||||
|
||||
// initParams isnt reactive, but the refs inside of it definitely are
|
||||
const state = useSetupViewer(props.initParams)
|
||||
emit('setup', state)
|
||||
</script>
|
||||
@@ -68,6 +68,7 @@ type Documents = {
|
||||
"\n fragment InviteDialogSharedSelectUsers_Workspace on Workspace {\n id\n slug\n defaultSeatType\n }\n": typeof types.InviteDialogSharedSelectUsers_WorkspaceFragmentDoc,
|
||||
"\n fragment PresentationSlidesSidebar_SavedViewGroup on SavedViewGroup {\n id\n views(input: $input) {\n items {\n ...PresentationSlidesLeftSidebarSlide_SavedView\n visibility\n id\n }\n }\n }\n": typeof types.PresentationSlidesSidebar_SavedViewGroupFragmentDoc,
|
||||
"\n fragment PresentationSlidesLeftSidebarSlide_SavedView on SavedView {\n id\n name\n screenshot\n visibility\n }\n": typeof types.PresentationSlidesLeftSidebarSlide_SavedViewFragmentDoc,
|
||||
"\n fragment PresentationViewerPageWrapper_SavedViewGroup on SavedViewGroup {\n id\n views(input: $input) {\n totalCount\n items {\n id\n resourceIdString\n }\n }\n }\n": typeof types.PresentationViewerPageWrapper_SavedViewGroupFragmentDoc,
|
||||
"\n fragment ProjectCardImportFileArea_Project on Project {\n id\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n }\n ...UseFileImport_Project\n }\n": typeof types.ProjectCardImportFileArea_ProjectFragmentDoc,
|
||||
"\n fragment ProjectCardImportFileArea_Model on Model {\n id\n name\n permissions {\n canCreateVersion {\n ...FullPermissionCheckResult\n }\n }\n ...UseFileImport_Model\n }\n": typeof types.ProjectCardImportFileArea_ModelFragmentDoc,
|
||||
"\n fragment ProjectInviteAdd_Project on Project {\n id\n ...InviteDialogProject_Project\n ...UseCanInviteToProject_Project\n ...WorkspaceMoveProject_Project\n }\n": typeof types.ProjectInviteAdd_ProjectFragmentDoc,
|
||||
@@ -286,6 +287,7 @@ type Documents = {
|
||||
"\n query NavigationWorkspaceList(\n $workspaceFilter: UserWorkspacesFilter\n $projectFilter: UserProjectsFilter\n ) {\n activeUser {\n id\n workspaces(filter: $workspaceFilter) {\n items {\n id\n ...HeaderWorkspaceSwitcherWorkspaceListItem_Workspace\n }\n }\n projects(filter: $projectFilter) {\n totalCount\n }\n }\n }\n": typeof types.NavigationWorkspaceListDocument,
|
||||
"\n query NavigationProjectInvites {\n activeUser {\n id\n projectInvites {\n ...HeaderNavNotificationsProjectInvite_PendingStreamCollaborator\n }\n }\n }\n": typeof types.NavigationProjectInvitesDocument,
|
||||
"\n query NavigationWorkspaceInvites {\n activeUser {\n id\n workspaceInvites {\n ...HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator\n }\n }\n }\n": typeof types.NavigationWorkspaceInvitesDocument,
|
||||
"\n query ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n workspace {\n id\n name\n logo\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationSlidesSidebar_SavedViewGroup\n ...PresentationViewerPageWrapper_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n screenshot\n projectId\n visibility\n group {\n id\n }\n }\n }\n }\n }\n }\n": typeof types.ProjectPresentationPageDocument,
|
||||
"\n fragment UseCopyModelLink_Model on Model {\n id\n projectId\n ...GetModelItemRoute_Model\n }\n": typeof types.UseCopyModelLink_ModelFragmentDoc,
|
||||
"\n fragment UseCanCreatePersonalProject_User on User {\n permissions {\n canCreatePersonalProject {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.UseCanCreatePersonalProject_UserFragmentDoc,
|
||||
"\n fragment UseCanCreateWorkspace_User on User {\n permissions {\n canCreateWorkspace {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.UseCanCreateWorkspace_UserFragmentDoc,
|
||||
@@ -524,8 +526,6 @@ type Documents = {
|
||||
"\n fragment ProjectPageAutomationPage_Automation on Automation {\n id\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageAutomationHeader_Automation\n ...ProjectPageAutomationFunctions_Automation\n ...ProjectPageAutomationRuns_Automation\n }\n": typeof types.ProjectPageAutomationPage_AutomationFragmentDoc,
|
||||
"\n fragment ProjectPageAutomationPage_Project on Project {\n id\n workspaceId\n ...ProjectPageAutomationHeader_Project\n }\n": typeof types.ProjectPageAutomationPage_ProjectFragmentDoc,
|
||||
"\n fragment ProjectPageSettingsTab_Project on Project {\n id\n name\n permissions {\n canReadWebhooks {\n ...FullPermissionCheckResult\n }\n canReadEmbedTokens {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.ProjectPageSettingsTab_ProjectFragmentDoc,
|
||||
"\n fragment ProjectPresentations_SavedViewGroup on SavedViewGroup {\n id\n }\n": typeof types.ProjectPresentations_SavedViewGroupFragmentDoc,
|
||||
"\n query ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n workspace {\n id\n name\n logo\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationSlidesSidebar_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n screenshot\n projectId\n visibility\n group {\n id\n }\n }\n }\n }\n }\n }\n": typeof types.ProjectPresentationPageDocument,
|
||||
"\n fragment SettingsServerProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\n }\n }\n": typeof types.SettingsServerProjects_ProjectCollectionFragmentDoc,
|
||||
"\n query SettingsServerRegions {\n serverInfo {\n multiRegion {\n regions {\n id\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n availableKeys\n }\n }\n }\n": typeof types.SettingsServerRegionsDocument,
|
||||
"\n fragment SettingsWorkspacesGeneral_Workspace on Workspace {\n ...SettingsWorkspacesGeneralEditAvatar_Workspace\n ...SettingsWorkspaceGeneralDeleteDialog_Workspace\n ...SettingsWorkspacesGeneralEditSlugDialog_Workspace\n id\n name\n slug\n description\n logo\n role\n plan {\n status\n name\n }\n embedOptions {\n hideSpeckleBranding\n }\n permissions {\n canEditEmbedOptions {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.SettingsWorkspacesGeneral_WorkspaceFragmentDoc,
|
||||
@@ -593,6 +593,7 @@ const documents: Documents = {
|
||||
"\n fragment InviteDialogSharedSelectUsers_Workspace on Workspace {\n id\n slug\n defaultSeatType\n }\n": types.InviteDialogSharedSelectUsers_WorkspaceFragmentDoc,
|
||||
"\n fragment PresentationSlidesSidebar_SavedViewGroup on SavedViewGroup {\n id\n views(input: $input) {\n items {\n ...PresentationSlidesLeftSidebarSlide_SavedView\n visibility\n id\n }\n }\n }\n": types.PresentationSlidesSidebar_SavedViewGroupFragmentDoc,
|
||||
"\n fragment PresentationSlidesLeftSidebarSlide_SavedView on SavedView {\n id\n name\n screenshot\n visibility\n }\n": types.PresentationSlidesLeftSidebarSlide_SavedViewFragmentDoc,
|
||||
"\n fragment PresentationViewerPageWrapper_SavedViewGroup on SavedViewGroup {\n id\n views(input: $input) {\n totalCount\n items {\n id\n resourceIdString\n }\n }\n }\n": types.PresentationViewerPageWrapper_SavedViewGroupFragmentDoc,
|
||||
"\n fragment ProjectCardImportFileArea_Project on Project {\n id\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n }\n ...UseFileImport_Project\n }\n": types.ProjectCardImportFileArea_ProjectFragmentDoc,
|
||||
"\n fragment ProjectCardImportFileArea_Model on Model {\n id\n name\n permissions {\n canCreateVersion {\n ...FullPermissionCheckResult\n }\n }\n ...UseFileImport_Model\n }\n": types.ProjectCardImportFileArea_ModelFragmentDoc,
|
||||
"\n fragment ProjectInviteAdd_Project on Project {\n id\n ...InviteDialogProject_Project\n ...UseCanInviteToProject_Project\n ...WorkspaceMoveProject_Project\n }\n": types.ProjectInviteAdd_ProjectFragmentDoc,
|
||||
@@ -811,6 +812,7 @@ const documents: Documents = {
|
||||
"\n query NavigationWorkspaceList(\n $workspaceFilter: UserWorkspacesFilter\n $projectFilter: UserProjectsFilter\n ) {\n activeUser {\n id\n workspaces(filter: $workspaceFilter) {\n items {\n id\n ...HeaderWorkspaceSwitcherWorkspaceListItem_Workspace\n }\n }\n projects(filter: $projectFilter) {\n totalCount\n }\n }\n }\n": types.NavigationWorkspaceListDocument,
|
||||
"\n query NavigationProjectInvites {\n activeUser {\n id\n projectInvites {\n ...HeaderNavNotificationsProjectInvite_PendingStreamCollaborator\n }\n }\n }\n": types.NavigationProjectInvitesDocument,
|
||||
"\n query NavigationWorkspaceInvites {\n activeUser {\n id\n workspaceInvites {\n ...HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator\n }\n }\n }\n": types.NavigationWorkspaceInvitesDocument,
|
||||
"\n query ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n workspace {\n id\n name\n logo\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationSlidesSidebar_SavedViewGroup\n ...PresentationViewerPageWrapper_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n screenshot\n projectId\n visibility\n group {\n id\n }\n }\n }\n }\n }\n }\n": types.ProjectPresentationPageDocument,
|
||||
"\n fragment UseCopyModelLink_Model on Model {\n id\n projectId\n ...GetModelItemRoute_Model\n }\n": types.UseCopyModelLink_ModelFragmentDoc,
|
||||
"\n fragment UseCanCreatePersonalProject_User on User {\n permissions {\n canCreatePersonalProject {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.UseCanCreatePersonalProject_UserFragmentDoc,
|
||||
"\n fragment UseCanCreateWorkspace_User on User {\n permissions {\n canCreateWorkspace {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.UseCanCreateWorkspace_UserFragmentDoc,
|
||||
@@ -1049,8 +1051,6 @@ const documents: Documents = {
|
||||
"\n fragment ProjectPageAutomationPage_Automation on Automation {\n id\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageAutomationHeader_Automation\n ...ProjectPageAutomationFunctions_Automation\n ...ProjectPageAutomationRuns_Automation\n }\n": types.ProjectPageAutomationPage_AutomationFragmentDoc,
|
||||
"\n fragment ProjectPageAutomationPage_Project on Project {\n id\n workspaceId\n ...ProjectPageAutomationHeader_Project\n }\n": types.ProjectPageAutomationPage_ProjectFragmentDoc,
|
||||
"\n fragment ProjectPageSettingsTab_Project on Project {\n id\n name\n permissions {\n canReadWebhooks {\n ...FullPermissionCheckResult\n }\n canReadEmbedTokens {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.ProjectPageSettingsTab_ProjectFragmentDoc,
|
||||
"\n fragment ProjectPresentations_SavedViewGroup on SavedViewGroup {\n id\n }\n": types.ProjectPresentations_SavedViewGroupFragmentDoc,
|
||||
"\n query ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n workspace {\n id\n name\n logo\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationSlidesSidebar_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n screenshot\n projectId\n visibility\n group {\n id\n }\n }\n }\n }\n }\n }\n": types.ProjectPresentationPageDocument,
|
||||
"\n fragment SettingsServerProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\n }\n }\n": types.SettingsServerProjects_ProjectCollectionFragmentDoc,
|
||||
"\n query SettingsServerRegions {\n serverInfo {\n multiRegion {\n regions {\n id\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n availableKeys\n }\n }\n }\n": types.SettingsServerRegionsDocument,
|
||||
"\n fragment SettingsWorkspacesGeneral_Workspace on Workspace {\n ...SettingsWorkspacesGeneralEditAvatar_Workspace\n ...SettingsWorkspaceGeneralDeleteDialog_Workspace\n ...SettingsWorkspacesGeneralEditSlugDialog_Workspace\n id\n name\n slug\n description\n logo\n role\n plan {\n status\n name\n }\n embedOptions {\n hideSpeckleBranding\n }\n permissions {\n canEditEmbedOptions {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.SettingsWorkspacesGeneral_WorkspaceFragmentDoc,
|
||||
@@ -1294,6 +1294,10 @@ export function graphql(source: "\n fragment PresentationSlidesSidebar_SavedVie
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment PresentationSlidesLeftSidebarSlide_SavedView on SavedView {\n id\n name\n screenshot\n visibility\n }\n"): (typeof documents)["\n fragment PresentationSlidesLeftSidebarSlide_SavedView on SavedView {\n id\n name\n screenshot\n visibility\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment PresentationViewerPageWrapper_SavedViewGroup on SavedViewGroup {\n id\n views(input: $input) {\n totalCount\n items {\n id\n resourceIdString\n }\n }\n }\n"): (typeof documents)["\n fragment PresentationViewerPageWrapper_SavedViewGroup on SavedViewGroup {\n id\n views(input: $input) {\n totalCount\n items {\n id\n resourceIdString\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -2166,6 +2170,10 @@ export function graphql(source: "\n query NavigationProjectInvites {\n activ
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query NavigationWorkspaceInvites {\n activeUser {\n id\n workspaceInvites {\n ...HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator\n }\n }\n }\n"): (typeof documents)["\n query NavigationWorkspaceInvites {\n activeUser {\n id\n workspaceInvites {\n ...HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n workspace {\n id\n name\n logo\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationSlidesSidebar_SavedViewGroup\n ...PresentationViewerPageWrapper_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n screenshot\n projectId\n visibility\n group {\n id\n }\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n workspace {\n id\n name\n logo\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationSlidesSidebar_SavedViewGroup\n ...PresentationViewerPageWrapper_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n screenshot\n projectId\n visibility\n group {\n id\n }\n }\n }\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -3118,14 +3126,6 @@ export function graphql(source: "\n fragment ProjectPageAutomationPage_Project
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment ProjectPageSettingsTab_Project on Project {\n id\n name\n permissions {\n canReadWebhooks {\n ...FullPermissionCheckResult\n }\n canReadEmbedTokens {\n ...FullPermissionCheckResult\n }\n }\n }\n"): (typeof documents)["\n fragment ProjectPageSettingsTab_Project on Project {\n id\n name\n permissions {\n canReadWebhooks {\n ...FullPermissionCheckResult\n }\n canReadEmbedTokens {\n ...FullPermissionCheckResult\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment ProjectPresentations_SavedViewGroup on SavedViewGroup {\n id\n }\n"): (typeof documents)["\n fragment ProjectPresentations_SavedViewGroup on SavedViewGroup {\n id\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n workspace {\n id\n name\n logo\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationSlidesSidebar_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n screenshot\n projectId\n visibility\n group {\n id\n }\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n workspace {\n id\n name\n logo\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationSlidesSidebar_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n screenshot\n projectId\n visibility\n group {\n id\n }\n }\n }\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,148 @@
|
||||
import type { Optional } from '@speckle/shared'
|
||||
import { resourceBuilder } from '@speckle/shared/viewer/route'
|
||||
import type { AsyncWritableComputedRef } from '@speckle/ui-components'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import type { Get } from 'type-fest'
|
||||
import {
|
||||
SavedViewVisibility,
|
||||
type ProjectPresentationPageQuery
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
import { projectPresentationPageQuery } from '~/lib/presentations/graphql/queries'
|
||||
|
||||
type ResponseProject = Optional<Get<ProjectPresentationPageQuery, 'project'>>
|
||||
type ResponseWorkspace = Get<ProjectPresentationPageQuery, 'project.workspace'>
|
||||
type ResponseGroup = Get<ResponseProject, 'savedViewGroup'>
|
||||
type ResponseView = NonNullable<Get<ResponseGroup, 'views.items.0'>>
|
||||
|
||||
export type InjectablePresentationState = Readonly<{
|
||||
projectId: AsyncWritableComputedRef<string>
|
||||
presentationId: AsyncWritableComputedRef<string>
|
||||
response: {
|
||||
project: ComputedRef<ResponseProject>
|
||||
workspace: ComputedRef<ResponseWorkspace>
|
||||
presentation: ComputedRef<ResponseGroup>
|
||||
slides: ComputedRef<ResponseView[]>
|
||||
/**
|
||||
* We only show public slides
|
||||
*/
|
||||
visibleSlides: ComputedRef<ResponseView[]>
|
||||
}
|
||||
ui: {
|
||||
/**
|
||||
* Current slide to show (0 based indexing). Indexes are based on visibleSlides, not slides.
|
||||
*/
|
||||
slideIdx: Ref<number>
|
||||
slide: ComputedRef<ResponseView | undefined>
|
||||
}
|
||||
viewer: {
|
||||
/**
|
||||
* The actual resource id string to load in the viewer - built from presentation metadata,
|
||||
* active slide etc.
|
||||
*/
|
||||
resourceIdString: ComputedRef<string>
|
||||
}
|
||||
}>
|
||||
|
||||
type InitState = Pick<InjectablePresentationState, 'projectId' | 'presentationId'>
|
||||
type ResponseState = Pick<InjectablePresentationState, 'response'>
|
||||
type UiState = Pick<InjectablePresentationState, 'ui'>
|
||||
type ViewerState = Pick<InjectablePresentationState, 'viewer'>
|
||||
|
||||
export const InjectablePresentationStateKey: InjectionKey<InjectablePresentationState> =
|
||||
Symbol('INJECTABLE_PRESENTATION_STATE')
|
||||
|
||||
export type UseSetupPresentationParams = {
|
||||
projectId: AsyncWritableComputedRef<string>
|
||||
presentationId: AsyncWritableComputedRef<string>
|
||||
}
|
||||
|
||||
const setupStateResponse = (initState: InitState): ResponseState => {
|
||||
const { result } = useQuery(projectPresentationPageQuery, () => ({
|
||||
projectId: initState.projectId.value,
|
||||
savedViewGroupId: initState.presentationId.value,
|
||||
input: {
|
||||
limit: 100
|
||||
}
|
||||
}))
|
||||
|
||||
const project = computed(() => result.value?.project)
|
||||
const presentation = computed(() => project.value?.savedViewGroup)
|
||||
const workspace = computed(() => project.value?.workspace)
|
||||
const slides = computed(() => presentation.value?.views.items || [])
|
||||
|
||||
const visibleSlides = computed(() =>
|
||||
slides.value.filter((view) => view.visibility === SavedViewVisibility.Public)
|
||||
)
|
||||
|
||||
return {
|
||||
response: {
|
||||
project,
|
||||
workspace,
|
||||
presentation,
|
||||
slides,
|
||||
visibleSlides
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const setupStateViewer = (initState: ResponseState & UiState): ViewerState => {
|
||||
const {
|
||||
response: { presentation },
|
||||
ui: { slideIdx }
|
||||
} = initState
|
||||
|
||||
const resourceIdString = computed(() => {
|
||||
const slides = presentation.value?.views.items || []
|
||||
|
||||
return resourceBuilder()
|
||||
.addResources(slides.at(slideIdx.value)?.resourceIdString || '')
|
||||
.toString()
|
||||
})
|
||||
|
||||
return { viewer: { resourceIdString } }
|
||||
}
|
||||
|
||||
const setupStateUi = (initState: ResponseState): UiState => {
|
||||
const slideIdx = ref(0)
|
||||
|
||||
const slide = computed(() => {
|
||||
const slides = initState.response.visibleSlides.value
|
||||
return slides.at(slideIdx.value)
|
||||
})
|
||||
|
||||
return {
|
||||
ui: {
|
||||
slideIdx,
|
||||
slide
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const useSetupPresentationState = (params: UseSetupPresentationParams) => {
|
||||
const initState: InitState = params
|
||||
const responseState = setupStateResponse(initState)
|
||||
const uiState = setupStateUi(responseState)
|
||||
const viewerState = setupStateViewer({ ...responseState, ...uiState })
|
||||
|
||||
const state: InjectablePresentationState = {
|
||||
...initState,
|
||||
...responseState,
|
||||
...uiState,
|
||||
...viewerState
|
||||
}
|
||||
|
||||
// We don't want the state to ever be proxified (e.g. when passed through props),
|
||||
// cause that will break composables (refs will be automatically unwrapped as if
|
||||
// they're accessed in a template)
|
||||
const rawState = markRaw(state)
|
||||
|
||||
provide(InjectablePresentationStateKey, rawState)
|
||||
return rawState
|
||||
}
|
||||
|
||||
export const useInjectedPresentationState = (): InjectablePresentationState => {
|
||||
// we're forcing TS to ignore the scenario where this data can't be found and returns undefined
|
||||
// to avoid unnecessary null checks everywhere
|
||||
const state = inject(InjectablePresentationStateKey) as InjectablePresentationState
|
||||
return state
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { graphql } from '~/lib/common/generated/gql/gql'
|
||||
|
||||
export const projectPresentationPageQuery = graphql(`
|
||||
query ProjectPresentationPage(
|
||||
$input: SavedViewGroupViewsInput!
|
||||
$savedViewGroupId: ID!
|
||||
$projectId: String!
|
||||
) {
|
||||
project(id: $projectId) {
|
||||
id
|
||||
workspace {
|
||||
id
|
||||
name
|
||||
logo
|
||||
}
|
||||
savedViewGroup(id: $savedViewGroupId) {
|
||||
id
|
||||
title
|
||||
...PresentationSlidesSidebar_SavedViewGroup
|
||||
...PresentationViewerPageWrapper_SavedViewGroup
|
||||
views(input: $input) {
|
||||
totalCount
|
||||
items {
|
||||
id
|
||||
name
|
||||
description
|
||||
screenshot
|
||||
projectId
|
||||
visibility
|
||||
group {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
@@ -6,15 +6,18 @@ import {
|
||||
} from '~/lib/viewer/composables/serialization'
|
||||
import {
|
||||
useInjectedViewerState,
|
||||
type InitialSetupState
|
||||
type InitialSetupState,
|
||||
type UseSetupViewerParams
|
||||
} from '~/lib/viewer/composables/setup'
|
||||
import type { SavedViewUrlSettings } from '~/lib/viewer/helpers/savedViews'
|
||||
import { ViewerRenderPageType } from '~/lib/viewer/helpers/state'
|
||||
|
||||
/**
|
||||
* Invoke in postSetup
|
||||
*/
|
||||
export const useViewerSavedViewIntegration = () => {
|
||||
const {
|
||||
pageType,
|
||||
resources: {
|
||||
request: {
|
||||
savedView: { id: savedViewId, loadOriginal }
|
||||
@@ -77,6 +80,9 @@ export const useViewerSavedViewIntegration = () => {
|
||||
}
|
||||
|
||||
const reset = async () => {
|
||||
// No such thing as a reset in presentation mode - we always have a view active
|
||||
if (pageType.value === ViewerRenderPageType.Presentation) return
|
||||
|
||||
savedViewId.value = null
|
||||
loadOriginal.value = false
|
||||
savedViewStateId.value = undefined
|
||||
@@ -88,23 +94,20 @@ export const useViewerSavedViewIntegration = () => {
|
||||
await update({ settings })
|
||||
})
|
||||
|
||||
// // Apply saved view state on initial load
|
||||
// useOnViewerLoadComplete(async ({ isInitial }) => {
|
||||
// if (isInitial) {
|
||||
// await apply()
|
||||
// }
|
||||
// })
|
||||
|
||||
// Saved view changed, apply
|
||||
watch(savedView, async (newVal, oldVal) => {
|
||||
if (!newVal || newVal.id === oldVal?.id) return
|
||||
watch(
|
||||
savedView,
|
||||
async (newVal, oldVal) => {
|
||||
if (!newVal || newVal.id === oldVal?.id) return
|
||||
|
||||
const state = validState(newVal.viewerState)
|
||||
if (!state) return
|
||||
const state = validState(newVal.viewerState)
|
||||
if (!state) return
|
||||
|
||||
// If the saved view has changed, apply it
|
||||
await apply()
|
||||
})
|
||||
// If the saved view has changed, apply it
|
||||
await apply()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => serializedStateId.value,
|
||||
@@ -136,30 +139,37 @@ export const useBuildSavedViewsUIState = () => {
|
||||
}
|
||||
}
|
||||
|
||||
export const useBuildSavedViewsCoreState = (state: InitialSetupState) => {
|
||||
export const useBuildSavedViewsCoreState = (
|
||||
state: InitialSetupState,
|
||||
initParams: UseSetupViewerParams
|
||||
) => {
|
||||
const {
|
||||
urlHashState: { savedView: urlHashStateSavedViewSettings }
|
||||
} = state
|
||||
|
||||
const savedViewId = ref<string | null | undefined>(undefined)
|
||||
const loadOriginal = ref<boolean>(false)
|
||||
const savedViewId =
|
||||
initParams?.savedView?.id || ref<string | null | undefined>(undefined)
|
||||
const loadOriginal = initParams?.savedView?.loadOriginal || ref<boolean>(false)
|
||||
|
||||
// Usually this watcher would happen in post-setup, but its critical that this is fired
|
||||
// early, before any of the GQL queries fire:
|
||||
// Url hash state -> core source of truth sync
|
||||
watch(
|
||||
urlHashStateSavedViewSettings,
|
||||
async (newVal) => {
|
||||
if ((newVal?.id || null) !== (savedViewId.value || null)) {
|
||||
savedViewId.value = newVal?.id || null
|
||||
}
|
||||
// Dont care about urlHashState in presentation mode
|
||||
if (state.pageType.value !== ViewerRenderPageType.Presentation) {
|
||||
// Usually this watcher would happen in post-setup, but its critical that this is fired
|
||||
// early, before any of the GQL queries fire:
|
||||
// Url hash state -> core source of truth sync
|
||||
watch(
|
||||
urlHashStateSavedViewSettings,
|
||||
async (newVal) => {
|
||||
if ((newVal?.id || null) !== (savedViewId.value || null)) {
|
||||
savedViewId.value = newVal?.id || null
|
||||
}
|
||||
|
||||
if ((newVal?.loadOriginal || false) !== loadOriginal.value) {
|
||||
loadOriginal.value = newVal?.loadOriginal || false
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
if ((newVal?.loadOriginal || false) !== loadOriginal.value) {
|
||||
loadOriginal.value = newVal?.loadOriginal || false
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
id: savedViewId,
|
||||
|
||||
@@ -100,6 +100,7 @@ import { useViewModesSetup } from '~/lib/viewer/composables/setup/viewMode'
|
||||
import { useMeasurementsSetup } from '~/lib/viewer/composables/setup/measurements'
|
||||
import { useFiltersSetup } from '~/lib/viewer/composables/setup/filters'
|
||||
import { useViewerPanelsSetup } from '~/lib/viewer/composables/setup/panels'
|
||||
import { ViewerRenderPageType } from '~/lib/viewer/helpers/state'
|
||||
|
||||
export type LoadedModel = NonNullable<
|
||||
Get<ViewerLoadedResourcesQuery, 'project.models.items[0]'>
|
||||
@@ -124,6 +125,13 @@ export type InjectableViewerState = Readonly<{
|
||||
* This is used to ignore user activity messages from the same tab.
|
||||
*/
|
||||
sessionId: ComputedRef<string>
|
||||
/**
|
||||
* The type of page that this state is powering. Based on this, certain features/UIs
|
||||
* can be toggled.
|
||||
*
|
||||
* Default: Viewer (main viewer page), but can also be Presentation
|
||||
*/
|
||||
pageType: ComputedRef<ViewerRenderPageType>
|
||||
/**
|
||||
* The actual Viewer instance and related objects.
|
||||
* Note: This is going to be undefined in SSR!
|
||||
@@ -403,7 +411,7 @@ type CachedViewerState = Pick<
|
||||
|
||||
export type InitialSetupState = Pick<
|
||||
InjectableViewerState,
|
||||
'projectId' | 'viewer' | 'sessionId' | 'urlHashState'
|
||||
'projectId' | 'viewer' | 'sessionId' | 'urlHashState' | 'pageType'
|
||||
>
|
||||
|
||||
type InitialStateWithRequest = InitialSetupState & {
|
||||
@@ -505,16 +513,20 @@ function setupInitialState(params: UseSetupViewerParams): InitialSetupState {
|
||||
public: { viewerDebug }
|
||||
} = useRuntimeConfig()
|
||||
|
||||
const route = useRoute()
|
||||
const sessionId = computed(() => nanoid())
|
||||
const isInitialized = ref(false)
|
||||
const { instance, initPromise, container } = useScopedState(
|
||||
GlobalViewerDataKey,
|
||||
createViewerDataBuilder({ viewerDebug })
|
||||
createViewerDataBuilder({
|
||||
viewerDebug: viewerDebug || route.query.viewerVerbose === '1'
|
||||
})
|
||||
) || { initPromise: Promise.resolve() }
|
||||
initPromise.then(() => (isInitialized.value = true))
|
||||
const hasDoneInitialLoad = ref(false)
|
||||
|
||||
return {
|
||||
pageType: computed(() => params.pageType),
|
||||
projectId: params.projectId,
|
||||
sessionId,
|
||||
viewer: import.meta.server
|
||||
@@ -550,50 +562,22 @@ function setupInitialState(params: UseSetupViewerParams): InitialSetupState {
|
||||
/**
|
||||
* Setup resource requests (tied to URL resource identifier param)
|
||||
*/
|
||||
function setupResourceRequest(state: InitialSetupState): InitialStateWithRequest {
|
||||
const route = useRoute()
|
||||
const router = useSafeRouter()
|
||||
const getParam = computed(() => route.params.modelId as string)
|
||||
function setupResourceRequest(
|
||||
state: InitialSetupState,
|
||||
params: UseSetupViewerParams
|
||||
): InitialStateWithRequest {
|
||||
const resourceIdString = params.resourceIdString
|
||||
|
||||
const resources = writableAsyncComputed({
|
||||
get: () => parseUrlParameters(getParam.value),
|
||||
get: () => resourceBuilder().addResources(resourceIdString.value).toResources(),
|
||||
set: async (newResources) => {
|
||||
const modelId = createGetParamFromResources(newResources)
|
||||
await router.push(
|
||||
() => ({
|
||||
params: { modelId },
|
||||
query: route.query,
|
||||
hash: route.hash
|
||||
}),
|
||||
{
|
||||
skipIf: (to) => {
|
||||
if (to.params.modelId !== getParam.value) return false
|
||||
if (to.query !== route.query) return false
|
||||
if (to.hash !== route.hash) return false
|
||||
return true
|
||||
}
|
||||
}
|
||||
)
|
||||
const newIdString = createGetParamFromResources(newResources)
|
||||
await resourceIdString.update(newIdString)
|
||||
},
|
||||
initialState: [],
|
||||
asyncRead: false
|
||||
})
|
||||
|
||||
// we could use getParam, but `createGetParamFromResources` does sorting and de-duplication AFAIK
|
||||
// + we can skip duplicate updates
|
||||
const resourceIdString = writableAsyncComputed({
|
||||
get: () => createGetParamFromResources(resources.value),
|
||||
set: async (newVal) => {
|
||||
const newResources = resourceBuilder().addResources(parseUrlParameters(newVal))
|
||||
const currentResources = resourceBuilder().addResources(resources.value)
|
||||
if (newResources.toString() === currentResources.toString()) return
|
||||
|
||||
await resources.update(newResources.toResources())
|
||||
},
|
||||
initialState: '',
|
||||
asyncRead: false
|
||||
})
|
||||
|
||||
const discussionLoadedVersionOnly = useSynchronizedCookie<boolean>(
|
||||
'discussionLoadedVersionOnly',
|
||||
{
|
||||
@@ -638,7 +622,7 @@ function setupResourceRequest(state: InitialSetupState): InitialStateWithRequest
|
||||
...state,
|
||||
resources: {
|
||||
request: {
|
||||
savedView: useBuildSavedViewsCoreState(state),
|
||||
savedView: useBuildSavedViewsCoreState(state, params),
|
||||
items: resources,
|
||||
resourceIdString,
|
||||
threadFilters,
|
||||
@@ -1052,7 +1036,11 @@ function setupResponseResourceData(
|
||||
resourceIdString: resourceIdString.value
|
||||
}
|
||||
}),
|
||||
{ keepPreviousResult: true }
|
||||
() => ({
|
||||
keepPreviousResult: true,
|
||||
// Dont need threads when in presentation mode
|
||||
enabled: state.pageType.value !== ViewerRenderPageType.Presentation
|
||||
})
|
||||
)
|
||||
|
||||
const commentThreadsMetadata = computed(
|
||||
@@ -1213,13 +1201,24 @@ function setupInterfaceState(
|
||||
}
|
||||
}
|
||||
|
||||
type UseSetupViewerParams = { projectId: AsyncWritableComputedRef<string> }
|
||||
export type UseSetupViewerParams = {
|
||||
projectId: AsyncWritableComputedRef<string>
|
||||
resourceIdString: AsyncWritableComputedRef<string>
|
||||
pageType: ViewerRenderPageType
|
||||
/**
|
||||
* Optionally override savedView source of truth
|
||||
*/
|
||||
savedView?: {
|
||||
id: InjectableViewerState['resources']['request']['savedView']['id']
|
||||
loadOriginal: InjectableViewerState['resources']['request']['savedView']['loadOriginal']
|
||||
}
|
||||
}
|
||||
|
||||
export function useSetupViewer(params: UseSetupViewerParams): InjectableViewerState {
|
||||
// Initialize full state object - each subsequent state initialization depends on
|
||||
// the results of the previous ones until we have the final full object
|
||||
const initState = setupInitialState(params)
|
||||
const initialStateWithRequest = setupResourceRequest(initState)
|
||||
const initialStateWithRequest = setupResourceRequest(initState, params)
|
||||
const stateWithResources = setupResourceResponse(initialStateWithRequest)
|
||||
const state: InjectableViewerState = setupInterfaceState(stateWithResources)
|
||||
|
||||
|
||||
@@ -35,7 +35,10 @@ import {
|
||||
useViewerCameraTracker,
|
||||
useViewerEventListener
|
||||
} from '~~/lib/viewer/composables/viewer'
|
||||
import { useViewerCommentUpdateTracking } from '~~/lib/viewer/composables/commentManagement'
|
||||
import {
|
||||
useCommentContext,
|
||||
useViewerCommentUpdateTracking
|
||||
} from '~~/lib/viewer/composables/commentManagement'
|
||||
import { getCacheId } from '~~/lib/common/helpers/graphql'
|
||||
import {
|
||||
useViewerOpenedThreadUpdateEmitter,
|
||||
@@ -841,6 +844,14 @@ function useViewerCursorIntegration() {
|
||||
})
|
||||
}
|
||||
|
||||
const useCommentContextIntegration = () => {
|
||||
const { cleanupThreadContext } = useCommentContext()
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupThreadContext()
|
||||
})
|
||||
}
|
||||
|
||||
export function useViewerPostSetup() {
|
||||
if (import.meta.server) return
|
||||
useViewerObjectAutoLoading()
|
||||
@@ -866,5 +877,6 @@ export function useViewerPostSetup() {
|
||||
useViewerTreeIntegration()
|
||||
useViewModesPostSetup()
|
||||
useHighlightingPostSetup()
|
||||
useCommentContextIntegration()
|
||||
setupDebugMode()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { StringEnum, type StringEnumValues } from '@speckle/shared'
|
||||
|
||||
export const ViewerRenderPageType = StringEnum(['Viewer', 'Presentation'])
|
||||
export type ViewerRenderPageType = StringEnumValues<typeof ViewerRenderPageType>
|
||||
@@ -2,7 +2,7 @@
|
||||
<div :class="isTransparent ? 'viewer-transparent' : ''">
|
||||
<ClientOnly>
|
||||
<ViewerEmbedManualLoad v-if="isManualLoad" @play="isManualLoad = false" />
|
||||
<LazyViewerPreSetupWrapper v-else @setup="state = $event" />
|
||||
<LazyViewerPageWrapper v-else @setup="state = $event" />
|
||||
<Component
|
||||
:is="state ? ViewerScope : 'div'"
|
||||
:state="state"
|
||||
@@ -31,10 +31,10 @@ definePageMeta({
|
||||
})
|
||||
|
||||
const ViewerScope = resolveComponent('ViewerScope')
|
||||
const route = useRoute()
|
||||
|
||||
const isManualLoad = ref(false)
|
||||
const isTransparent = ref(false)
|
||||
const route = useRoute()
|
||||
const state = ref<InjectableViewerState>()
|
||||
|
||||
const checkUrlForEmbedManualLoadSettings = () => {
|
||||
|
||||
@@ -1,255 +1,11 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div class="h-screen w-screen flex flex-col md:flex-row relative">
|
||||
<PresentationCloseMessage
|
||||
class="absolute z-50 top-4 left-1/2 -translate-x-1/2"
|
||||
:show-close-message="showCloseMessage"
|
||||
@hide-close-message="hideCloseMessage"
|
||||
/>
|
||||
|
||||
<PresentationHeader
|
||||
v-if="!hideUi"
|
||||
v-model:is-sidebar-open="isLeftSidebarOpen"
|
||||
class="absolute top-4 z-40"
|
||||
:class="[
|
||||
isLeftSidebarOpen ? 'left-[calc(50%+0.75rem)] md:left-[15.75rem]' : 'left-4'
|
||||
]"
|
||||
:title="presentation?.title"
|
||||
@toggle-sidebar="isLeftSidebarOpen = !isLeftSidebarOpen"
|
||||
/>
|
||||
|
||||
<PresentationActions
|
||||
v-if="!hideUi"
|
||||
v-model:is-sidebar-open="isInfoSidebarOpen"
|
||||
v-model:is-present-mode="isPresentMode"
|
||||
:presentation-id="presentation?.id"
|
||||
class="absolute top-4 right-4 z-20"
|
||||
:class="{ 'lg:right-[21rem]': isInfoSidebarOpen }"
|
||||
@toggle-sidebar="isInfoSidebarOpen = !isInfoSidebarOpen"
|
||||
@toggle-present-mode="isPresentMode = !isPresentMode"
|
||||
/>
|
||||
|
||||
<PresentationSlideIndicator
|
||||
class="absolute top-1/2 -translate-y-1/2 z-20"
|
||||
:current-slide-index="currentVisibleIndex"
|
||||
:slide-count="slideCount || 0"
|
||||
:class="[isLeftSidebarOpen ? 'lg:left-[15.75rem] hidden md:block' : 'left-4']"
|
||||
/>
|
||||
|
||||
<PresentationLeftSidebar
|
||||
v-if="isLeftSidebarOpen"
|
||||
class="absolute left-0 top-0 md:relative flex-shrink-0 z-30"
|
||||
:slides="presentation"
|
||||
:workspace-logo="workspace?.logo"
|
||||
:workspace-name="workspace?.name"
|
||||
:current-slide-id="currentViewId"
|
||||
:is-present-mode="isPresentMode"
|
||||
@select-slide="onSelectSlide"
|
||||
/>
|
||||
|
||||
<div class="flex-1">
|
||||
<ClientOnly>
|
||||
<img
|
||||
:src="currentView?.screenshot"
|
||||
alt="Current view"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<PresentationInfoSidebar
|
||||
v-if="isInfoSidebarOpen"
|
||||
class="flex-shrink-0"
|
||||
:title="currentView?.name"
|
||||
:description="currentView?.description"
|
||||
:view-id="currentView?.id"
|
||||
:project-id="projectId"
|
||||
:is-present-mode="isPresentMode"
|
||||
/>
|
||||
|
||||
<PresentationControls
|
||||
v-if="!hideUi"
|
||||
class="absolute left-1/2 -translate-x-1/2"
|
||||
:disable-previous="disablePrevious"
|
||||
:disable-next="disableNext"
|
||||
:class="[
|
||||
isInfoSidebarOpen ? 'bottom-52 md:bottom-4' : 'bottom-4',
|
||||
isLeftSidebarOpen ? 'hidden md:flex' : ''
|
||||
]"
|
||||
@on-previous="onPrevious"
|
||||
@on-next="onNext"
|
||||
/>
|
||||
|
||||
<PresentationSpeckleLogo
|
||||
class="absolute right-4 z-20"
|
||||
:class="[isInfoSidebarOpen ? 'bottom-52 md:bottom-4' : 'bottom-4']"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<PresentationStateSetup>
|
||||
<PresentationPageWrapper />
|
||||
</PresentationStateSetup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { SavedViewVisibility } from '~~/lib/common/generated/gql/graphql'
|
||||
|
||||
graphql(`
|
||||
fragment ProjectPresentations_SavedViewGroup on SavedViewGroup {
|
||||
id
|
||||
}
|
||||
`)
|
||||
|
||||
const projectPresentationPageQuery = graphql(`
|
||||
query ProjectPresentationPage(
|
||||
$input: SavedViewGroupViewsInput!
|
||||
$savedViewGroupId: ID!
|
||||
$projectId: String!
|
||||
) {
|
||||
project(id: $projectId) {
|
||||
id
|
||||
workspace {
|
||||
id
|
||||
name
|
||||
logo
|
||||
}
|
||||
savedViewGroup(id: $savedViewGroupId) {
|
||||
id
|
||||
title
|
||||
...PresentationSlidesSidebar_SavedViewGroup
|
||||
views(input: $input) {
|
||||
totalCount
|
||||
items {
|
||||
id
|
||||
name
|
||||
description
|
||||
screenshot
|
||||
projectId
|
||||
visibility
|
||||
group {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
definePageMeta({
|
||||
layout: 'empty'
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => route.params.id as string)
|
||||
const savedViewGroupId = computed(() => route.params.presentationId as string)
|
||||
const { result } = useQuery(projectPresentationPageQuery, () => ({
|
||||
projectId: projectId.value,
|
||||
savedViewGroupId: savedViewGroupId.value,
|
||||
input: {
|
||||
limit: 100
|
||||
}
|
||||
}))
|
||||
|
||||
const currentViewId = ref<string | undefined>()
|
||||
const isInfoSidebarOpen = ref(true)
|
||||
const isLeftSidebarOpen = ref(true)
|
||||
const hideUi = ref(false)
|
||||
const isPresentMode = ref(false)
|
||||
const showCloseMessage = ref(false)
|
||||
const closeMessageTimeout = ref<NodeJS.Timeout | undefined>()
|
||||
|
||||
const workspace = computed(() => result.value?.project.workspace)
|
||||
const allSlides = computed(() => result.value?.project.savedViewGroup.views.items || [])
|
||||
const visibleSlides = computed(() =>
|
||||
allSlides.value.filter((view) => view.visibility === SavedViewVisibility.Public)
|
||||
)
|
||||
const currentView = computed(() =>
|
||||
allSlides.value.find((slide) => slide.id === currentViewId.value)
|
||||
)
|
||||
const slideCount = computed(() => visibleSlides.value?.length || 0)
|
||||
const presentation = computed(() => result.value?.project.savedViewGroup)
|
||||
const currentVisibleIndex = computed(() => {
|
||||
if (!currentViewId.value) return 0
|
||||
return visibleSlides.value.findIndex((slide) => slide.id === currentViewId.value)
|
||||
})
|
||||
const disablePrevious = computed(() => currentVisibleIndex.value === 0)
|
||||
const disableNext = computed(() =>
|
||||
slideCount.value ? currentVisibleIndex.value === slideCount.value - 1 : false
|
||||
)
|
||||
|
||||
const onSelectSlide = (slideId: string) => {
|
||||
currentViewId.value = slideId
|
||||
}
|
||||
|
||||
const onPrevious = () => {
|
||||
const currentIndex = currentVisibleIndex.value
|
||||
if (currentIndex > 0) {
|
||||
const previousSlide = visibleSlides.value[currentIndex - 1]
|
||||
currentViewId.value = previousSlide.id
|
||||
}
|
||||
}
|
||||
|
||||
const onNext = () => {
|
||||
const currentIndex = currentVisibleIndex.value
|
||||
if (currentIndex < visibleSlides.value.length - 1) {
|
||||
const nextSlide = visibleSlides.value[currentIndex + 1]
|
||||
currentViewId.value = nextSlide.id
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'i' || event.key === 'I') {
|
||||
hideUi.value = !hideUi.value
|
||||
isLeftSidebarOpen.value = false
|
||||
isInfoSidebarOpen.value = false
|
||||
} else if (event.key === 'Escape' && isPresentMode.value) {
|
||||
isPresentMode.value = false
|
||||
} else if (event.key === 'ArrowLeft' && !disablePrevious.value) {
|
||||
onPrevious()
|
||||
} else if (event.key === 'ArrowRight' && !disableNext.value) {
|
||||
onNext()
|
||||
}
|
||||
}
|
||||
|
||||
const hideCloseMessage = () => {
|
||||
showCloseMessage.value = false
|
||||
if (closeMessageTimeout.value) {
|
||||
clearTimeout(closeMessageTimeout.value)
|
||||
closeMessageTimeout.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
watch(isPresentMode, (newVal, oldVal) => {
|
||||
if (newVal && !oldVal) {
|
||||
isLeftSidebarOpen.value = false
|
||||
isInfoSidebarOpen.value = false
|
||||
showCloseMessage.value = true
|
||||
closeMessageTimeout.value = setTimeout(() => {
|
||||
showCloseMessage.value = false
|
||||
}, 3000)
|
||||
} else if (!newVal && oldVal) {
|
||||
isLeftSidebarOpen.value = true
|
||||
isInfoSidebarOpen.value = true
|
||||
hideCloseMessage()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
|
||||
// Initialize with first public slide after mount
|
||||
if (visibleSlides.value && visibleSlides.value.length > 0 && !currentViewId.value) {
|
||||
const firstPublicSlide = visibleSlides.value.find(
|
||||
(slide) => slide.visibility === SavedViewVisibility.Public
|
||||
)
|
||||
if (firstPublicSlide) {
|
||||
currentViewId.value = firstPublicSlide.id
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
hideCloseMessage()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createTransport } from 'nodemailer'
|
||||
import type { EmailTransport } from '@/modules/emails/domain/types'
|
||||
import type { Logger } from '@/observability/logging'
|
||||
import { EmailTransportInitializationError } from '@/modules/emails/errors'
|
||||
import type { SentMessageInfo } from 'nodemailer/lib/json-transport'
|
||||
import { SentEmailDeliveryStatus } from '@/modules/emails/domain/consts'
|
||||
|
||||
const createJsonEchoTransporter = () => {
|
||||
const newTransport = createTransport({ jsonTransport: true })
|
||||
const wrappedTransporter: EmailTransport = {
|
||||
sendMail: async (options) => {
|
||||
const response = await newTransport.sendMail(options)
|
||||
|
||||
return {
|
||||
messageId: response.messageId,
|
||||
status: mapJsonResponseToSentEmailDeliveryStatus(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return wrappedTransporter
|
||||
}
|
||||
|
||||
export async function initializeJSONEchoTransporter(deps: {
|
||||
isSandboxMode: boolean
|
||||
logger: Logger
|
||||
}): Promise<EmailTransport | undefined> {
|
||||
let newTransporter = undefined
|
||||
|
||||
newTransporter = createJsonEchoTransporter()
|
||||
if (!newTransporter) {
|
||||
const message =
|
||||
'📧 In testing or email sandbox mode a mock email provider is enabled but transport has not initialized correctly.'
|
||||
deps.logger.error(message)
|
||||
throw new EmailTransportInitializationError(message)
|
||||
}
|
||||
|
||||
return newTransporter
|
||||
}
|
||||
|
||||
export const mapJsonResponseToSentEmailDeliveryStatus = (
|
||||
response: SentMessageInfo
|
||||
): SentEmailDeliveryStatus => {
|
||||
if (response.rejected.length > 0) {
|
||||
return SentEmailDeliveryStatus.FAILED
|
||||
}
|
||||
|
||||
if (response.accepted.length === 0) {
|
||||
return SentEmailDeliveryStatus.PENDING
|
||||
}
|
||||
|
||||
return SentEmailDeliveryStatus.SENT
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { MisconfiguredEnvironmentError } from '@/modules/shared/errors'
|
||||
import { createTransport } from 'nodemailer'
|
||||
import type { EmailTransport } from '@/modules/emails/domain/types'
|
||||
import { ensureError } from '@speckle/shared'
|
||||
import type { Logger } from '@/observability/logging'
|
||||
import { EmailTransportInitializationError } from '@/modules/emails/errors'
|
||||
import { SentEmailDeliveryStatus } from '@/modules/emails/domain/consts'
|
||||
import type { SentMessageInfo } from 'nodemailer/lib/smtp-pool'
|
||||
|
||||
type SMTPConfig = {
|
||||
host: string
|
||||
port: number
|
||||
sslEnabled: boolean
|
||||
tlsRequired: boolean
|
||||
auth: { username: string; password: string }
|
||||
}
|
||||
|
||||
const initSmtpTransporter = async (params: SMTPConfig & { logger: Logger }) => {
|
||||
if (!params.tlsRequired && !params.sslEnabled) {
|
||||
params.logger.warn(
|
||||
'Neither EMAIL_SECURE and EMAIL_REQUIRE_TLS are true. Client will attempt to upgrade to TLS on connect, but will default to whatever the server supports which may be insecure.'
|
||||
)
|
||||
} else if (params.tlsRequired && params.sslEnabled) {
|
||||
throw new MisconfiguredEnvironmentError(
|
||||
'EMAIL_SECURE and EMAIL_REQUIRE_TLS cannot both be true. TLS would typically be preferred over SSL.'
|
||||
)
|
||||
}
|
||||
const smtpTransporter = createTransport({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
requireTLS: params.tlsRequired,
|
||||
secure: params.sslEnabled,
|
||||
auth: {
|
||||
user: params.auth.username,
|
||||
pass: params.auth.password
|
||||
},
|
||||
pool: true,
|
||||
maxConnections: 20,
|
||||
maxMessages: Infinity
|
||||
})
|
||||
const verifyResult = await smtpTransporter.verify()
|
||||
if (!verifyResult) {
|
||||
throw new EmailTransportInitializationError(
|
||||
'SMTP transporter verification failed. Please check your SMTP configuration.'
|
||||
)
|
||||
}
|
||||
|
||||
const wrappedTransporter: EmailTransport = {
|
||||
sendMail: async (options) => {
|
||||
const response = await smtpTransporter.sendMail(options)
|
||||
|
||||
return {
|
||||
messageId: response.messageId,
|
||||
status: mapSMTPResponseToSentEmailDeliveryStatus(response),
|
||||
errorMessages: response.rejectedErrors?.map((e) => `${e.code}: ${e.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return wrappedTransporter
|
||||
}
|
||||
|
||||
export async function initializeSMTPTransporter(
|
||||
deps: SMTPConfig & {
|
||||
isSandboxMode: boolean
|
||||
logger: Logger
|
||||
}
|
||||
): Promise<EmailTransport | undefined> {
|
||||
let newTransporter = undefined
|
||||
|
||||
const errorMessage =
|
||||
'📧 Email provider is enabled but transport has not initialized correctly. Please review the email configuration or your email system for problems.'
|
||||
try {
|
||||
newTransporter = await initSmtpTransporter(deps)
|
||||
} catch (e) {
|
||||
const err = ensureError(e, 'Unknown error while initializing SMTP transporter')
|
||||
deps.logger.error(err, errorMessage)
|
||||
throw new EmailTransportInitializationError(errorMessage, { cause: err })
|
||||
}
|
||||
|
||||
if (!newTransporter) {
|
||||
deps.logger.error(errorMessage)
|
||||
throw new EmailTransportInitializationError(errorMessage)
|
||||
}
|
||||
|
||||
return newTransporter
|
||||
}
|
||||
|
||||
const mapSMTPResponseToSentEmailDeliveryStatus = (
|
||||
response: SentMessageInfo
|
||||
): SentEmailDeliveryStatus => {
|
||||
if (response.rejected.length > 0) {
|
||||
return SentEmailDeliveryStatus.FAILED
|
||||
}
|
||||
|
||||
if (response.accepted.length === 0) {
|
||||
return SentEmailDeliveryStatus.PENDING
|
||||
}
|
||||
|
||||
return SentEmailDeliveryStatus.SENT
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { EmailTransport } from '@/modules/emails/domain/types'
|
||||
import type { Logger } from '@/observability/logging'
|
||||
import { initializeSMTPTransporter } from '@/modules/emails/clients/smtp'
|
||||
import { initializeJSONEchoTransporter } from '@/modules/emails/clients/jsonEcho'
|
||||
import {
|
||||
getEmailHost,
|
||||
getEmailPassword,
|
||||
getEmailPort,
|
||||
getEmailUsername,
|
||||
isSSLEmailEnabled,
|
||||
isTLSEmailRequired
|
||||
} from '@/modules/shared/helpers/envHelper'
|
||||
|
||||
let transporter: EmailTransport | undefined = undefined
|
||||
|
||||
export const initializeEmailTransport = async (params: {
|
||||
isSandboxMode?: boolean
|
||||
logger: Logger
|
||||
}) => {
|
||||
const { isSandboxMode = false, logger } = params
|
||||
|
||||
if (isSandboxMode) {
|
||||
logger.info('📧 Using JSON Echo email transporter')
|
||||
transporter = await initializeJSONEchoTransporter({ logger, isSandboxMode })
|
||||
} else {
|
||||
logger.info('📧 Using SMTP email transporter')
|
||||
transporter = await initializeSMTPTransporter({
|
||||
host: getEmailHost(),
|
||||
port: getEmailPort(),
|
||||
sslEnabled: isSSLEmailEnabled(),
|
||||
tlsRequired: isTLSEmailRequired(),
|
||||
auth: {
|
||||
username: getEmailUsername(),
|
||||
password: getEmailPassword()
|
||||
},
|
||||
logger,
|
||||
isSandboxMode
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const getTransporter = (): EmailTransport | undefined => {
|
||||
return transporter
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export const EmailTransportType = {
|
||||
SMTP: 'smtp',
|
||||
JSONEcho: 'jsonecho'
|
||||
} as const
|
||||
export type EmailTransportType =
|
||||
(typeof EmailTransportType)[keyof typeof EmailTransportType]
|
||||
|
||||
export const isEmailTransportType = (value: string): value is EmailTransportType => {
|
||||
return Object.values(EmailTransportType).includes(value as EmailTransportType)
|
||||
}
|
||||
|
||||
export const SentEmailDeliveryStatus = {
|
||||
QUEUED: 'queued',
|
||||
PENDING: 'pending', // addresses that received a temporary failure response from the receiving server
|
||||
SENT: 'sent',
|
||||
FAILED: 'failed'
|
||||
} as const
|
||||
|
||||
export type SentEmailDeliveryStatus =
|
||||
(typeof SentEmailDeliveryStatus)[keyof typeof SentEmailDeliveryStatus]
|
||||
@@ -1,4 +1,5 @@
|
||||
import type Mail from 'nodemailer/lib/mailer'
|
||||
import type { SentEmailDeliveryStatus } from '@/modules/emails/domain/consts'
|
||||
|
||||
export const emailsEventNamespace = 'emails' as const
|
||||
|
||||
@@ -9,6 +10,10 @@ export const EmailsEvents = {
|
||||
export type EmailsEvents = (typeof EmailsEvents)[keyof typeof EmailsEvents]
|
||||
|
||||
export type EmailsEventsPayloads = {
|
||||
[EmailsEvents.Sent]: { options: Mail.Options }
|
||||
[EmailsEvents.Sent]: {
|
||||
options: Mail.Options
|
||||
deliveryStatus: SentEmailDeliveryStatus
|
||||
deliverErrorMessages?: string[]
|
||||
}
|
||||
[EmailsEvents.PreparingToSend]: { options: Omit<Mail.Options, 'from'> }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,32 @@
|
||||
import type { SentEmailDeliveryStatus } from '@/modules/emails/domain/consts'
|
||||
|
||||
export type EmailVerification = {
|
||||
id: string
|
||||
email: string
|
||||
createdAt: Date
|
||||
code: string
|
||||
}
|
||||
|
||||
export type EmailOptions = {
|
||||
from?: string
|
||||
to: string | string[]
|
||||
subject: string
|
||||
text: string
|
||||
html: string
|
||||
speckleEmailId?: string
|
||||
}
|
||||
|
||||
export type SentEmailInfo = {
|
||||
messageId: string
|
||||
/**
|
||||
* Overall status for the sent email. If multiple recipients were specified, this will be the "worst" status among them.
|
||||
* For example, if one recipient's email was sent successfully but another's failed, the overall status will be "failed".
|
||||
* The error messages should be checked for more granular details.
|
||||
*/
|
||||
status: SentEmailDeliveryStatus
|
||||
errorMessages?: string[]
|
||||
}
|
||||
|
||||
export type EmailTransport = {
|
||||
sendMail: (options: EmailOptions) => Promise<SentEmailInfo>
|
||||
}
|
||||
|
||||
@@ -11,3 +11,21 @@ export class EmailVerificationFinalizationError extends BaseError {
|
||||
static defaultMessage = 'Invalid email verification finalization request'
|
||||
static statusCode = 400
|
||||
}
|
||||
|
||||
export class EmailSendingError extends BaseError {
|
||||
static code = 'EMAIL_SENDING_ERROR'
|
||||
static defaultMessage = 'Error sending email'
|
||||
static statusCode = 500
|
||||
}
|
||||
|
||||
export class EmailTransportInitializationError extends BaseError {
|
||||
static code = 'EMAIL_TRANSPORT_INITIALIZATION_ERROR'
|
||||
static defaultMessage = 'Error initializing email transport'
|
||||
static statusCode = 500
|
||||
}
|
||||
|
||||
export class MailchimpClientError extends BaseError {
|
||||
static code = 'MAILCHIMP_CLIENT_ERROR'
|
||||
static defaultMessage = 'Error with Mailchimp client'
|
||||
static statusCode = 500
|
||||
}
|
||||
|
||||
@@ -1,42 +1,31 @@
|
||||
/* istanbul ignore file */
|
||||
import { moduleLogger } from '@/observability/logging'
|
||||
import * as SendingService from '@/modules/emails/services/sending'
|
||||
import { initializeTransporter } from '@/modules/emails/utils/transporter'
|
||||
import { emailLogger, moduleLogger } from '@/observability/logging'
|
||||
import type { SpeckleModule } from '@/modules/shared/helpers/typeHelper'
|
||||
import RestApi from '@/modules/emails/rest/index'
|
||||
import { isEmailEnabled, isTestEnv } from '@/modules/shared/helpers/envHelper'
|
||||
import { initializeEmailTransport } from '@/modules/emails/clients/transportBuilder'
|
||||
|
||||
const emailsModule: SpeckleModule = {
|
||||
init: async ({ app }) => {
|
||||
moduleLogger.info('📧 Init emails module')
|
||||
|
||||
// init transporter
|
||||
await initializeTransporter()
|
||||
if (isEmailEnabled()) {
|
||||
await initializeEmailTransport({
|
||||
logger: emailLogger
|
||||
})
|
||||
} else if (isTestEnv()) {
|
||||
await initializeEmailTransport({
|
||||
isSandboxMode: true,
|
||||
logger: emailLogger
|
||||
})
|
||||
} else {
|
||||
moduleLogger.warn('📧 Email functionality is disabled')
|
||||
}
|
||||
|
||||
// init rest api
|
||||
RestApi(app)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `sendEmail` from `@/modules/emails/services/sending` instead
|
||||
*/
|
||||
async function sendEmail({
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html
|
||||
}: {
|
||||
from?: string
|
||||
to: string
|
||||
subject: string
|
||||
text: string
|
||||
html: string
|
||||
}) {
|
||||
return SendingService.sendEmail({ from, to, subject, text, html })
|
||||
}
|
||||
|
||||
export default {
|
||||
...emailsModule,
|
||||
sendEmail
|
||||
...emailsModule
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { emailLogger } from '@/observability/logging'
|
||||
import type { SendEmail, SendEmailParams } from '@/modules/emails/domain/operations'
|
||||
import { getTransporter } from '@/modules/emails/utils/transporter'
|
||||
import { getTransporter } from '@/modules/emails/clients/transportBuilder'
|
||||
import { getEmailFromAddress } from '@/modules/shared/helpers/envHelper'
|
||||
import { ensureError, resolveMixpanelUserId } from '@speckle/shared'
|
||||
import {
|
||||
getRequestContext,
|
||||
getRequestLogger,
|
||||
loggerWithMaybeContext
|
||||
} from '@/observability/utils/requestContext'
|
||||
import type Mail from 'nodemailer/lib/mailer'
|
||||
import { getEventBus } from '@/modules/shared/services/eventBus'
|
||||
import { EmailsEvents } from '@/modules/emails/domain/events'
|
||||
import type { EmailOptions } from '@/modules/emails/domain/types'
|
||||
import cryptoRandomString from 'crypto-random-string'
|
||||
import { SentEmailDeliveryStatus } from '@/modules/emails/domain/consts'
|
||||
|
||||
/**
|
||||
* Send out an e-mail
|
||||
@@ -23,6 +26,7 @@ export const sendEmail: SendEmail = async ({
|
||||
}: SendEmailParams): Promise<boolean> => {
|
||||
const eventBus = getEventBus()
|
||||
const logger = getRequestLogger() || loggerWithMaybeContext({ logger: emailLogger })
|
||||
const context = getRequestContext()
|
||||
|
||||
try {
|
||||
const baseOptions = {
|
||||
@@ -44,28 +48,43 @@ export const sendEmail: SendEmail = async ({
|
||||
}
|
||||
|
||||
const emailFrom = getEmailFromAddress()
|
||||
const options: Mail.Options = {
|
||||
const options: EmailOptions = {
|
||||
...baseOptions,
|
||||
from: from || `"Speckle" <${emailFrom}>`
|
||||
}
|
||||
if (context && 'requestId' in context) {
|
||||
// add some random digits to avoid collisions if multiple emails are sent within the same request
|
||||
options.speckleEmailId = `${context.requestId}_${cryptoRandomString({
|
||||
length: 4
|
||||
})}`
|
||||
}
|
||||
|
||||
await transporter.sendMail(options)
|
||||
const sentEmailResponse = await transporter.sendMail(options)
|
||||
await eventBus.emit({
|
||||
eventName: EmailsEvents.Sent,
|
||||
payload: { options }
|
||||
payload: {
|
||||
options,
|
||||
deliveryStatus: sentEmailResponse.status,
|
||||
deliverErrorMessages: sentEmailResponse.errorMessages
|
||||
}
|
||||
})
|
||||
|
||||
const emails = typeof to === 'string' ? [to] : to
|
||||
const distinctIds = await Promise.all(
|
||||
emails.map((email) => resolveMixpanelUserId(email))
|
||||
)
|
||||
logger.info(
|
||||
;(sentEmailResponse.status === SentEmailDeliveryStatus.FAILED
|
||||
? logger.warn
|
||||
: logger.info)(
|
||||
{
|
||||
subject,
|
||||
distinctIds
|
||||
distinctIds,
|
||||
deliveryStatus: sentEmailResponse.status,
|
||||
deliveryErrorMessages: sentEmailResponse.errorMessages
|
||||
},
|
||||
'Email "{subject}" sent out to distinctIds {distinctIds}'
|
||||
'Email "{subject}" sent out to distinctIds {distinctIds}; status: {deliveryStatus}'
|
||||
)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
const err = ensureError(error, 'Unknown error when sending email')
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { emailLogger as logger } from '@/observability/logging'
|
||||
import { MisconfiguredEnvironmentError } from '@/modules/shared/errors'
|
||||
import {
|
||||
getEmailHost,
|
||||
getEmailPassword,
|
||||
getEmailPort,
|
||||
getEmailUsername,
|
||||
isEmailEnabled,
|
||||
isSSLEmailEnabled,
|
||||
isTestEnv,
|
||||
isTLSEmailRequired
|
||||
} from '@/modules/shared/helpers/envHelper'
|
||||
import type { Transporter } from 'nodemailer'
|
||||
import { createTransport } from 'nodemailer'
|
||||
|
||||
let transporter: Transporter | undefined = undefined
|
||||
|
||||
const createJsonEchoTransporter = () => createTransport({ jsonTransport: true })
|
||||
|
||||
const initSmtpTransporter = async () => {
|
||||
const sslRequired = isSSLEmailEnabled()
|
||||
const tlsRequired = isTLSEmailRequired()
|
||||
if (!tlsRequired && !sslRequired) {
|
||||
logger.warn(
|
||||
'Neither EMAIL_SECURE and EMAIL_REQUIRE_TLS are true. Client will attempt to upgrade to TLS on connect, but will default to whatever the server supports which may be insecure.'
|
||||
)
|
||||
} else if (sslRequired && tlsRequired) {
|
||||
throw new MisconfiguredEnvironmentError(
|
||||
'EMAIL_SECURE and EMAIL_REQUIRE_TLS cannot both be true. TLS would typically be preferred over SSL.'
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const smtpTransporter = createTransport({
|
||||
host: getEmailHost(),
|
||||
port: getEmailPort(),
|
||||
requireTLS: tlsRequired,
|
||||
secure: sslRequired,
|
||||
auth: {
|
||||
user: getEmailUsername(),
|
||||
pass: getEmailPassword()
|
||||
},
|
||||
pool: true,
|
||||
maxConnections: 20,
|
||||
maxMessages: Infinity
|
||||
})
|
||||
const transporterVerified = await smtpTransporter.verify()
|
||||
if (!transporterVerified) {
|
||||
logger.error(
|
||||
'📧 Email provider is likely misconfigured as validation failed, check config variables'
|
||||
)
|
||||
}
|
||||
return smtpTransporter
|
||||
} catch (e) {
|
||||
logger.error(e, '📧 Email provider is misconfigured, check config variables.')
|
||||
}
|
||||
}
|
||||
|
||||
export async function initializeTransporter(): Promise<Transporter | undefined> {
|
||||
let newTransporter = undefined
|
||||
|
||||
if (isEmailEnabled()) {
|
||||
newTransporter = await initSmtpTransporter()
|
||||
|
||||
if (!newTransporter) {
|
||||
const message =
|
||||
'📧 Email provider is enabled but transport has not initialized correctly. Please review the email configuration or your email system for problems.'
|
||||
logger.error(message)
|
||||
throw new MisconfiguredEnvironmentError(message)
|
||||
}
|
||||
}
|
||||
|
||||
if (!newTransporter && isTestEnv()) {
|
||||
newTransporter = createJsonEchoTransporter()
|
||||
if (!newTransporter) {
|
||||
const message =
|
||||
'📧 In testing a mock email provider is enabled but transport has not initialized correctly.'
|
||||
logger.error(message)
|
||||
throw new MisconfiguredEnvironmentError(message)
|
||||
}
|
||||
}
|
||||
|
||||
if (!newTransporter) {
|
||||
logger.warn(
|
||||
'📧 Email provider is not configured. Server functionality will be limited.'
|
||||
)
|
||||
}
|
||||
|
||||
transporter = newTransporter
|
||||
return newTransporter
|
||||
}
|
||||
|
||||
export function getTransporter(): Transporter | undefined {
|
||||
return transporter
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import crs from 'crypto-random-string'
|
||||
import emailsModule from '@/modules/emails'
|
||||
import { InviteCreateValidationError } from '@/modules/serverinvites/errors'
|
||||
import sanitizeHtml from 'sanitize-html'
|
||||
import type { ResolvedTargetData } from '@/modules/serverinvites/helpers/core'
|
||||
@@ -30,6 +29,7 @@ import type { ServerInfo } from '@/modules/core/helpers/types'
|
||||
import type { EventBusEmit } from '@/modules/shared/services/eventBus'
|
||||
import type { GetUser } from '@/modules/core/domain/users/operations'
|
||||
import type { GetServerInfo } from '@/modules/core/domain/server/operations'
|
||||
import { sendEmail } from '@/modules/emails/services/sending'
|
||||
|
||||
const getFinalTargetData = (
|
||||
target: string,
|
||||
@@ -71,7 +71,7 @@ const sendInviteEmailFactory =
|
||||
)
|
||||
|
||||
// send email and emit event
|
||||
await emailsModule.sendEmail({
|
||||
await sendEmail({
|
||||
subject: emailContents.subject,
|
||||
to: targetUser ? targetUser.email : targetData.userEmail!,
|
||||
...renderedEmail
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: main
|
||||
image: speckle/speckle-webhook-service:{{ .Values.docker_image_tag }}
|
||||
image: {{ default (printf "speckle/speckle-webhook-service:%s" .Values.docker_image_tag) .Values.webhook_service.image }}
|
||||
imagePullPolicy: {{ .Values.imagePullPolicy }}
|
||||
|
||||
ports:
|
||||
|
||||
Reference in New Issue
Block a user