Files
speckle-server/packages/frontend-2/components/viewer/PreSetupWrapper.vue
T
2025-06-04 14:02:35 +02:00

300 lines
8.7 KiB
Vue

<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>
<HeaderNavLink
v-else
:to="projectsRoute"
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 />
</Transition>
</div>
<!-- Global loading bar -->
<ViewerLoadingBar
class="absolute left-0 w-full z-40 h-30"
:class="isEmbedEnabled ? 'top-0' : ' -top-2'"
/>
<!-- Sidebar controls -->
<ViewerControls v-if="showControls" class="relative z-20" />
<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 class="z-20" />
</Transition>
<div
class="absolute z-10 w-screen px-8 grid grid-cols-1 sm:grid-cols-3 gap-2"
:class="isEmbedEnabled ? 'bottom-16 mb-1' : 'bottom-6'"
>
<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>
<!-- Shows up when filters are applied for an easy return to normality -->
<ViewerGlobalFilterReset
v-if="hasAnyFiltersApplied"
class="z-20"
:embed="!!isEmbedEnabled"
/>
</div>
</div>
<div class="flex items-end justify-center sm:justify-end">
<PortalTarget name="pocket-right"></PortalTarget>
</div>
</div>
</ClientOnly>
</div>
</ViewerPostSetupWrapper>
<ViewerEmbedFooter
:name="modelName || 'Loading...'"
:date="lastUpdate"
:url="route.path"
:hide-speckle-branding="hideSpeckleLogo"
:disable-model-link="disableModelLink"
/>
<Portal to="primary-actions">
<HeaderNavShare
v-if="project"
:resource-id-string="resourceIdString"
:project="project"
/>
</Portal>
</div>
</template>
<script setup lang="ts">
import {
useSetupViewer,
type InjectableViewerState
} 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 { useFilterUtilities } from '~/lib/viewer/composables/ui'
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'
graphql(`
fragment ModelPageProject on Project {
id
createdAt
name
visibility
workspace {
id
slug
name
role
}
embedOptions {
hideSpeckleBranding
}
hasAccessToFeature(featureName: hideSpeckleBranding)
...ViewerLimitsDialog_Project
}
`)
const emit = defineEmits<{
setup: [InjectableViewerState]
}>()
const router = useRouter()
const route = useRoute()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
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 = useSetupViewer({
projectId
})
const {
filters: { hasAnyFiltersApplied }
} = useFilterUtilities({ state })
const {
isEnabled: isEmbedEnabled,
hideSelectionInfo,
isTransparent,
showControls,
disableModelLink,
hideSpeckleBranding
} = useEmbed()
const mp = useMixpanel()
emit('setup', state)
const {
resources: {
response: { project, modelsAndVersionIds }
}
} = state
const showLimitsDialog = ref(false)
const limitsDialogType = ref<ViewerLimitsDialogType>(ViewerLimitsDialogType.Version)
// Check for missing referencedObject in url referenced versions (out of plan limits)
const hasMissingReferencedObject = computed(() => {
const resources = parseUrlParameters(resourceIdString.value)
const result = modelsAndVersionIds.value.some((item) => {
const version = item.model?.loadedVersion?.items?.find(
(v) => v.id === item.versionId
)
if (!version || version.referencedObject === null) {
const modelVersionString = resourceBuilder()
.addModel(item.model.id, item.versionId)
.toString()
const isInUrl = resources.some(
(r) => r.toString().toLowerCase() === modelVersionString
)
return isInUrl
}
return false
})
return result
})
const isFederated = computed(
() => state.resources.response.resourceItems.value.length > 1
)
const title = computed(() => {
if (project.value?.models?.items) {
const modelCount = project.value.models.items.length
const projectName = project.value.name || ''
if (modelCount > 1) {
return projectName ? `Multiple models - ${projectName}` : 'Multiple models'
} else if (modelCount === 1) {
const modelName = project.value.models.items[0].name || ''
return projectName ? `${modelName} - ${projectName}` : modelName
}
}
return ''
})
const modelName = computed(() => {
if (project.value?.models?.items && project.value.models.items.length > 0) {
return project.value.models.items[0].name
} else {
return project.value?.name
}
})
const lastUpdate = computed(() => {
if (project.value?.models?.items[0] && project.value.models.items[0].updatedAt) {
return 'Updated ' + dayjs(project.value.models.items[0].updatedAt).fromNow()
} else if (project.value) {
return 'Created ' + dayjs(project.value.createdAt).fromNow()
} else return undefined
})
const canEditEmbedOptions = computed(() => {
return project.value?.hasAccessToFeature
})
const hideSpeckleLogo = computed(() => {
if (!project.value?.workspace) return true
if (!canEditEmbedOptions.value) return false
if (project.value?.embedOptions?.hideSpeckleBranding) return true
else return hideSpeckleBranding.value
})
useHead({ title })
onMounted(() => {
const referrer = document.referrer
const shouldTrackEvent = !referrer?.includes('speckle.systems') && !import.meta.dev
if (isEmbedEnabled.value && shouldTrackEvent) {
mp.track('Embedded Model Load')
}
})
// Watch for plan limit conditions and show dialog if needed
watch(
[hasMissingReferencedObject, state.resources.response.resourcesLoading],
([missingObject, resourcesLoading]: [boolean, boolean]) => {
// Only show dialog if resources are not loading to prevent flashing during version switches
if (missingObject && !resourcesLoading) {
if (isFederated.value) {
limitsDialogType.value = 'federated'
} else {
limitsDialogType.value = 'version'
}
showLimitsDialog.value = true
} else {
showLimitsDialog.value = false
}
},
{ immediate: true }
)
</script>