Merge branch 'main' into andrew/fe2-expand-object-arrays
This commit is contained in:
Коммит
3eb81ffbd9
|
@ -8,7 +8,7 @@ DOCKER_FILE_NAME="$(echo ${DOCKER_IMAGE_TAG}_${IMAGE_VERSION_TAG} | sed -e 's/[^
|
|||
# shellcheck disable=SC2068,SC2046
|
||||
LAST_RELEASE="$(git describe --always --tags $(git rev-list --tags) | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1)" # get the last release tag. FIXME: Fails if a commit is tagged with more than one tag: https://stackoverflow.com/questions/8089002/git-describe-with-two-tags-on-the-same-commit/56039163#56039163
|
||||
# shellcheck disable=SC2034
|
||||
NEXT_RELEASE="$(echo "${LAST_RELEASE}" | python -c "parts = input().split('.'); parts[-1] = str(int(parts[-1])+1); print('.'.join(parts))")"
|
||||
NEXT_RELEASE="$(echo "${LAST_RELEASE}" | awk -F. -v OFS=. '{$NF += 1 ; print}')"
|
||||
# shellcheck disable=SC2034
|
||||
BRANCH_NAME_TRUNCATED="$(echo "${CIRCLE_BRANCH}" | cut -c -50 | sed 's/[^a-zA-Z0-9_.-]/_/g')" # docker has a 128 character tag limit, so ensuring the branch name will be short enough
|
||||
# shellcheck disable=SC2034
|
||||
|
|
|
@ -91,6 +91,10 @@ workflows:
|
|||
- pre-commit:
|
||||
filters: *filters-allow-all
|
||||
|
||||
- build-image-approval:
|
||||
type: approval
|
||||
filters: *filters-ignore-main-branch-or-all-tags
|
||||
|
||||
- docker-build-server:
|
||||
context: &build-context
|
||||
- github-readonly-public-repos
|
||||
|
@ -99,54 +103,63 @@ workflows:
|
|||
only: /.*/
|
||||
requires:
|
||||
- get-version
|
||||
- build-image-approval
|
||||
|
||||
- docker-build-frontend:
|
||||
context: *build-context
|
||||
filters: *filters-build
|
||||
requires:
|
||||
- get-version
|
||||
- build-image-approval
|
||||
|
||||
- docker-build-frontend-2:
|
||||
context: *build-context
|
||||
filters: *filters-build
|
||||
requires:
|
||||
- get-version
|
||||
- build-image-approval
|
||||
|
||||
- docker-build-webhooks:
|
||||
context: *build-context
|
||||
filters: *filters-build
|
||||
requires:
|
||||
- get-version
|
||||
- build-image-approval
|
||||
|
||||
- docker-build-file-imports:
|
||||
context: *build-context
|
||||
filters: *filters-build
|
||||
requires:
|
||||
- get-version
|
||||
- build-image-approval
|
||||
|
||||
- docker-build-previews:
|
||||
context: *build-context
|
||||
filters: *filters-build
|
||||
requires:
|
||||
- get-version
|
||||
- build-image-approval
|
||||
|
||||
- docker-build-test-container:
|
||||
context: *build-context
|
||||
filters: *filters-build
|
||||
requires:
|
||||
- get-version
|
||||
- build-image-approval
|
||||
|
||||
- docker-build-monitor-container:
|
||||
context: *build-context
|
||||
filters: *filters-build
|
||||
requires:
|
||||
- get-version
|
||||
- build-image-approval
|
||||
|
||||
- docker-build-docker-compose-ingress:
|
||||
context: *build-context
|
||||
filters: *filters-build
|
||||
requires:
|
||||
- get-version
|
||||
- build-image-approval
|
||||
|
||||
- publish-approval:
|
||||
type: approval
|
||||
|
@ -299,8 +312,8 @@ workflows:
|
|||
|
||||
jobs:
|
||||
get-version:
|
||||
docker:
|
||||
- image: cimg/python:3.12.1
|
||||
docker: &docker-base-image
|
||||
- image: cimg/base:2024.02
|
||||
working_directory: &work-dir /tmp/ci
|
||||
steps:
|
||||
- checkout
|
||||
|
@ -338,8 +351,10 @@ jobs:
|
|||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
name: Restore pre-commit & Yarn Package cache
|
||||
keys:
|
||||
- cache-pre-commit-<<parameters.cache_prefix>>-{{ checksum "<<parameters.config_file>>" }}
|
||||
- yarn-packages-{{ checksum "yarn.lock" }}
|
||||
- run:
|
||||
name: Install pre-commit hooks
|
||||
command: pre-commit install-hooks --config <<parameters.config_file>>
|
||||
|
@ -347,10 +362,6 @@ jobs:
|
|||
key: cache-pre-commit-<<parameters.cache_prefix>>-{{ checksum "<<parameters.config_file>>" }}
|
||||
paths:
|
||||
- ~/.cache/pre-commit
|
||||
- restore_cache:
|
||||
name: Restore Yarn Package Cache
|
||||
keys:
|
||||
- yarn-packages-{{ checksum "yarn.lock" }}
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: yarn
|
||||
|
@ -471,7 +482,7 @@ jobs:
|
|||
# destination: package/server/coverage
|
||||
|
||||
test-frontend-2:
|
||||
docker:
|
||||
docker: &docker-node-browsers-image
|
||||
- image: cimg/node:18.19.0-browsers
|
||||
resource_class: xlarge
|
||||
steps:
|
||||
|
@ -505,7 +516,7 @@ jobs:
|
|||
working_directory: 'packages/frontend-2'
|
||||
|
||||
test-dui-3:
|
||||
docker:
|
||||
docker: &docker-node-image
|
||||
- image: cimg/node:18.19.0
|
||||
resource_class: medium+
|
||||
steps:
|
||||
|
@ -535,8 +546,7 @@ jobs:
|
|||
working_directory: 'packages/dui3'
|
||||
|
||||
test-ui-components:
|
||||
docker:
|
||||
- image: cimg/node:18.19.0-browsers
|
||||
docker: *docker-node-browsers-image
|
||||
resource_class: xlarge
|
||||
steps:
|
||||
- checkout
|
||||
|
@ -589,8 +599,7 @@ jobs:
|
|||
|
||||
ui-components-chromatic:
|
||||
resource_class: medium+
|
||||
docker:
|
||||
- image: cimg/node:18.19.0
|
||||
docker: *docker-node-image
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
|
@ -627,14 +636,13 @@ jobs:
|
|||
# but it is not possible to scan npm/yarn package.json
|
||||
# because it requires node_modules
|
||||
# therefore this scanning has to be triggered via the cli
|
||||
docker: &docker-image
|
||||
- image: cimg/python:3.12.1-node
|
||||
docker: *docker-node-image
|
||||
resource_class: small
|
||||
working_directory: *work-dir
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
name: Restore Yarn Package Cache
|
||||
name: Restore Yarn Package cache
|
||||
keys:
|
||||
- yarn-packages-server-{{ checksum "yarn.lock" }}
|
||||
- run:
|
||||
|
@ -668,6 +676,7 @@ jobs:
|
|||
sudo mkdir /nix
|
||||
sudo chmod 777 /nix
|
||||
- restore_cache:
|
||||
name: Restore nix cache
|
||||
keys:
|
||||
- nix-{{ checksum "./.circleci/deployment/docker-compose-shell.nix" }}
|
||||
- run:
|
||||
|
@ -701,6 +710,7 @@ jobs:
|
|||
sudo mkdir /nix
|
||||
sudo chmod 777 /nix
|
||||
- restore_cache:
|
||||
name: Restore nix cache
|
||||
keys:
|
||||
- nix-{{ checksum "./.circleci/deployment/helm-chart-shell.nix" }}
|
||||
- run:
|
||||
|
@ -761,28 +771,14 @@ jobs:
|
|||
./.circleci/deployment/helm-chart-shell.nix
|
||||
|
||||
docker-build: &build-job
|
||||
docker: &docker-image
|
||||
- image: cimg/python:3.12.1-node
|
||||
docker: *docker-base-image
|
||||
resource_class: medium
|
||||
working_directory: *work-dir
|
||||
steps:
|
||||
- checkout
|
||||
- attach_workspace:
|
||||
at: /tmp/ci/workspace
|
||||
- run:
|
||||
name: determine if draft PR
|
||||
command: |
|
||||
echo "export IS_DRAFT_PR=$(.circleci/is_draft.sh)" >> workspace/env-vars
|
||||
- run: cat workspace/env-vars >> $BASH_ENV
|
||||
- run: echo "IS_DRAFT_PR=${IS_DRAFT_PR}"
|
||||
- run:
|
||||
name: 'Check if should proceed'
|
||||
command: |
|
||||
[[ "${CIRCLE_TAG}" ]] && echo "proceed because tag is set" && exit 0
|
||||
[[ "${CIRCLE_BRANCH}" == "main" ]] && echo "proceed because main branch" && exit 0
|
||||
[[ "${CIRCLE_BRANCH}" == "testing" ]] && echo "proceed because testing branch" && exit 0
|
||||
[[ "${IS_DRAFT_PR}" == "TRUE" || -z "${CIRCLE_PULL_REQUEST}" ]] && echo "Should not build because either Draft PR or branch without PR, stopping" && exit 1
|
||||
echo "proceeding"
|
||||
- setup_remote_docker:
|
||||
version: default
|
||||
docker_layer_caching: true
|
||||
|
@ -843,8 +839,7 @@ jobs:
|
|||
SPECKLE_SERVER_PACKAGE: docker-compose-ingress
|
||||
|
||||
docker-publish: &publish-job
|
||||
docker:
|
||||
- image: cimg/python:3.12.1-node
|
||||
docker: *docker-base-image
|
||||
resource_class: medium
|
||||
working_directory: *work-dir
|
||||
steps:
|
||||
|
@ -908,14 +903,13 @@ jobs:
|
|||
SPECKLE_SERVER_PACKAGE: docker-compose-ingress
|
||||
|
||||
publish-npm:
|
||||
docker: *docker-image
|
||||
docker: *docker-node-image
|
||||
working_directory: *work-dir
|
||||
steps:
|
||||
- checkout
|
||||
- attach_workspace:
|
||||
at: /tmp/ci/workspace
|
||||
- run: cat workspace/env-vars >> $BASH_ENV
|
||||
|
||||
- restore_cache:
|
||||
name: Restore Yarn Package Cache
|
||||
keys:
|
||||
|
@ -932,7 +926,6 @@ jobs:
|
|||
paths:
|
||||
- .yarn/cache
|
||||
- .yarn/unplugged
|
||||
|
||||
- run:
|
||||
name: auth to npm as Speckle
|
||||
command: |
|
||||
|
@ -955,7 +948,8 @@ jobs:
|
|||
command: 'yarn workspaces foreach -pv --no-private npm publish --access public'
|
||||
|
||||
publish-helm-chart:
|
||||
docker: *docker-image
|
||||
docker:
|
||||
- image: cimg/python:3.12.1
|
||||
working_directory: *work-dir
|
||||
steps:
|
||||
- checkout
|
||||
|
@ -970,7 +964,7 @@ jobs:
|
|||
command: ./.circleci/publish_helm_chart.sh
|
||||
|
||||
update-helm-documentation:
|
||||
docker: *docker-image
|
||||
docker: *docker-node-image
|
||||
working_directory: *work-dir
|
||||
steps:
|
||||
- checkout
|
||||
|
|
|
@ -18,9 +18,11 @@ def docker_load(name, filename=None, existing_ref=None, deps=None):
|
|||
docker tag "{EXISTING_REF}" "$EXPECTED_REF"'.format(DOCKER_FILE_NAME=filename, EXISTING_REF=existing_ref),
|
||||
deps=deps)
|
||||
|
||||
def speckle_image(package):
|
||||
def speckle_image(package,original_package_name=None):
|
||||
if not package:
|
||||
fail('package must be specified')
|
||||
if not original_package_name:
|
||||
original_package_name = package
|
||||
|
||||
image_version_tag = os.getenv('IMAGE_VERSION_TAG')
|
||||
if not image_version_tag:
|
||||
|
@ -31,7 +33,8 @@ def speckle_image(package):
|
|||
workspace='/tmp/ci/workspace'
|
||||
|
||||
docker_image_tag = 'speckle/speckle-{}'.format(package)
|
||||
existing_ref = '{}:{}'.format(docker_image_tag, image_version_tag)
|
||||
original_docker_image_tag = 'speckle/speckle-{}'.format(original_package_name)
|
||||
existing_ref = '{}:{}'.format(original_docker_image_tag, image_version_tag)
|
||||
docker_file_name = "".join([ c if c.isalnum() or c=='-' or c=='_' or c=='.' else "_" for c in existing_ref.elems() ])
|
||||
return docker_load(docker_image_tag,
|
||||
filename=docker_file_name,
|
||||
|
|
|
@ -24,6 +24,7 @@ speckle_image('frontend-2')
|
|||
speckle_image('monitor-deployment')
|
||||
speckle_image('preview-service')
|
||||
speckle_image('server')
|
||||
speckle_image('objects','server')
|
||||
speckle_image('test-deployment')
|
||||
speckle_image('webhook-service')
|
||||
|
||||
|
@ -51,6 +52,7 @@ helm_resource('postgresql',
|
|||
flags=['--version=^12.0.0',
|
||||
'--values=./values/postgres.values.yaml',
|
||||
'--kube-context=kind-speckle-server'],
|
||||
deps=['./values/postgres.values.yaml'],
|
||||
labels=['speckle-dependencies'])
|
||||
|
||||
helm_resource('minio',
|
||||
|
@ -60,6 +62,7 @@ helm_resource('minio',
|
|||
flags=['--version=^12.0.0',
|
||||
'--values=./values/minio.values.yaml',
|
||||
'--kube-context=kind-speckle-server'],
|
||||
deps=['./values/minio.values.yaml'],
|
||||
labels=['speckle-dependencies'])
|
||||
|
||||
helm_resource('redis',
|
||||
|
@ -69,6 +72,7 @@ helm_resource('redis',
|
|||
flags=['--version=18.7.1',
|
||||
'--values=./values/redis.values.yaml',
|
||||
'--kube-context=kind-speckle-server'],
|
||||
deps=['./values/redis.values.yaml'],
|
||||
labels=['speckle-dependencies'])
|
||||
|
||||
#FIXME this helm chart does not deploy any containers, so tilt incorrectly believes it never gets to a final state
|
||||
|
@ -79,6 +83,7 @@ helm_resource('redis',
|
|||
# namespace='prometheus',
|
||||
# resource_deps=['prometheus-repo'],
|
||||
# chart='prometheus-repo/prometheus-operator-crds',
|
||||
# deps=['./values/prometheus-operator-crds.values.yaml'],
|
||||
# flags=['--version=^7.0.0',
|
||||
# '--values=./values/prometheus-operator-crds.values.yaml',
|
||||
# '--kube-context=kind-speckle-server'])
|
||||
|
@ -95,6 +100,7 @@ helm_resource('ingress-nginx',
|
|||
flags=['--version=^4.8.0',
|
||||
'--values=./values/nginx.values.yaml',
|
||||
'--kube-context=kind-speckle-server'],
|
||||
deps=['./values/nginx.values.yaml'],
|
||||
resource_deps=['postgresql', 'minio', 'redis', 'ingress-nginx-repo'],
|
||||
labels=['speckle-dependencies'])
|
||||
|
||||
|
@ -122,6 +128,7 @@ helm_resource('speckle-server',
|
|||
'speckle/speckle-monitor-deployment',
|
||||
'speckle/speckle-preview-service',
|
||||
'speckle/speckle-server',
|
||||
'speckle/speckle-objects',
|
||||
'speckle/speckle-test-deployment',
|
||||
'speckle/speckle-webhook-service',
|
||||
],
|
||||
|
@ -131,6 +138,7 @@ helm_resource('speckle-server',
|
|||
'monitor.image',
|
||||
'preview_service.image',
|
||||
'server.image',
|
||||
'objects.image',
|
||||
'test.image',
|
||||
'webhook_service.image',
|
||||
],
|
||||
|
|
|
@ -1,11 +1,16 @@
|
|||
# Build each Dockerfile and reference resource for use in the primary Tiltfile
|
||||
# The referenced resources can then be deployed by the Helm Chart
|
||||
|
||||
def speckle_image(package):
|
||||
package_dir = 'packages/{}'.format(package)
|
||||
def speckle_image(package,original_package_name=None):
|
||||
if not original_package_name:
|
||||
original_package_name = package
|
||||
|
||||
package_dir = 'packages/{}'.format(original_package_name)
|
||||
if package == 'test-deployment' or package == 'monitor-deployment' or package == 'docker-compose-ingress':
|
||||
package_dir = 'utils/{}'.format(package)
|
||||
docker_build('speckle/speckle-{}'.format(package),
|
||||
context='../..',
|
||||
dockerfile='../../{}/Dockerfile'.format(package_dir),
|
||||
platform='linux/amd64')
|
||||
platform='linux/amd64',
|
||||
ignore = ['**/node_modules', '**/dist', '**/build', '**/coverage', 'minio-data', 'postgres-data']
|
||||
)
|
||||
|
|
|
@ -25,6 +25,23 @@ server:
|
|||
disable_tracking: true
|
||||
disable_tracing: true
|
||||
|
||||
objects:
|
||||
replicas: 1
|
||||
# session_secret: secret -> `session_secret`
|
||||
auth:
|
||||
local:
|
||||
enabled: true
|
||||
logLevel: debug
|
||||
|
||||
email:
|
||||
enabled: false
|
||||
|
||||
monitoring:
|
||||
mp:
|
||||
enabled: false
|
||||
disable_tracking: true
|
||||
disable_tracing: true
|
||||
|
||||
frontend_2:
|
||||
enabled: true
|
||||
|
||||
|
|
|
@ -11,9 +11,9 @@ if [[ "${CIRCLE_TAG}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|||
fi
|
||||
|
||||
if [[ "${CIRCLE_BRANCH}" == "main" ]]; then
|
||||
echo "$NEXT_RELEASE-alpha.${CIRCLE_BUILD_NUM}"
|
||||
echo "${NEXT_RELEASE}-alpha.${CIRCLE_BUILD_NUM}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "$NEXT_RELEASE-branch.${BRANCH_NAME_TRUNCATED}.${CIRCLE_BUILD_NUM}-${COMMIT_SHA1_TRUNCATED}"
|
||||
echo "${NEXT_RELEASE}-branch.${BRANCH_NAME_TRUNCATED}.${CIRCLE_BUILD_NUM}-${COMMIT_SHA1_TRUNCATED}"
|
||||
exit 0
|
||||
|
|
|
@ -35,8 +35,9 @@ import {
|
|||
ExclamationTriangleIcon
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
|
||||
import { useFilterUtilities } from '~~/lib/viewer/composables/ui'
|
||||
import { useFilterUtilities, useSelectionUtilities } from '~~/lib/viewer/composables/ui'
|
||||
import type { NumericPropertyInfo } from '@speckle/viewer'
|
||||
import { containsAll } from '~~/lib/common/helpers/utils'
|
||||
|
||||
type ObjectResultWithOptionalMetadata = {
|
||||
category: string
|
||||
|
@ -62,8 +63,7 @@ const {
|
|||
|
||||
const { isolateObjects, resetFilters, setPropertyFilter, applyPropertyFilter } =
|
||||
useFilterUtilities()
|
||||
|
||||
import { containsAll } from '~~/lib/common/helpers/utils'
|
||||
const { setSelectionFromObjectIds, clearSelection } = useSelectionUtilities()
|
||||
|
||||
const hasMetadataGradient = computed(() => {
|
||||
if (props.result.metadata?.gradient) return true
|
||||
|
@ -72,7 +72,7 @@ const hasMetadataGradient = computed(() => {
|
|||
|
||||
const isolatedObjects = computed(() => filteringState.value?.isolatedObjects)
|
||||
const isIsolated = computed(() => {
|
||||
if (!isolatedObjects.value) return false
|
||||
if (!isolatedObjects.value?.length) return false
|
||||
if (filteringState.value?.activePropFilterKey === props.functionId) return false
|
||||
const ids = props.result.objectIds
|
||||
return containsAll(ids, isolatedObjects.value)
|
||||
|
@ -83,17 +83,21 @@ const handleClick = () => {
|
|||
setOrUnsetGradient()
|
||||
return
|
||||
}
|
||||
|
||||
isolateOrUnisolateObjects()
|
||||
}
|
||||
|
||||
const isolateOrUnisolateObjects = () => {
|
||||
const ids = props.result.objectIds
|
||||
if (!isIsolated.value) {
|
||||
resetFilters()
|
||||
isolateObjects(ids)
|
||||
return
|
||||
}
|
||||
const isCurrentlyIsolated = isIsolated.value
|
||||
|
||||
resetFilters()
|
||||
if (isCurrentlyIsolated) {
|
||||
clearSelection()
|
||||
} else {
|
||||
isolateObjects(ids)
|
||||
setSelectionFromObjectIds(ids)
|
||||
}
|
||||
}
|
||||
|
||||
const metadataGradientIsSet = ref(false)
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
>
|
||||
<!-- Add new thread bubble -->
|
||||
<ViewerAnchoredPointNewThread
|
||||
v-if="!isEmbedEnabled"
|
||||
v-if="shouldShowNewThread"
|
||||
v-model="buttonState"
|
||||
:can-post-comment="canPostComment"
|
||||
class="z-[13]"
|
||||
|
@ -89,25 +89,27 @@
|
|||
class="w-full h-full outline -outline-offset-0 outline-8 rounded-md outline-blue-500/40"
|
||||
>
|
||||
<div class="absolute bottom-4 right-4 p-2 pointer-events-auto">
|
||||
<FormButton
|
||||
v-if="spotlightUserSessionId && spotlightUser"
|
||||
size="xs"
|
||||
class="truncate"
|
||||
@click="() => (spotlightUserSessionId = null)"
|
||||
>
|
||||
<span>Stop Following {{ spotlightUser?.userName.split(' ')[0] }}</span>
|
||||
</FormButton>
|
||||
<div
|
||||
v-else
|
||||
v-tippy="followers.map((u) => u.name).join(', ')"
|
||||
class="text-xs p-2 font-bold text-primary"
|
||||
>
|
||||
Followed by {{ followers[0].name.split(' ')[0] }}
|
||||
<span v-if="followers.length > 1">
|
||||
& {{ followers.length - 1 }}
|
||||
{{ followers.length - 1 === 1 ? 'other' : 'others' }}
|
||||
</span>
|
||||
</div>
|
||||
<Portal to="pocket-right">
|
||||
<FormButton
|
||||
v-if="spotlightUserSessionId && spotlightUser"
|
||||
size="xs"
|
||||
class="truncate"
|
||||
@click="() => (spotlightUserSessionId = null)"
|
||||
>
|
||||
<span>Stop Following {{ spotlightUser?.userName.split(' ')[0] }}</span>
|
||||
</FormButton>
|
||||
<div
|
||||
v-else
|
||||
v-tippy="followers.map((u) => u.name).join(', ')"
|
||||
class="text-xs p-2 font-bold text-primary"
|
||||
>
|
||||
Followed by {{ followers[0].name.split(' ')[0] }}
|
||||
<span v-if="followers.length > 1">
|
||||
& {{ followers.length - 1 }}
|
||||
{{ followers.length - 1 === 1 ? 'other' : 'others' }}
|
||||
</span>
|
||||
</div>
|
||||
</Portal>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -186,6 +188,10 @@ const onThreadExpandedChange = (isExpanded: boolean) => {
|
|||
}
|
||||
}
|
||||
|
||||
const shouldShowNewThread = computed(
|
||||
() => !isEmbedEnabled.value && !state.ui.measurement.enabled.value
|
||||
)
|
||||
|
||||
const allThreadsChronologicalOrder = computed(() => {
|
||||
const vals = Object.values(commentThreads.value)
|
||||
return vals.sort(
|
||||
|
|
|
@ -11,11 +11,7 @@
|
|||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<FormButton
|
||||
:size="isEmbedEnabled ? 'xs' : 'sm'"
|
||||
class="pointer-events-auto"
|
||||
@click="trackAndResetFilters"
|
||||
>
|
||||
<FormButton size="xs" class="pointer-events-auto" @click="trackAndResetFilters">
|
||||
Reset Filters
|
||||
</FormButton>
|
||||
</Transition>
|
||||
|
@ -24,14 +20,12 @@
|
|||
<script setup lang="ts">
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import { useFilterUtilities } from '~~/lib/viewer/composables/ui'
|
||||
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
|
||||
|
||||
const {
|
||||
resetFilters,
|
||||
filters: { hasAnyFiltersApplied }
|
||||
} = useFilterUtilities()
|
||||
|
||||
const { isEnabled: isEmbedEnabled } = useEmbed()
|
||||
const mp = useMixpanel()
|
||||
const trackAndResetFilters = () => {
|
||||
resetFilters()
|
||||
|
|
|
@ -63,14 +63,22 @@
|
|||
</div>
|
||||
</Transition>
|
||||
<div
|
||||
class="absolute z-10 w-screen flex flex-col items-center justify-center gap-2"
|
||||
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'"
|
||||
>
|
||||
<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 class="z-20" :embed="!!isEmbedEnabled" />
|
||||
<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 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>
|
||||
|
|
|
@ -0,0 +1,135 @@
|
|||
<template>
|
||||
<div
|
||||
ref="resizableElement"
|
||||
class="relative sm:absolute z-10 right-0 h-[50dvh] overflow-hidden w-screen sm:pr-3 sm:pb-3 sm:pt-0"
|
||||
:style="!isSmallerOrEqualSm ? { maxWidth: width + 'px' } : {}"
|
||||
:class="[
|
||||
open ? '' : 'pointer-events-none',
|
||||
isEmbedEnabled === true
|
||||
? 'sm:top-2 sm:h-[calc(100dvh-3.8rem)]'
|
||||
: 'sm:top-[4.2rem] sm:h-[calc(100dvh-4.2rem)]'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="flex transition-all h-full"
|
||||
:class="open ? '' : 'sm:translate-x-[100%]'"
|
||||
>
|
||||
<!-- Resize Handle -->
|
||||
<div
|
||||
ref="resizeHandle"
|
||||
class="hidden sm:flex group relative z-30 hover:z-50 w-6 h-full items-center overflow-hidden -mr-1"
|
||||
>
|
||||
<div
|
||||
class="w-5 h-8 mr-1 bg-foundation group-hover:bg-outline-2 rounded-l translate-x-3 group-hover:translate-x-0.5 transition cursor-ew-resize flex items-center justify-center group-hover:shadow-xl"
|
||||
@mousedown="startResizing"
|
||||
>
|
||||
<ArrowsRightLeftIcon
|
||||
class="h-3 w-3 transition opacity-0 group-hover:opacity-80 text-outline-1 -ml-px"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="relative z-30 w-1 h-full pt-[4.2rem] -ml-1 bg-transparent group-hover:bg-outline-2 cursor-ew-resize transition rounded-l"
|
||||
@mousedown="startResizing"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col w-full h-full relative z-20 overflow-hidden shadow-lg rounded-b-md"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="h-18 absolute z-10 top-0 w-full left-0 bg-foundation shadow-md sm:rounded-t-md"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between pl-3 pr-2.5 h-10 border-b border-outline-3"
|
||||
>
|
||||
<div v-if="$slots.title" class="font-bold text-sm text-primary">
|
||||
<slot name="title"></slot>
|
||||
</div>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<button class="p-0.5 text-foreground hover:text-primary" @click="onClose">
|
||||
<XMarkIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="w-full px-3 h-8">
|
||||
<div class="flex items-center gap-1 h-full">
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full" :class="$slots.actions ? 'h-24 sm:h-20' : 'h-10'"></div>
|
||||
<div
|
||||
class="overflow-y-auto simple-scrollbar h-[calc(50dvh)] sm:h-full bg-foundation w-full pt-2 sm:rounded-b-md"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
class="absolute z-20 bottom-0 h-8 bg-foundation shadow-t w-full flex items-center px-3 empty:translate-y-10 transition sm:rounded-b-md"
|
||||
>
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
<div v-if="$slots.footer" class="h-8"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { XMarkIcon, ArrowsRightLeftIcon } from '@heroicons/vue/24/outline'
|
||||
import { useIsSmallerOrEqualThanBreakpoint } from '~~/composables/browser'
|
||||
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
|
||||
|
||||
defineProps<{
|
||||
open: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
}>()
|
||||
|
||||
const resizableElement = ref(null)
|
||||
const resizeHandle = ref(null)
|
||||
const isResizing = ref(false)
|
||||
const width = ref(300)
|
||||
let startWidth = 0
|
||||
let startX = 0
|
||||
|
||||
const { isSmallerOrEqualSm } = useIsSmallerOrEqualThanBreakpoint()
|
||||
const { isEnabled: isEmbedEnabled } = useEmbed()
|
||||
|
||||
const startResizing = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
isResizing.value = true
|
||||
startX = event.clientX
|
||||
startWidth = width.value
|
||||
}
|
||||
|
||||
useEventListener(resizeHandle, 'mousedown', startResizing)
|
||||
|
||||
useEventListener(document, 'mousemove', (event) => {
|
||||
if (isResizing.value) {
|
||||
const diffX = startX - event.clientX
|
||||
width.value = Math.max(
|
||||
300,
|
||||
Math.min(startWidth + diffX, (parseInt('75vw') * window.innerWidth) / 100)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
useEventListener(document, 'mouseup', () => {
|
||||
if (isResizing.value) {
|
||||
isResizing.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const minimize = () => {
|
||||
width.value = 300
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
minimize()
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
|
@ -16,9 +16,9 @@
|
|||
color="danger"
|
||||
:icon-left="TrashIcon"
|
||||
class="font-normal py-1"
|
||||
@click="() => removeMeasurement()"
|
||||
@click="() => clearMeasurements()"
|
||||
>
|
||||
Delete Selected
|
||||
Delete All Measurements
|
||||
</FormButton>
|
||||
</template>
|
||||
<div class="px-3 py-2 sm:p-3 flex flex-col gap-3 border-b border-outline-3">
|
||||
|
@ -75,13 +75,13 @@
|
|||
</div>
|
||||
</div>
|
||||
<Portal to="pocket-tip">
|
||||
<ViewerTip>
|
||||
<ViewerTip class="hidden sm:flex">
|
||||
<strong>Tip:</strong>
|
||||
Right click to cancel measurement
|
||||
</ViewerTip>
|
||||
</Portal>
|
||||
<Portal to="pocket-actions">
|
||||
<FormButton size="sm" @click="() => clearMeasurements()">
|
||||
<FormButton size="xs" @click="() => clearMeasurements()">
|
||||
Reset Measurements
|
||||
</FormButton>
|
||||
</Portal>
|
||||
|
@ -113,8 +113,7 @@ const measurementParams = ref({
|
|||
precision: measurementPrecision.value
|
||||
})
|
||||
|
||||
const { setMeasurementOptions, removeMeasurement, clearMeasurements } =
|
||||
useMeasurementUtilities()
|
||||
const { setMeasurementOptions, clearMeasurements } = useMeasurementUtilities()
|
||||
|
||||
const updateMeasurementsType = (selectedOption: MeasurementTypeOption) => {
|
||||
measurementParams.value.type = selectedOption.value
|
||||
|
|
|
@ -8,10 +8,15 @@
|
|||
>
|
||||
<div class="mb-1 flex items-center">
|
||||
<button
|
||||
class="flex h-full w-full p-2 items-center justify-between gap-4 rounded bg-foundation-2 hover:sm:bg-primary-muted hover:text-primary"
|
||||
class="flex h-full w-full px-2 py-0.5 items-center gap-1 rounded bg-foundation-2 hover:sm:bg-primary-muted hover:text-primary"
|
||||
:class="unfold && 'text-primary'"
|
||||
@click="unfold = !unfold"
|
||||
>
|
||||
<ChevronDownIcon
|
||||
:class="`h-3 w-3 transition ${headerClasses} ${
|
||||
!unfold ? '-rotate-90' : 'rotate-0'
|
||||
}`"
|
||||
/>
|
||||
<div :class="`truncate text-xs font-bold ${headerClasses}`">
|
||||
{{ title || headerAndSubheader.header }}
|
||||
<span
|
||||
|
@ -20,39 +25,57 @@
|
|||
{{ isModifiedQuery.isNew ? '(New)' : '(Old)' }}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDownIcon
|
||||
:class="`h-3 w-3 transition ${headerClasses} ${
|
||||
!unfold ? '-rotate-90' : 'rotate-0'
|
||||
}`"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="unfold" class="ml-1 space-y-1 p-2">
|
||||
<div v-if="unfold" class="ml-1 space-y-1 px-2 py-1">
|
||||
<div
|
||||
v-for="(kvp, index) in [
|
||||
...categorisedValuePairs.primitives,
|
||||
...categorisedValuePairs.nulls
|
||||
]"
|
||||
:key="index"
|
||||
class="flex w-full"
|
||||
>
|
||||
<div
|
||||
:class="`grid grid-cols-3 ${
|
||||
:class="`grid grid-cols-3 w-full pl-2 ${
|
||||
kvp.value === null || kvp.value === undefined ? 'text-foreground-2' : ''
|
||||
}`"
|
||||
>
|
||||
<div
|
||||
class="col-span-1 truncate text-xs font-bold"
|
||||
class="col-span-1 truncate text-xs font-bold mr-2"
|
||||
:title="(kvp.key as string)"
|
||||
>
|
||||
{{ kvp.key }}
|
||||
</div>
|
||||
<div class="col-span-2 pl-1 truncate text-xs" :title="(kvp.value as string)">
|
||||
<!-- NOTE: can't do kvp.value || 'null' because 0 || 'null' = 'null' -->
|
||||
{{ kvp.value === null ? 'null' : kvp.value }}
|
||||
<div
|
||||
class="group col-span-2 pl-1 truncate text-xs flex gap-1 items-center"
|
||||
:title="(kvp.value as string)"
|
||||
>
|
||||
<div class="flex gap-1 items-center w-full">
|
||||
<!-- NOTE: can't do kvp.value || 'null' because 0 || 'null' = 'null' -->
|
||||
<span
|
||||
class="truncate"
|
||||
:class="kvp.value === null ? '' : 'group-hover:max-w-[calc(100%-1rem)]'"
|
||||
>
|
||||
{{ kvp.value === null ? 'null' : kvp.value }}
|
||||
</span>
|
||||
<button
|
||||
v-if="isCopyable(kvp)"
|
||||
:class="isCopyable(kvp) ? 'cursor-pointer' : 'cursor-default'"
|
||||
class="opacity-0 group-hover:opacity-100 w-4"
|
||||
@click="handleCopy(kvp)"
|
||||
>
|
||||
<ClipboardDocumentIcon class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="(kvp, index) in categorisedValuePairs.objects" :key="index">
|
||||
<div
|
||||
v-for="(kvp, index) in categorisedValuePairs.objects"
|
||||
:key="index"
|
||||
class="pl-2"
|
||||
>
|
||||
<ViewerSelectionObject
|
||||
:object="(kvp.value as Record<string,unknown>) || {}"
|
||||
:title="(kvp.key as string)"
|
||||
|
@ -96,6 +119,7 @@
|
|||
<script setup lang="ts">
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
import { ChevronDownIcon } from '@heroicons/vue/24/solid'
|
||||
import { ClipboardDocumentIcon } from '@heroicons/vue/24/outline'
|
||||
import type { SpeckleObject } from '~~/lib/common/helpers/sceneExplorer'
|
||||
import { getHeaderAndSubheaderForSpeckleObject } from '~~/lib/object-sidebar/helpers'
|
||||
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
|
||||
|
@ -186,6 +210,21 @@ const headerAndSubheader = computed(() => {
|
|||
return getHeaderAndSubheaderForSpeckleObject(props.object)
|
||||
})
|
||||
|
||||
const isCopyable = (kvp: Record<string, unknown>) => {
|
||||
return kvp.value !== null && kvp.value !== undefined && typeof kvp.value !== 'object'
|
||||
}
|
||||
|
||||
const handleCopy = async (kvp: Record<string, unknown>) => {
|
||||
const { copy } = useClipboard()
|
||||
if (isCopyable(kvp)) {
|
||||
const keyName = kvp.key as string
|
||||
await copy(kvp.value as string, {
|
||||
successMessage: `${keyName} copied to clipboard`,
|
||||
failureMessage: `Failed to copy ${keyName} to clipboard`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const ignoredProps = [
|
||||
'__closure',
|
||||
'displayMesh',
|
||||
|
|
|
@ -1,70 +1,56 @@
|
|||
<template>
|
||||
<ViewerCommentsPortalOrDiv v-if="objects.length !== 0" to="bottomPanel">
|
||||
<div
|
||||
:class="`sm:bg-foundation simple-scrollbar z-10 relative sm:fixed sm:right-4 sm:right-4 sm:mb-4 sm:max-w-64 min-h-[3rem] sm:min-h-[4.75rem] max-h-[50vh] sm:max-h-[calc(100vh-5.5rem)] w-full sm:w-64 overflow-y-auto sm:rounded-md sm:shadow transition ${
|
||||
objects.length !== 0
|
||||
? 'translate-x-0 opacity-100'
|
||||
: 'translate-x-[120%] opacity-0'
|
||||
} ${isEmbedEnabled ? 'sm:top-2' : 'sm:top-[4rem]'} ${
|
||||
focusedThreadId && isSmallerOrEqualSm ? 'hidden' : ''
|
||||
}`"
|
||||
>
|
||||
<ViewerLayoutPanel @close="trackAndClearSelection()">
|
||||
<template #title>Selection Info</template>
|
||||
<template #actions>
|
||||
<FormButton
|
||||
size="xs"
|
||||
color="secondary"
|
||||
class="opacity-80 hover:opacity-100"
|
||||
@click.stop="hideOrShowSelection"
|
||||
>
|
||||
<div v-if="isHidden" class="flex items-center gap-1">
|
||||
<EyeIcon class="h-4 w-4" />
|
||||
Show
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-1">
|
||||
<EyeSlashIcon class="h-4 w-4" />
|
||||
Hide
|
||||
</div>
|
||||
</FormButton>
|
||||
<FormButton
|
||||
size="xs"
|
||||
color="secondary"
|
||||
class="hover:opacity-100"
|
||||
:class="isIsolated ? 'text-primary opacity-100' : 'opacity-80'"
|
||||
@click.stop="isolateOrUnisolateSelection"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<FunnelIconOutline v-if="!isIsolated" class="h-4 w-4" />
|
||||
<FunnelIcon v-else class="h-4 w-4" />
|
||||
Isolate
|
||||
</div>
|
||||
</FormButton>
|
||||
</template>
|
||||
<div class="p-1 mb-2 sm:mb-0 sm:py-2 sm:bg-white/90 dark:sm:bg-neutral-700/90">
|
||||
<div class="space-y-2">
|
||||
<ViewerSelectionObject
|
||||
v-for="object in objectsLimited"
|
||||
:key="(object.id as string)"
|
||||
:object="object"
|
||||
:unfold="false"
|
||||
:root="true"
|
||||
/>
|
||||
<ViewerCommentsPortalOrDiv v-if="shouldRenderSidebar" to="bottomPanel">
|
||||
<ViewerSidebar :open="sidebarOpen" @close="onClose">
|
||||
<template #title><div class="select-none">Selection Info</div></template>
|
||||
<template #actions>
|
||||
<FormButton
|
||||
size="xs"
|
||||
color="secondary"
|
||||
class="opacity-80 hover:opacity-100"
|
||||
@click.stop="hideOrShowSelection"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<EyeIcon v-if="!isHidden" class="h-4 w-4" />
|
||||
<EyeSlashIcon v-else class="h-4 w-4" />
|
||||
Hide
|
||||
</div>
|
||||
<div v-if="itemCount <= objects.length" class="mb-2">
|
||||
<FormButton size="xs" text full-width @click="itemCount += 10">
|
||||
View More ({{ objects.length - itemCount }})
|
||||
</FormButton>
|
||||
</div>
|
||||
<div
|
||||
v-if="objects.length === 1"
|
||||
class="hidden sm:block text-foreground-2 mt-2 px-2 text-xs"
|
||||
>
|
||||
Hold "shift" to select multiple objects
|
||||
</FormButton>
|
||||
<FormButton
|
||||
size="xs"
|
||||
color="secondary"
|
||||
class="hover:opacity-100"
|
||||
:class="isIsolated ? 'text-primary opacity-100' : 'opacity-80'"
|
||||
@click.stop="isolateOrUnisolateSelection"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<FunnelIconOutline v-if="!isIsolated" class="h-4 w-4" />
|
||||
<FunnelIcon v-else class="h-4 w-4" />
|
||||
Isolate
|
||||
</div>
|
||||
</FormButton>
|
||||
</template>
|
||||
<div class="p-1 mb-2 sm:mb-0 sm:py-2">
|
||||
<div class="space-y-2">
|
||||
<ViewerSelectionObject
|
||||
v-for="object in objectsLimited"
|
||||
:key="(object.id as string)"
|
||||
:object="object"
|
||||
:root="true"
|
||||
:unfold="objectsLimited.length === 1"
|
||||
/>
|
||||
</div>
|
||||
</ViewerLayoutPanel>
|
||||
</div>
|
||||
<div v-if="itemCount <= objects.length" class="mb-2">
|
||||
<FormButton size="xs" text full-width @click="itemCount += 10">
|
||||
View More ({{ objects.length - itemCount }})
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="!isSmallerOrEqualSm" #footer>
|
||||
<div class="text-foreground-2 text-xs select-none">
|
||||
Hold "shift" to select multiple objects
|
||||
</div>
|
||||
</template>
|
||||
</ViewerSidebar>
|
||||
</ViewerCommentsPortalOrDiv>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
|
@ -79,23 +65,21 @@ import { useFilterUtilities, useSelectionUtilities } from '~~/lib/viewer/composa
|
|||
import { uniqWith } from 'lodash-es'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import { useIsSmallerOrEqualThanBreakpoint } from '~~/composables/browser'
|
||||
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
|
||||
|
||||
const {
|
||||
viewer: {
|
||||
metadata: { filteringState }
|
||||
},
|
||||
urlHashState: { focusedThreadId },
|
||||
ui: { diff }
|
||||
ui: { diff, measurement }
|
||||
} = useInjectedViewerState()
|
||||
const { objects, clearSelection } = useSelectionUtilities()
|
||||
const { hideObjects, showObjects, isolateObjects, unIsolateObjects } =
|
||||
useFilterUtilities()
|
||||
const { isEmbedEnabled } = useEmbed()
|
||||
|
||||
const { isSmallerOrEqualSm } = useIsSmallerOrEqualThanBreakpoint()
|
||||
|
||||
const itemCount = ref(42)
|
||||
const itemCount = ref(20)
|
||||
const sidebarOpen = ref(false)
|
||||
|
||||
const objectsUniqueByAppId = computed(() => {
|
||||
if (!diff.enabled.value) return objects.value
|
||||
|
@ -104,6 +88,10 @@ const objectsUniqueByAppId = computed(() => {
|
|||
})
|
||||
})
|
||||
|
||||
const shouldRenderSidebar = computed(() => {
|
||||
return (!isSmallerOrEqualSm.value || sidebarOpen.value) && !measurement.enabled.value
|
||||
})
|
||||
|
||||
const objectsLimited = computed(() => {
|
||||
return objectsUniqueByAppId.value.slice(0, itemCount.value)
|
||||
})
|
||||
|
@ -179,6 +167,11 @@ const trackAndClearSelection = () => {
|
|||
})
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
sidebarOpen.value = false
|
||||
trackAndClearSelection()
|
||||
}
|
||||
|
||||
onKeyStroke('Escape', () => {
|
||||
// Cleareance of any vis/iso state coming from here should happen in clearSelection()
|
||||
// Note: we're not using the trackAndClearSelection method beacuse
|
||||
|
@ -191,4 +184,15 @@ onKeyStroke('Escape', () => {
|
|||
source: 'keypress-escape'
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
() => objects.value.length,
|
||||
(newLength) => {
|
||||
if (newLength !== 0) {
|
||||
sidebarOpen.value = true
|
||||
} else {
|
||||
sidebarOpen.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
|
|
@ -6,7 +6,7 @@ export { isNonNullable } from '@speckle/shared'
|
|||
* @param target the array you want to check that is included in the other one
|
||||
* @param source the array you want to check INTO for inclusion of the previous one
|
||||
*/
|
||||
export const containsAll = (target: unknown[], source: unknown[]) =>
|
||||
export const containsAll = <T>(target: T[], source: T[]) =>
|
||||
target.every((v) => source.includes(v))
|
||||
|
||||
/**
|
||||
|
@ -24,18 +24,34 @@ const length = (vals: unknown[] | Set<unknown>) =>
|
|||
isSet(vals) ? vals.size : vals.length
|
||||
|
||||
/**
|
||||
* A performant way to check if two arrays/sets have at least one element in common
|
||||
* Various performance-improved array utilities:
|
||||
*/
|
||||
|
||||
/**
|
||||
* Whenever you have to compare two arrays/sets, this function will make sure the biggest one is a Set for quick look ups
|
||||
* and so you can iterate over the smallest one
|
||||
* @param vals1
|
||||
* @param vals2
|
||||
*/
|
||||
const toOptimizedComparisonArrays = <V = unknown>(
|
||||
vals1: V[] | Set<V>,
|
||||
vals2: V[] | Set<V>
|
||||
) => {
|
||||
const biggest: Set<V> = length(vals1) > length(vals2) ? toSet(vals1) : toSet(vals2)
|
||||
const smallest: V[] | Set<V> = length(vals1) > length(vals2) ? vals2 : vals1
|
||||
|
||||
return { biggest, smallest: isSet(smallest) ? [...smallest] : smallest }
|
||||
}
|
||||
|
||||
/**
|
||||
* A fast way to check if two arrays/sets have at least one element in common
|
||||
*/
|
||||
export const hasIntersection = <V = unknown>(
|
||||
vals1: V[] | Set<V>,
|
||||
vals2: V[] | Set<V>
|
||||
) => {
|
||||
if (!length(vals1) || !length(vals2)) return false
|
||||
const { biggest, smallest } = toOptimizedComparisonArrays(vals1, vals2)
|
||||
if (!length(biggest) || !length(smallest)) return false
|
||||
|
||||
// Always iterating over the smallest collection to speed things up, and making
|
||||
// sure the biggest one is a Set for quick look ups
|
||||
const biggest: Set<V> = length(vals1) > length(vals2) ? toSet(vals1) : toSet(vals2)
|
||||
const smallest: V[] | Set<V> = length(vals1) > length(vals2) ? vals2 : vals1
|
||||
|
||||
return (isSet(smallest) ? [...smallest] : smallest).some((v) => biggest.has(v))
|
||||
return smallest.some((v) => biggest.has(v))
|
||||
}
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
import { Redis } from 'ioredis'
|
||||
import type pino from 'pino'
|
||||
|
||||
export const createRedis = async (params: { logger: pino.Logger }) => {
|
||||
const { logger } = params
|
||||
const { redisUrl } = useRuntimeConfig()
|
||||
if (!redisUrl?.length) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const redis = new Redis(redisUrl)
|
||||
|
||||
redis.on('error', (err) => {
|
||||
logger.error(err, 'Redis error')
|
||||
})
|
||||
|
||||
redis.on('end', () => {
|
||||
logger.info('Redis disconnected from server')
|
||||
})
|
||||
|
||||
// Try to ping the server
|
||||
const res = await redis.ping()
|
||||
if (res !== 'PONG') {
|
||||
throw new Error('Redis server did not respond to ping')
|
||||
}
|
||||
|
||||
return redis
|
||||
}
|
|
@ -242,6 +242,19 @@ export function useSelectionUtilities() {
|
|||
selectedObjects.value = objs
|
||||
}
|
||||
|
||||
const addToSelectionFromObjectIds = (objectIds: string[]) => {
|
||||
const originalObjects = selectedObjects.value.slice()
|
||||
setSelectionFromObjectIds(objectIds)
|
||||
selectedObjects.value = [...originalObjects, ...selectedObjects.value]
|
||||
}
|
||||
|
||||
const removeFromSelectionObjectIds = (objectIds: string[]) => {
|
||||
const finalObjects = selectedObjects.value.filter(
|
||||
(o) => !objectIds.includes(o.id || '')
|
||||
)
|
||||
selectedObjects.value = finalObjects
|
||||
}
|
||||
|
||||
const addToSelection = (object: SpeckleObject) => {
|
||||
const idx = selectedObjects.value.findIndex((o) => o.id === object.id)
|
||||
if (idx !== -1) return
|
||||
|
@ -268,6 +281,8 @@ export function useSelectionUtilities() {
|
|||
removeFromSelection,
|
||||
clearSelection,
|
||||
setSelectionFromObjectIds,
|
||||
addToSelectionFromObjectIds,
|
||||
removeFromSelectionObjectIds,
|
||||
objects: selectedObjects,
|
||||
objectIds: selectedObjectIds
|
||||
}
|
||||
|
|
|
@ -120,7 +120,7 @@
|
|||
"tailwindcss": "^3.4.1",
|
||||
"type-fest": "^3.5.1",
|
||||
"typescript": "^4.8.3",
|
||||
"vue-tsc": "1.8.22",
|
||||
"vue-tsc": "1.8.27",
|
||||
"wait-on": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
|
|
|
@ -5,7 +5,7 @@ import { useCreateErrorLoggingTransport } from '~/lib/core/composables/error'
|
|||
type PluginNuxtApp = Parameters<Plugin>[0]
|
||||
|
||||
async function initRumClient(app: PluginNuxtApp) {
|
||||
const { enabled, keys, speckleServerVersion } = resolveInitParams()
|
||||
const { enabled, keys, speckleServerVersion, baseUrl } = resolveInitParams()
|
||||
const logger = useLogger()
|
||||
const onAuthStateChange = useOnAuthStateChange()
|
||||
const router = useRouter()
|
||||
|
@ -18,8 +18,9 @@ async function initRumClient(app: PluginNuxtApp) {
|
|||
rg4js('apiKey', keys.raygun)
|
||||
rg4js('enableCrashReporting', true)
|
||||
rg4js('enablePulse', true)
|
||||
rg4js('boot')
|
||||
rg4js('enableRum', true)
|
||||
// rg4js('boot')
|
||||
// rg4js('enableRum', true)
|
||||
rg4js('withTags', [`baseUrl:${baseUrl}`, `version:${speckleServerVersion}`])
|
||||
|
||||
await onAuthStateChange(
|
||||
(user, { resolveDistinctId }) => {
|
||||
|
@ -184,7 +185,8 @@ function resolveInitParams() {
|
|||
logrocketAppId,
|
||||
speckleServerVersion,
|
||||
speedcurveId,
|
||||
debugbearId
|
||||
debugbearId,
|
||||
baseUrl
|
||||
}
|
||||
} = useRuntimeConfig()
|
||||
const raygun = raygunKey?.length ? raygunKey : null
|
||||
|
@ -201,7 +203,8 @@ function resolveInitParams() {
|
|||
speedcurve,
|
||||
debugbear
|
||||
},
|
||||
speckleServerVersion
|
||||
speckleServerVersion,
|
||||
baseUrl
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Redis } from 'ioredis'
|
||||
import { createRedis } from '~/lib/core/helpers/redis'
|
||||
|
||||
/**
|
||||
* Re-using the same client for all SSR reqs (shouldn't be a problem)
|
||||
|
@ -9,31 +10,20 @@ let redis: InstanceType<typeof Redis> | undefined = undefined
|
|||
* Provide redis (only in SSR)
|
||||
*/
|
||||
export default defineNuxtPlugin(async () => {
|
||||
const { redisUrl } = useRuntimeConfig()
|
||||
const logger = useLogger()
|
||||
|
||||
if (redisUrl?.length) {
|
||||
try {
|
||||
const hasValidStatus =
|
||||
redis && ['ready', 'connecting', 'reconnecting'].includes(redis.status)
|
||||
if (!redis || !hasValidStatus) {
|
||||
if (redis) {
|
||||
await redis.quit()
|
||||
}
|
||||
|
||||
redis = new Redis(redisUrl)
|
||||
|
||||
redis.on('error', (err) => {
|
||||
logger.error(err, 'Redis error')
|
||||
})
|
||||
|
||||
redis.on('end', () => {
|
||||
logger.info('Redis disconnected from server')
|
||||
})
|
||||
try {
|
||||
const hasValidStatus =
|
||||
redis && ['ready', 'connecting', 'reconnecting'].includes(redis.status)
|
||||
if (!redis || !hasValidStatus) {
|
||||
if (redis) {
|
||||
await redis.quit()
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(e, 'Redis setup failure')
|
||||
|
||||
redis = await createRedis({ logger })
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(e, 'Redis setup failure')
|
||||
}
|
||||
|
||||
const isValid = redis && redis.status === 'ready'
|
||||
|
|
|
@ -1,6 +1,28 @@
|
|||
import { useRequestId } from '~/lib/core/composables/server'
|
||||
import { ensureError } from '@speckle/shared'
|
||||
import { createRedis } from '~/lib/core/helpers/redis'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const reqId = useRequestId({ event })
|
||||
return { status: 'ok', reqId }
|
||||
/**
|
||||
* Check that the deployment is fine
|
||||
*/
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
let redisConnected = false
|
||||
|
||||
// Check that redis works
|
||||
try {
|
||||
const redis = await createRedis({ logger: useLogger() })
|
||||
redisConnected = !!redis
|
||||
if (redis) {
|
||||
await redis.quit()
|
||||
}
|
||||
} catch (e) {
|
||||
const errMsg = ensureError(e).message
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
fatal: true,
|
||||
message: `Redis connection failed: ${errMsg}`
|
||||
})
|
||||
}
|
||||
|
||||
return { status: 'ok', redisConnected }
|
||||
})
|
||||
|
|
|
@ -3,6 +3,7 @@ import { Observability } from '@speckle/shared'
|
|||
import type { IncomingMessage } from 'node:http'
|
||||
import { get } from 'lodash-es'
|
||||
import type { Logger } from 'pino'
|
||||
import type express from 'express'
|
||||
|
||||
const redactedReqHeaders = ['authorization', 'cookie']
|
||||
|
||||
|
@ -44,7 +45,7 @@ export function serializeRequest(req: IncomingMessage) {
|
|||
return {
|
||||
id: req.id,
|
||||
method: req.method,
|
||||
path: req.url?.split('?')[0], // Remove query params which might be sensitive
|
||||
path: getRequestPath(req),
|
||||
// Allowlist useful headers
|
||||
headers: Object.keys(req.headers).reduce((obj, key) => {
|
||||
let valueToPrint = req.headers[key]
|
||||
|
@ -58,3 +59,10 @@ export function serializeRequest(req: IncomingMessage) {
|
|||
}, {})
|
||||
}
|
||||
}
|
||||
|
||||
export const getRequestPath = (req: IncomingMessage | express.Request) => {
|
||||
const path = ((get(req, 'originalUrl') || get(req, 'url') || '') as string).split(
|
||||
'?'
|
||||
)[0] as string
|
||||
return path?.length ? path : null
|
||||
}
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { Observability } from '@speckle/shared'
|
||||
import { defineEventHandler, fromNodeMiddleware } from 'h3'
|
||||
import { IncomingMessage, ServerResponse } from 'http'
|
||||
import pino from 'pino'
|
||||
|
@ -9,7 +8,10 @@ import { randomUUID } from 'crypto'
|
|||
import type { IncomingHttpHeaders } from 'http'
|
||||
import { REQUEST_ID_HEADER } from '~~/server/lib/core/helpers/constants'
|
||||
import { get } from 'lodash'
|
||||
import { serializeRequest } from '~/server/lib/core/helpers/observability'
|
||||
import {
|
||||
serializeRequest,
|
||||
getRequestPath
|
||||
} from '~/server/lib/core/helpers/observability'
|
||||
|
||||
/**
|
||||
* Server request logger
|
||||
|
@ -28,10 +30,7 @@ function determineRequestId(
|
|||
const generateReqId: GenReqId = (req: IncomingMessage) =>
|
||||
determineRequestId(req.headers)
|
||||
|
||||
const logger = Observability.getLogger(
|
||||
useRuntimeConfig().public.logLevel,
|
||||
useRuntimeConfig().public.logPretty
|
||||
)
|
||||
const logger = useLogger()
|
||||
|
||||
export const LoggingMiddleware = pinoHttp({
|
||||
logger,
|
||||
|
@ -46,8 +45,9 @@ export const LoggingMiddleware = pinoHttp({
|
|||
error: Error | undefined
|
||||
) => {
|
||||
// Mark some lower importance/spammy endpoints w/ 'debug' to reduce noise
|
||||
const path = req.url?.split('?')[0]
|
||||
const shouldBeDebug = ['/metrics', '/health'].includes(path || '') ?? false
|
||||
const path = getRequestPath(req)
|
||||
const shouldBeDebug =
|
||||
['/metrics', '/health', '/api/status'].includes(path || '') ?? false
|
||||
|
||||
if (res.statusCode >= 400 && res.statusCode < 500) {
|
||||
return 'info'
|
||||
|
@ -66,7 +66,7 @@ export const LoggingMiddleware = pinoHttp({
|
|||
customSuccessObject(req, res, val: Record<string, unknown>) {
|
||||
const isCompleted = !req.readableAborted && res.writableEnded
|
||||
const requestStatus = isCompleted ? 'completed' : 'aborted'
|
||||
const requestPath = req.url?.split('?')[0] || 'unknown'
|
||||
const requestPath = getRequestPath(req) || 'unknown'
|
||||
const appBindings = res.vueLoggerBindings || {}
|
||||
|
||||
return {
|
||||
|
@ -82,7 +82,7 @@ export const LoggingMiddleware = pinoHttp({
|
|||
},
|
||||
customErrorObject(req, res, err, val: Record<string, unknown>) {
|
||||
const requestStatus = 'failed'
|
||||
const requestPath = req.url?.split('?')[0] || 'unknown'
|
||||
const requestPath = getRequestPath(req) || 'unknown'
|
||||
const appBindings = res.vueLoggerBindings || {}
|
||||
|
||||
return {
|
||||
|
@ -107,9 +107,10 @@ export const LoggingMiddleware = pinoHttp({
|
|||
const realRaw = get(res, 'raw.raw') as typeof res.raw
|
||||
const isRequestCompleted = !!realRaw.writableEnded
|
||||
const isRequestAborted = !isRequestCompleted
|
||||
const statusCode = res.statusCode || res.raw.statusCode || realRaw.statusCode
|
||||
|
||||
return {
|
||||
statusCode: res.raw.statusCode,
|
||||
statusCode,
|
||||
// Allowlist useful headers
|
||||
headers: resRaw.headers,
|
||||
isRequestAborted
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"extends": "../.nuxt/tsconfig.server.json",
|
||||
"compilerOptions": {
|
||||
"verbatimModuleSyntax": true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import type { Optional } from '@speckle/shared'
|
||||
import type pino from 'pino'
|
||||
import { buildLogger } from '~/server/lib/core/helpers/observability'
|
||||
|
||||
let logger: Optional<pino.Logger> = undefined
|
||||
|
||||
const createLogger = () => {
|
||||
const {
|
||||
public: { logLevel, logPretty, speckleServerVersion, serverName }
|
||||
} = useRuntimeConfig()
|
||||
|
||||
const logger = buildLogger(logLevel, logPretty).child({
|
||||
browser: false,
|
||||
speckleServerVersion,
|
||||
serverName,
|
||||
frontendType: 'frontend-2',
|
||||
serverLogger: true
|
||||
})
|
||||
|
||||
return logger
|
||||
}
|
||||
|
||||
export const useLogger = () => {
|
||||
if (!logger) {
|
||||
logger = createLogger()
|
||||
}
|
||||
|
||||
return logger
|
||||
}
|
|
@ -126,4 +126,9 @@ STRATEGY_LOCAL=true
|
|||
# FRONTEND_HOST=127.0.0.1
|
||||
# FRONTEND_PORT=8081
|
||||
|
||||
SPECKLE_AUTOMATE_URL="http://127.0.0.1:3030"
|
||||
############################################################
|
||||
# Speckle automate related variables
|
||||
# the env var is only needed if you are running the server and
|
||||
# the execution engine locally
|
||||
# SPECKLE_AUTOMATE_URL="http://127.0.0.1:3030"
|
||||
############################################################
|
||||
|
|
|
@ -12,6 +12,11 @@ const { init, startHttp } = require('../app')
|
|||
|
||||
init()
|
||||
.then(({ app, server }) => startHttp(server, app))
|
||||
.catch((err) => logger.error(err))
|
||||
.catch((err) => {
|
||||
logger.error(err, 'Failed to start server. Exiting with non-zero exit code...')
|
||||
|
||||
// kill it with fire 🔥
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
// 💥
|
||||
|
|
|
@ -6,6 +6,11 @@ const { init, startHttp } = require('../dist/app')
|
|||
|
||||
init()
|
||||
.then(({ app, server }) => startHttp(server, app))
|
||||
.catch((err) => logger.error(err))
|
||||
.catch((err) => {
|
||||
logger.error(err, 'Failed to start server. Exiting with non-zero exit code...')
|
||||
|
||||
// kill it with fire 🔥
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
// 💥
|
||||
|
|
|
@ -106,9 +106,10 @@ export const LoggingExpressMiddleware = HttpLogger({
|
|||
}
|
||||
const serverRes = get(res, 'raw.raw') as ServerResponse
|
||||
const auth = serverRes.req.context
|
||||
const statusCode = res.statusCode || res.raw.statusCode || serverRes.statusCode
|
||||
|
||||
return {
|
||||
statusCode: res.raw.statusCode,
|
||||
statusCode,
|
||||
// Allowlist useful headers
|
||||
headers: Object.fromEntries(
|
||||
Object.entries(resRaw.raw.headers).filter(
|
||||
|
|
|
@ -1,220 +0,0 @@
|
|||
'use strict'
|
||||
const knex = require('@/db/knex')
|
||||
const Scopes = () => knex('scopes')
|
||||
const Apps = () => knex('server_apps')
|
||||
const AppScopes = () => knex('server_apps_scopes')
|
||||
|
||||
const { getApp } = require('@/modules/auth/services/apps')
|
||||
const { Scopes: ScopesConst } = require('@/modules/core/helpers/mainConstants')
|
||||
const { difference } = require('lodash')
|
||||
const { moduleLogger } = require('@/logging/logging')
|
||||
const { speckleAutomateUrl } = require('@/modules/shared/helpers/envHelper')
|
||||
|
||||
let allScopes = []
|
||||
|
||||
module.exports = async () => {
|
||||
allScopes = await Scopes().select('*')
|
||||
|
||||
// Note: shallow cloning of app objs so as to not interfere with the original objects.
|
||||
await registerOrUpdateApp({ ...SpeckleWebApp })
|
||||
await registerOrUpdateApp({ ...SpeckleApiExplorer })
|
||||
await registerOrUpdateApp({ ...SpeckleDesktopApp })
|
||||
await registerOrUpdateApp({ ...SpeckleConnectorApp })
|
||||
await registerOrUpdateApp({ ...SpeckleExcel })
|
||||
await registerOrUpdateApp({ ...SpecklePowerBi })
|
||||
await registerOrUpdateApp({ ...SpeckleAutomate })
|
||||
}
|
||||
|
||||
async function registerOrUpdateApp(app) {
|
||||
if (app.scopes && app.scopes === 'all') {
|
||||
// let scopes = await Scopes( ).select( '*' )
|
||||
// logger.debug( allScopes.length )
|
||||
app.scopes = allScopes.map((s) => s.name)
|
||||
}
|
||||
|
||||
const existingApp = await getApp({ id: app.id })
|
||||
if (existingApp) {
|
||||
updateDefaultApp(app, existingApp)
|
||||
} else {
|
||||
await registerDefaultApp(app)
|
||||
}
|
||||
}
|
||||
|
||||
async function registerDefaultApp(app) {
|
||||
const scopes = app.scopes.map((s) => ({ appId: app.id, scopeName: s }))
|
||||
delete app.scopes
|
||||
await Apps().insert(app)
|
||||
await AppScopes().insert(scopes)
|
||||
}
|
||||
|
||||
async function updateDefaultApp(app, existingApp) {
|
||||
const existingAppScopes = existingApp.scopes.map((s) => s.name)
|
||||
|
||||
const newScopes = difference(app.scopes, existingAppScopes)
|
||||
const removedScopes = difference(existingAppScopes, app.scopes)
|
||||
|
||||
let affectedTokenIds = []
|
||||
|
||||
if (newScopes.length || removedScopes.length) {
|
||||
moduleLogger.info(`🔑 Updating default app ${app.name}`)
|
||||
affectedTokenIds = await knex('user_server_app_tokens')
|
||||
.where({ appId: app.id })
|
||||
.pluck('tokenId')
|
||||
}
|
||||
|
||||
// the internal code block makes sure if an error occurred, the trx gets rolled back
|
||||
await knex.transaction(async (trx) => {
|
||||
// add new scopes to the app
|
||||
if (newScopes.length)
|
||||
await AppScopes()
|
||||
.insert(newScopes.map((s) => ({ appId: app.id, scopeName: s })))
|
||||
.transacting(trx)
|
||||
|
||||
// remove scopes from the app
|
||||
if (removedScopes.length)
|
||||
await AppScopes()
|
||||
.where({ appId: app.id })
|
||||
.whereIn('scopeName', removedScopes)
|
||||
.delete()
|
||||
.transacting(trx)
|
||||
|
||||
//update user tokens with scope changes
|
||||
if (affectedTokenIds.length)
|
||||
await Promise.all(
|
||||
affectedTokenIds.map(async (tokenId) => {
|
||||
if (newScopes.length)
|
||||
await knex('token_scopes')
|
||||
.insert(newScopes.map((s) => ({ tokenId, scopeName: s })))
|
||||
.transacting(trx)
|
||||
|
||||
if (removedScopes.length)
|
||||
await knex('token_scopes')
|
||||
.where({ tokenId })
|
||||
.whereIn('scopeName', removedScopes)
|
||||
.delete()
|
||||
.transacting(trx)
|
||||
})
|
||||
)
|
||||
delete app.scopes
|
||||
await Apps().where({ id: app.id }).update(app).transacting(trx)
|
||||
})
|
||||
}
|
||||
|
||||
// this is exported to be able to test the retention of permissions
|
||||
module.exports.updateDefaultApp = updateDefaultApp
|
||||
|
||||
const SpeckleWebApp = {
|
||||
id: 'spklwebapp',
|
||||
secret: 'spklwebapp',
|
||||
name: 'Speckle Web Manager',
|
||||
description:
|
||||
'The Speckle Web Manager is your one-stop place to manage and coordinate your data.',
|
||||
trustByDefault: true,
|
||||
public: true,
|
||||
redirectUrl: process.env.CANONICAL_URL,
|
||||
scopes: 'all'
|
||||
}
|
||||
|
||||
const SpeckleApiExplorer = {
|
||||
id: 'explorer',
|
||||
secret: 'explorer',
|
||||
name: 'Speckle Explorer',
|
||||
description: 'GraphiQL Playground with authentication.',
|
||||
trustByDefault: true,
|
||||
public: true,
|
||||
redirectUrl: new URL('/explorer', process.env.CANONICAL_URL).toString(),
|
||||
scopes: 'all'
|
||||
}
|
||||
|
||||
const SpeckleDesktopApp = {
|
||||
id: 'sdm',
|
||||
secret: 'sdm',
|
||||
name: 'Speckle Desktop Manager',
|
||||
description:
|
||||
'Manages local installations of Speckle connectors, kits and everything else.',
|
||||
trustByDefault: true,
|
||||
public: true,
|
||||
redirectUrl: 'speckle://account',
|
||||
scopes: [
|
||||
ScopesConst.Streams.Read,
|
||||
ScopesConst.Streams.Write,
|
||||
ScopesConst.Profile.Read,
|
||||
ScopesConst.Profile.Email,
|
||||
ScopesConst.Users.Read,
|
||||
ScopesConst.Users.Invite
|
||||
]
|
||||
}
|
||||
|
||||
const SpeckleConnectorApp = {
|
||||
id: 'sca',
|
||||
secret: 'sca',
|
||||
name: 'Speckle Connector',
|
||||
description: 'A Speckle Desktop Connectors.',
|
||||
trustByDefault: true,
|
||||
public: true,
|
||||
redirectUrl: 'http://localhost:29363',
|
||||
scopes: [
|
||||
ScopesConst.Streams.Read,
|
||||
ScopesConst.Streams.Write,
|
||||
ScopesConst.Profile.Read,
|
||||
ScopesConst.Profile.Email,
|
||||
ScopesConst.Users.Read,
|
||||
ScopesConst.Users.Invite
|
||||
]
|
||||
}
|
||||
|
||||
const SpeckleExcel = {
|
||||
id: 'spklexcel',
|
||||
secret: 'spklexcel',
|
||||
name: 'Speckle Connector For Excel',
|
||||
description:
|
||||
'The Speckle Connector For Excel. For more info, check the docs here: https://speckle.guide/user/excel.',
|
||||
trustByDefault: true,
|
||||
public: true,
|
||||
redirectUrl: 'https://speckle-excel.netlify.app',
|
||||
scopes: [
|
||||
ScopesConst.Streams.Read,
|
||||
ScopesConst.Streams.Write,
|
||||
ScopesConst.Profile.Read,
|
||||
ScopesConst.Profile.Email,
|
||||
ScopesConst.Users.Read,
|
||||
ScopesConst.Users.Invite
|
||||
]
|
||||
}
|
||||
|
||||
const SpecklePowerBi = {
|
||||
id: 'spklpwerbi',
|
||||
secret: 'spklpwerbi',
|
||||
name: 'Speckle Connector For PowerBI',
|
||||
description:
|
||||
'The Speckle Connector For Excel. For more info check the docs here: https://speckle.guide/user/powerbi.html.',
|
||||
trustByDefault: true,
|
||||
public: true,
|
||||
redirectUrl: 'https://oauth.powerbi.com/views/oauthredirect.html',
|
||||
scopes: [
|
||||
ScopesConst.Streams.Read,
|
||||
ScopesConst.Profile.Read,
|
||||
ScopesConst.Profile.Email,
|
||||
ScopesConst.Users.Read,
|
||||
ScopesConst.Users.Invite
|
||||
]
|
||||
}
|
||||
|
||||
const SpeckleAutomate = {
|
||||
id: 'spklautoma',
|
||||
secret: 'spklautoma',
|
||||
name: 'Speckle Automate',
|
||||
description: 'Our automation platform',
|
||||
trustByDefault: true,
|
||||
public: true,
|
||||
redirectUrl: `${speckleAutomateUrl()}/authn/callback`,
|
||||
scopes: [
|
||||
ScopesConst.Profile.Email,
|
||||
ScopesConst.Profile.Read,
|
||||
ScopesConst.Users.Read,
|
||||
ScopesConst.Tokens.Write,
|
||||
ScopesConst.Streams.Read,
|
||||
ScopesConst.Streams.Write,
|
||||
ScopesConst.Automate.ReportResults
|
||||
]
|
||||
}
|
|
@ -0,0 +1,136 @@
|
|||
import { Scopes } from '@/modules/core/helpers/mainConstants'
|
||||
import { speckleAutomateUrl, getServerOrigin } from '@/modules/shared/helpers/envHelper'
|
||||
|
||||
const SpeckleWebApp = {
|
||||
id: 'spklwebapp',
|
||||
secret: 'spklwebapp',
|
||||
name: 'Speckle Web Manager',
|
||||
description:
|
||||
'The Speckle Web Manager is your one-stop place to manage and coordinate your data.',
|
||||
trustByDefault: true,
|
||||
public: true,
|
||||
redirectUrl: getServerOrigin(),
|
||||
scopes: 'all'
|
||||
}
|
||||
|
||||
const SpeckleApiExplorer = {
|
||||
id: 'explorer',
|
||||
secret: 'explorer',
|
||||
name: 'Speckle Explorer',
|
||||
description: 'GraphiQL Playground with authentication.',
|
||||
trustByDefault: true,
|
||||
public: true,
|
||||
redirectUrl: new URL('/explorer', getServerOrigin()).toString(),
|
||||
scopes: 'all'
|
||||
}
|
||||
|
||||
const SpeckleDesktopApp = {
|
||||
id: 'sdm',
|
||||
secret: 'sdm',
|
||||
name: 'Speckle Desktop Manager',
|
||||
description:
|
||||
'Manages local installations of Speckle connectors, kits and everything else.',
|
||||
trustByDefault: true,
|
||||
public: true,
|
||||
redirectUrl: 'speckle://account',
|
||||
scopes: [
|
||||
Scopes.Streams.Read,
|
||||
Scopes.Streams.Write,
|
||||
Scopes.Profile.Read,
|
||||
Scopes.Profile.Email,
|
||||
Scopes.Users.Read,
|
||||
Scopes.Users.Invite
|
||||
]
|
||||
}
|
||||
|
||||
const SpeckleConnectorApp = {
|
||||
id: 'sca',
|
||||
secret: 'sca',
|
||||
name: 'Speckle Connector',
|
||||
description: 'A Speckle Desktop Connectors.',
|
||||
trustByDefault: true,
|
||||
public: true,
|
||||
redirectUrl: 'http://localhost:29363',
|
||||
scopes: [
|
||||
Scopes.Streams.Read,
|
||||
Scopes.Streams.Write,
|
||||
Scopes.Profile.Read,
|
||||
Scopes.Profile.Email,
|
||||
Scopes.Users.Read,
|
||||
Scopes.Users.Invite
|
||||
]
|
||||
}
|
||||
|
||||
const SpeckleExcel = {
|
||||
id: 'spklexcel',
|
||||
secret: 'spklexcel',
|
||||
name: 'Speckle Connector For Excel',
|
||||
description:
|
||||
'The Speckle Connector For Excel. For more info, check the docs here: https://speckle.guide/user/excel.',
|
||||
trustByDefault: true,
|
||||
public: true,
|
||||
redirectUrl: 'https://speckle-excel.netlify.app',
|
||||
scopes: [
|
||||
Scopes.Streams.Read,
|
||||
Scopes.Streams.Write,
|
||||
Scopes.Profile.Read,
|
||||
Scopes.Profile.Email,
|
||||
Scopes.Users.Read,
|
||||
Scopes.Users.Invite
|
||||
]
|
||||
}
|
||||
|
||||
const SpecklePowerBi = {
|
||||
id: 'spklpwerbi',
|
||||
secret: 'spklpwerbi',
|
||||
name: 'Speckle Connector For PowerBI',
|
||||
description:
|
||||
'The Speckle Connector For Excel. For more info check the docs here: https://speckle.guide/user/powerbi.html.',
|
||||
trustByDefault: true,
|
||||
public: true,
|
||||
redirectUrl: 'https://oauth.powerbi.com/views/oauthredirect.html',
|
||||
scopes: [
|
||||
Scopes.Streams.Read,
|
||||
Scopes.Profile.Read,
|
||||
Scopes.Profile.Email,
|
||||
Scopes.Users.Read,
|
||||
Scopes.Users.Invite
|
||||
]
|
||||
}
|
||||
|
||||
const SpeckleAutomate = {
|
||||
id: 'spklautoma',
|
||||
secret: 'spklautoma',
|
||||
name: 'Speckle Automate',
|
||||
description: 'Our automation platform',
|
||||
trustByDefault: true,
|
||||
public: true,
|
||||
redirectUrl: `${speckleAutomateUrl()}/authn/callback`,
|
||||
scopes: [
|
||||
Scopes.Profile.Email,
|
||||
Scopes.Profile.Read,
|
||||
Scopes.Users.Read,
|
||||
Scopes.Tokens.Write,
|
||||
Scopes.Streams.Read,
|
||||
Scopes.Streams.Write,
|
||||
Scopes.Automate.ReportResults
|
||||
]
|
||||
}
|
||||
|
||||
const defaultApps = [
|
||||
SpeckleWebApp,
|
||||
SpeckleApiExplorer,
|
||||
SpeckleDesktopApp,
|
||||
SpeckleConnectorApp,
|
||||
SpeckleExcel,
|
||||
SpecklePowerBi,
|
||||
SpeckleAutomate
|
||||
]
|
||||
|
||||
export function getDefaultApps() {
|
||||
return defaultApps
|
||||
}
|
||||
|
||||
export function getDefaultApp({ id }: { id: string }) {
|
||||
return defaultApps.find((app) => app.id === id) || null
|
||||
}
|
|
@ -21,5 +21,5 @@ exports.init = async (app) => {
|
|||
exports.finalize = async () => {
|
||||
// Note: we're registering the default apps last as we want to ensure that all
|
||||
// scopes have been registered by any other modules.
|
||||
await require('./defaultApps')()
|
||||
await require('./manageDefaultApps')()
|
||||
}
|
||||
|
|
|
@ -0,0 +1,100 @@
|
|||
'use strict'
|
||||
const knex = require('@/db/knex')
|
||||
const Scopes = () => knex('scopes')
|
||||
const Apps = () => knex('server_apps')
|
||||
const AppScopes = () => knex('server_apps_scopes')
|
||||
|
||||
const { getApp } = require('@/modules/auth/services/apps')
|
||||
const { difference } = require('lodash')
|
||||
const { moduleLogger } = require('@/logging/logging')
|
||||
const { getDefaultApps } = require('@/modules/auth/defaultApps')
|
||||
|
||||
let allScopes = []
|
||||
|
||||
module.exports = async () => {
|
||||
allScopes = await Scopes().select('*')
|
||||
|
||||
// Note: shallow cloning of app objs so as to not interfere with the original objects.
|
||||
await Promise.all(getDefaultApps().map((app) => registerOrUpdateApp({ ...app })))
|
||||
}
|
||||
|
||||
async function registerOrUpdateApp(app) {
|
||||
if (app.scopes && app.scopes === 'all') {
|
||||
// let scopes = await Scopes( ).select( '*' )
|
||||
// logger.debug( allScopes.length )
|
||||
app.scopes = allScopes.map((s) => s.name)
|
||||
}
|
||||
|
||||
const existingApp = await getApp({ id: app.id })
|
||||
if (existingApp) {
|
||||
updateDefaultApp(app, existingApp)
|
||||
} else {
|
||||
await registerDefaultApp(app)
|
||||
}
|
||||
}
|
||||
|
||||
async function registerDefaultApp(app) {
|
||||
const scopes = app.scopes.map((s) => ({ appId: app.id, scopeName: s }))
|
||||
delete app.scopes
|
||||
await Apps().insert(app)
|
||||
await AppScopes().insert(scopes)
|
||||
}
|
||||
|
||||
async function updateDefaultApp(app, existingApp) {
|
||||
const existingAppScopes = existingApp.scopes.map((s) => s.name)
|
||||
|
||||
const newScopes = difference(app.scopes, existingAppScopes)
|
||||
const removedScopes = difference(existingAppScopes, app.scopes)
|
||||
|
||||
let affectedTokenIds = []
|
||||
|
||||
if (newScopes.length || removedScopes.length) {
|
||||
moduleLogger.info(`🔑 Updating default app ${app.name}`)
|
||||
affectedTokenIds = await knex('user_server_app_tokens')
|
||||
.where({ appId: app.id })
|
||||
.pluck('tokenId')
|
||||
}
|
||||
|
||||
// the internal code block makes sure if an error occurred, the trx gets rolled back
|
||||
await knex.transaction(async (trx) => {
|
||||
// add new scopes to the app
|
||||
if (newScopes.length)
|
||||
await AppScopes()
|
||||
.insert(newScopes.map((s) => ({ appId: app.id, scopeName: s })))
|
||||
.transacting(trx)
|
||||
|
||||
// remove scopes from the app
|
||||
if (removedScopes.length)
|
||||
await AppScopes()
|
||||
.where({ appId: app.id })
|
||||
.whereIn('scopeName', removedScopes)
|
||||
.delete()
|
||||
.transacting(trx)
|
||||
|
||||
//update user tokens with scope changes
|
||||
if (affectedTokenIds.length)
|
||||
await Promise.all(
|
||||
affectedTokenIds.map(async (tokenId) => {
|
||||
if (newScopes.length)
|
||||
await knex('token_scopes')
|
||||
.insert(newScopes.map((s) => ({ tokenId, scopeName: s })))
|
||||
.transacting(trx)
|
||||
|
||||
if (removedScopes.length)
|
||||
await knex('token_scopes')
|
||||
.where({ tokenId })
|
||||
.whereIn('scopeName', removedScopes)
|
||||
.delete()
|
||||
.transacting(trx)
|
||||
})
|
||||
)
|
||||
delete app.scopes
|
||||
// not writing the redirect url to the DB anymore
|
||||
// it will be patched on an application level from the default app definitions
|
||||
delete app.redirectUrl
|
||||
await Apps().where({ id: app.id }).update(app).transacting(trx)
|
||||
})
|
||||
}
|
||||
|
||||
// this is exported to be able to test the retention of permissions
|
||||
module.exports.updateDefaultApp = updateDefaultApp
|
|
@ -5,6 +5,7 @@ const knex = require(`@/db/knex`)
|
|||
|
||||
const { createBareToken, createAppToken } = require(`@/modules/core/services/tokens`)
|
||||
const { logger } = require('@/logging/logging')
|
||||
const { getDefaultApp } = require('@/modules/auth/defaultApps')
|
||||
const Users = () => knex('users')
|
||||
const ApiTokens = () => knex('api_tokens')
|
||||
const ServerApps = () => knex('server_apps')
|
||||
|
@ -14,6 +15,12 @@ const Scopes = () => knex('scopes')
|
|||
const AuthorizationCodes = () => knex('authorization_codes')
|
||||
const RefreshTokens = () => knex('refresh_tokens')
|
||||
|
||||
const addDefaultAppOverrides = (app) => {
|
||||
const defaultApp = getDefaultApp({ id: app.id })
|
||||
if (defaultApp) app.redirectUrl = defaultApp.redirectUrl
|
||||
return app
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
async getApp({ id }) {
|
||||
const allScopes = await Scopes().select('*')
|
||||
|
@ -30,7 +37,8 @@ module.exports = {
|
|||
.select('id', 'name', 'avatar')
|
||||
.where({ id: app.authorId })
|
||||
.first()
|
||||
return app
|
||||
|
||||
return addDefaultAppOverrides(app)
|
||||
},
|
||||
|
||||
async getAllPublicApps() {
|
||||
|
@ -40,6 +48,7 @@ module.exports = {
|
|||
'server_apps.name',
|
||||
'server_apps.description',
|
||||
'server_apps.trustByDefault',
|
||||
'server_apps.redirectUrl',
|
||||
'server_apps.logo',
|
||||
'server_apps.termsAndConditionsLink',
|
||||
'users.name as authorName',
|
||||
|
@ -50,6 +59,8 @@ module.exports = {
|
|||
.orderBy('server_apps.trustByDefault', 'DESC')
|
||||
|
||||
apps.forEach((app) => {
|
||||
app = addDefaultAppOverrides(app)
|
||||
|
||||
if (app.authorName && app.authorId) {
|
||||
app.author = { name: app.authorName, id: app.authorId }
|
||||
}
|
||||
|
@ -100,10 +111,13 @@ module.exports = {
|
|||
)
|
||||
|
||||
const { rows } = await query
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
author: r.author?.id ? r.author : null
|
||||
}))
|
||||
return rows.map((r) => {
|
||||
const app = {
|
||||
...r,
|
||||
author: r.author?.id ? r.author : null
|
||||
}
|
||||
return addDefaultAppOverrides(app)
|
||||
})
|
||||
},
|
||||
|
||||
async createApp(app) {
|
||||
|
|
|
@ -1,52 +1,63 @@
|
|||
/* eslint-disable camelcase */
|
||||
import mailchimp from '@mailchimp/mailchimp_marketing'
|
||||
import { logger } from '@/logging/logging'
|
||||
import { md5 } from '@/modules/shared/helpers/cryptoHelper'
|
||||
import {
|
||||
getMailchimpConfig,
|
||||
getMailchimpStatus
|
||||
} from '@/modules/shared/helpers/envHelper'
|
||||
import { getUserById } from '@/modules/core/services/users'
|
||||
import { getMailchimpConfig } from '@/modules/shared/helpers/envHelper'
|
||||
// import { getUserById } from '@/modules/core/services/users'
|
||||
import { UserRecord } from '@/modules/core/helpers/types'
|
||||
|
||||
async function addToMailchimpAudience(userId: string) {
|
||||
// Do not do anything (inc. logging) if we do not explicitely enable it
|
||||
if (!getMailchimpStatus()) return
|
||||
let mailchimpInitialized = false
|
||||
|
||||
// Note: fails here should not block registration at any cost
|
||||
try {
|
||||
const config = getMailchimpConfig() // Note: throws an error if not configured
|
||||
function initializeMailchimp() {
|
||||
if (mailchimpInitialized) return
|
||||
const config = getMailchimpConfig() // Note: throws an error if not configured
|
||||
if (!config) throw new Error('Cannot initialize mailchimp without config values')
|
||||
|
||||
mailchimp.setConfig({
|
||||
apiKey: config.apiKey,
|
||||
server: config.serverPrefix
|
||||
})
|
||||
|
||||
const user = await getUserById({ userId })
|
||||
|
||||
if (!user) {
|
||||
throw new Error(
|
||||
'Could not register user for newsletter - no db user record found.'
|
||||
)
|
||||
}
|
||||
|
||||
const [first, second] = user.name.split(' ')
|
||||
const subscriberHash = md5(user.email.toLowerCase())
|
||||
|
||||
// NOTE: using setListMember (NOT addListMember) to prevent errors for previously
|
||||
// registered members.
|
||||
await mailchimp.lists.setListMember(config.listId, subscriberHash, {
|
||||
status_if_new: 'subscribed',
|
||||
email_address: user.email,
|
||||
merge_fields: {
|
||||
EMAIL: user.email,
|
||||
FNAME: first,
|
||||
LNAME: second,
|
||||
FULLNAME: user.name // NOTE: this field needs to be set in the audience merge fields
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
logger.warn(e, 'Failed to register user to newsletter.')
|
||||
}
|
||||
mailchimp.setConfig({
|
||||
apiKey: config.apiKey,
|
||||
server: config.serverPrefix
|
||||
})
|
||||
mailchimpInitialized = true
|
||||
}
|
||||
|
||||
export { addToMailchimpAudience }
|
||||
async function addToMailchimpAudience(user: UserRecord, listId: string) {
|
||||
initializeMailchimp()
|
||||
// Do not do anything (inc. logging) if we do not explicitly enable it
|
||||
// Note: fails here should not block registration at any cost
|
||||
|
||||
const [first, second] = user.name.split(' ')
|
||||
const subscriberHash = md5(user.email.toLowerCase())
|
||||
|
||||
// NOTE: using setListMember (NOT addListMember) to prevent errors for previously
|
||||
// registered members.
|
||||
await mailchimp.lists.setListMember(listId, subscriberHash, {
|
||||
status_if_new: 'subscribed',
|
||||
email_address: user.email,
|
||||
merge_fields: {
|
||||
EMAIL: user.email,
|
||||
FNAME: first,
|
||||
LNAME: second,
|
||||
FULLNAME: user.name // NOTE: this field needs to be set in the audience merge fields
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function triggerMailchimpCustomerJourney(
|
||||
user: UserRecord,
|
||||
{
|
||||
listId,
|
||||
journeyId,
|
||||
stepId
|
||||
}: {
|
||||
listId: string
|
||||
journeyId: number
|
||||
stepId: number
|
||||
}
|
||||
) {
|
||||
await addToMailchimpAudience(user, listId)
|
||||
// @ts-expect-error the mailchimp api typing sucks
|
||||
await mailchimp.customerJourneys.trigger(journeyId, stepId, {
|
||||
email_address: user.email
|
||||
})
|
||||
}
|
||||
|
||||
export { addToMailchimpAudience, triggerMailchimpCustomerJourney }
|
||||
|
|
|
@ -6,12 +6,21 @@ const passport = require('passport')
|
|||
|
||||
const sentry = require('@/logging/sentryHelper')
|
||||
const { createAuthorizationCode } = require('./services/apps')
|
||||
const { getFrontendOrigin } = require('@/modules/shared/helpers/envHelper')
|
||||
const {
|
||||
getFrontendOrigin,
|
||||
getMailchimpStatus,
|
||||
getMailchimpNewsletterIds,
|
||||
getMailchimpOnboardingIds
|
||||
} = require('@/modules/shared/helpers/envHelper')
|
||||
const { isSSLServer, getRedisUrl } = require('@/modules/shared/helpers/envHelper')
|
||||
const { authLogger } = require('@/logging/logging')
|
||||
const { authLogger, logger } = require('@/logging/logging')
|
||||
const { createRedisClient } = require('@/modules/shared/redis/redis')
|
||||
const { mixpanel } = require('@/modules/shared/utils/mixpanel')
|
||||
const { addToMailchimpAudience } = require('./services/mailchimp')
|
||||
const { mixpanel, resolveMixpanelUserId } = require('@/modules/shared/utils/mixpanel')
|
||||
const {
|
||||
addToMailchimpAudience,
|
||||
triggerMailchimpCustomerJourney
|
||||
} = require('./services/mailchimp')
|
||||
const { getUserById } = require('@/modules/core/services/users')
|
||||
/**
|
||||
* TODO: Get rid of session entirely, we don't use it for the app and it's not really necessary for the auth flow, so it only complicates things
|
||||
* NOTE: it does seem used!
|
||||
|
@ -83,16 +92,34 @@ module.exports = async (app) => {
|
|||
urlObj.searchParams.set('register', 'true')
|
||||
|
||||
// Send event to MP
|
||||
const mixpanelUserId = req.user.email
|
||||
? resolveMixpanelUserId(req.user.email)
|
||||
: null
|
||||
const isInvite = !!req.user.isInvite
|
||||
if (req.user.email) {
|
||||
await mixpanel({ userEmail: req.user.email }).track('Sign Up', {
|
||||
if (mixpanelUserId) {
|
||||
await mixpanel({ mixpanelUserId }).track('Sign Up', {
|
||||
isInvite
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (newsletterConsent) {
|
||||
await addToMailchimpAudience(req.user.id)
|
||||
if (getMailchimpStatus()) {
|
||||
try {
|
||||
const user = await getUserById({ userId: req.user.id })
|
||||
if (!user)
|
||||
throw new Error(
|
||||
'Could not register user for mailchimp lists - no db user record found.'
|
||||
)
|
||||
const onboardingIds = getMailchimpOnboardingIds()
|
||||
await triggerMailchimpCustomerJourney(user, onboardingIds)
|
||||
|
||||
if (newsletterConsent) {
|
||||
const { listId } = getMailchimpNewsletterIds()
|
||||
await addToMailchimpAudience(user, listId)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(error, 'Failed to sign up user to mailchimp lists')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const redirectUrl = urlObj.toString()
|
||||
|
|
|
@ -17,7 +17,7 @@ const {
|
|||
} = require('../services/apps')
|
||||
|
||||
const { Scopes } = require('@/modules/core/helpers/mainConstants')
|
||||
const { updateDefaultApp } = require('@/modules/auth/defaultApps')
|
||||
const { updateDefaultApp } = require('@/modules/auth/manageDefaultApps')
|
||||
const knex = require('@/db/knex')
|
||||
const cryptoRandomString = require('crypto-random-string')
|
||||
|
||||
|
|
|
@ -140,7 +140,7 @@ module.exports = {
|
|||
|
||||
/**
|
||||
* @param {{userId: string}} param0
|
||||
* @returns {import('@/modules/core/helpers/userHelper').UserRecord | null}
|
||||
* @returns Promise<{import('@/modules/core/helpers/userHelper').UserRecord | null>}
|
||||
*/
|
||||
async getUserById({ userId }) {
|
||||
const user = await Users().where({ id: userId }).select('*').first()
|
||||
|
|
|
@ -93,21 +93,35 @@ export function getMailchimpStatus() {
|
|||
}
|
||||
|
||||
export function getMailchimpConfig() {
|
||||
if (
|
||||
!process.env.MAILCHIMP_API_KEY ||
|
||||
!process.env.MAILCHIMP_SERVER_PREFIX ||
|
||||
!process.env.MAILCHIMP_LIST_ID
|
||||
) {
|
||||
throw new MisconfiguredEnvironmentError('Mailchimp is not configured')
|
||||
}
|
||||
|
||||
if (!getMailchimpStatus()) return null
|
||||
if (!process.env.MAILCHIMP_API_KEY || !process.env.MAILCHIMP_SERVER_PREFIX)
|
||||
throw new MisconfiguredEnvironmentError('Mailchimp api is not configured')
|
||||
return {
|
||||
apiKey: process.env.MAILCHIMP_API_KEY,
|
||||
serverPrefix: process.env.MAILCHIMP_SERVER_PREFIX,
|
||||
listId: process.env.MAILCHIMP_LIST_ID
|
||||
serverPrefix: process.env.MAILCHIMP_SERVER_PREFIX
|
||||
}
|
||||
}
|
||||
|
||||
export function getMailchimpOnboardingIds() {
|
||||
if (
|
||||
!process.env.MAILCHIMP_ONBOARDING_LIST_ID ||
|
||||
!process.env.MAILCHIMP_ONBOARDING_JOURNEY_ID ||
|
||||
!process.env.MAILCHIMP_ONBOARDING_STEP_ID
|
||||
)
|
||||
throw new MisconfiguredEnvironmentError('Mailchimp onboarding is not configured')
|
||||
return {
|
||||
listId: process.env.MAILCHIMP_ONBOARDING_LIST_ID,
|
||||
journeyId: parseInt(process.env.MAILCHIMP_ONBOARDING_JOURNEY_ID),
|
||||
stepId: parseInt(process.env.MAILCHIMP_ONBOARDING_STEP_ID)
|
||||
}
|
||||
}
|
||||
|
||||
export function getMailchimpNewsletterIds() {
|
||||
if (!process.env.MAILCHIMP_NEWSLETTER_LIST_ID)
|
||||
throw new MisconfiguredEnvironmentError('Mailchimp newsletter id is not configured')
|
||||
return { listId: process.env.MAILCHIMP_NEWSLETTER_LIST_ID }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get app base url / canonical url / origin
|
||||
* TODO: Go over all getBaseUrl() usages and move them to getXOrigin() instead
|
||||
|
@ -197,8 +211,7 @@ export function enableMixpanel() {
|
|||
}
|
||||
|
||||
export function speckleAutomateUrl() {
|
||||
const automateUrl =
|
||||
process.env.SPECKLE_AUTOMATE_URL || 'https://automate.speckle.systems'
|
||||
const automateUrl = process.env.SPECKLE_AUTOMATE_URL
|
||||
return automateUrl
|
||||
}
|
||||
|
||||
|
|
|
@ -76,3 +76,42 @@ export const WithManualClose: StoryType = {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const NoCtaOrDescription: StoryObj = {
|
||||
render: (args) => ({
|
||||
components: { ToastRenderer, FormButton },
|
||||
setup() {
|
||||
const notification = ref(null as Nullable<ToastNotification>)
|
||||
const onClick = () => {
|
||||
// Update notification without cta or description
|
||||
notification.value = {
|
||||
type: ToastNotificationType.Info,
|
||||
title: 'Displays a toast notification'
|
||||
}
|
||||
|
||||
// Clear after 2s
|
||||
setTimeout(() => (notification.value = null), 2000)
|
||||
}
|
||||
return { args, onClick, notification }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<FormButton @click="onClick">Trigger Title Only</FormButton>
|
||||
<ToastRenderer v-model:notification="notification"/>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Displays a toast notification with only a title, no description or CTA.'
|
||||
},
|
||||
source: {
|
||||
code: `
|
||||
<FormButton @click="onClick">Trigger Title Only</FormButton>
|
||||
<ToastRenderer v-model:notification="notification"/>
|
||||
`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
aria-live="assertive"
|
||||
class="pointer-events-none fixed inset-0 flex items-end px-4 py-6 mt-10 sm:items-start sm:p-6 z-50"
|
||||
>
|
||||
<div class="flex w-full flex-col items-center space-y-4 sm:items-end">
|
||||
<div class="flex w-full flex-col items-center gap-4 sm:items-end">
|
||||
<!-- Notification panel, dynamically insert this into the live region when it needs to be displayed -->
|
||||
<Transition
|
||||
enter-active-class="transform ease-out duration-300 transition"
|
||||
|
@ -15,67 +15,72 @@
|
|||
>
|
||||
<div
|
||||
v-if="notification"
|
||||
class="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-foundation text-foreground shadow-lg ring-1 ring-primary-muted ring-opacity-5"
|
||||
class="pointer-events-auto w-full max-w-[20rem] overflow-hidden rounded-lg bg-foundation text-foreground shadow-lg ring-1 ring-primary-muted ring-opacity-5"
|
||||
:class="isTitleOnly ? 'p-2' : 'p-3'"
|
||||
>
|
||||
<div class="p-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<CheckCircleIcon
|
||||
v-if="notification.type === ToastNotificationType.Success"
|
||||
class="h-6 w-6 text-success"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<XCircleIcon
|
||||
v-else-if="notification.type === ToastNotificationType.Danger"
|
||||
class="h-6 w-6 text-danger"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ExclamationCircleIcon
|
||||
v-else-if="notification.type === ToastNotificationType.Warning"
|
||||
class="h-6 w-6 text-warning"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<InformationCircleIcon
|
||||
v-else-if="notification.type === ToastNotificationType.Info"
|
||||
class="h-6 w-6 text-info"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-2 w-0 flex-1 flex flex-col">
|
||||
<p v-if="notification.title" class="text-foreground font-bold">
|
||||
{{ notification.title }}
|
||||
</p>
|
||||
<p
|
||||
v-if="notification.description"
|
||||
class="label label--light text-foreground-2"
|
||||
>
|
||||
{{ notification.description }}
|
||||
</p>
|
||||
<div v-if="notification.cta" class="flex justify-start mt-2">
|
||||
<CommonTextLink
|
||||
:to="notification.cta.url"
|
||||
class="label"
|
||||
primary
|
||||
@click="onCtaClick"
|
||||
>
|
||||
{{ notification.cta.title }}
|
||||
</CommonTextLink>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="ml-4 flex flex-shrink-0"
|
||||
:class="{ 'self-center': shouldVerticallyCenterCloser }"
|
||||
<div class="flex gap-2" :class="isTitleOnly ? 'items-center' : 'items-start'">
|
||||
<div class="flex-shrink-0">
|
||||
<CheckCircleIcon
|
||||
v-if="notification.type === ToastNotificationType.Success"
|
||||
class="text-success"
|
||||
:class="isTitleOnly ? 'h-5 w-5' : 'h-6 w-6'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<XCircleIcon
|
||||
v-else-if="notification.type === ToastNotificationType.Danger"
|
||||
class="text-danger"
|
||||
:class="isTitleOnly ? 'h-5 w-5' : 'h-6 w-6'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ExclamationCircleIcon
|
||||
v-else-if="notification.type === ToastNotificationType.Warning"
|
||||
class="text-warning"
|
||||
:class="isTitleOnly ? 'h-5 w-5' : 'h-6 w-6'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<InformationCircleIcon
|
||||
v-else-if="notification.type === ToastNotificationType.Info"
|
||||
class="text-info"
|
||||
:class="isTitleOnly ? 'h-5 w-5' : 'h-6 w-6'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full min-w-[10rem]">
|
||||
<p
|
||||
v-if="notification.title"
|
||||
class="text-foreground font-bold"
|
||||
:class="isTitleOnly ? 'text-sm' : 'text-base'"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex rounded-md bg-foundation text-foreground-2 hover:text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
@click="dismiss"
|
||||
{{ notification.title }}
|
||||
</p>
|
||||
<p
|
||||
v-if="notification.description"
|
||||
class="label label--light text-foreground-2 text-sm"
|
||||
>
|
||||
{{ notification.description }}
|
||||
</p>
|
||||
<div v-if="notification.cta">
|
||||
<CommonTextLink
|
||||
:to="notification.cta.url"
|
||||
class="label"
|
||||
size="xs"
|
||||
primary
|
||||
@click="onCtaClick"
|
||||
>
|
||||
<span class="sr-only">Close</span>
|
||||
<XMarkIcon class="h-6 w-6" aria-hidden="true" />
|
||||
</button>
|
||||
{{ notification.cta.title }}
|
||||
</CommonTextLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-2 flex-shrink-0" :class="isTitleOnly ? 'mt-1.5' : ''">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex rounded-md bg-foundation text-foreground-2 hover:text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
@click="dismiss"
|
||||
>
|
||||
<span class="sr-only">Close</span>
|
||||
<XMarkIcon class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
@ -104,7 +109,7 @@ const props = defineProps<{
|
|||
notification: Nullable<ToastNotification>
|
||||
}>()
|
||||
|
||||
const shouldVerticallyCenterCloser = computed(
|
||||
const isTitleOnly = computed(
|
||||
() => !props.notification?.description && !props.notification?.cta
|
||||
)
|
||||
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
import {
|
||||
CameraController,
|
||||
Extension,
|
||||
GeometryType,
|
||||
IViewer,
|
||||
MeshBatch,
|
||||
Vector3
|
||||
} from '@speckle/viewer'
|
||||
import { PerspectiveCamera } from 'three'
|
||||
|
||||
export class CameraPlanes extends Extension {
|
||||
private camerController: CameraController
|
||||
|
||||
public constructor(viewer: IViewer) {
|
||||
super(viewer)
|
||||
this.camerController = viewer.getExtension(CameraController)
|
||||
}
|
||||
|
||||
public onEarlyUpdate(): void {
|
||||
this.computePerspectiveCameraPlanes()
|
||||
}
|
||||
|
||||
public computePerspectiveCameraPlanes() {
|
||||
const camera = this.viewer.getRenderer().renderingCamera as PerspectiveCamera
|
||||
const minDist = this.getClosestGeometryDistance(camera)
|
||||
if (minDist === Number.POSITIVE_INFINITY) return
|
||||
|
||||
const fov = camera.fov
|
||||
const aspect = camera.aspect
|
||||
const nearPlane =
|
||||
Math.max(minDist, 0) /
|
||||
Math.sqrt(
|
||||
1 +
|
||||
Math.pow(Math.tan(((fov / 180) * Math.PI) / 2), 2) * (Math.pow(aspect, 2) + 1)
|
||||
)
|
||||
this.viewer.getRenderer().renderingCamera.near = nearPlane
|
||||
console.log(minDist, nearPlane)
|
||||
}
|
||||
|
||||
public getClosestGeometryDistance(camera: PerspectiveCamera): number {
|
||||
const cameraPosition = camera.position
|
||||
const cameraTarget = this.camerController.controls.getTarget(new Vector3())
|
||||
const cameraDir = new Vector3()
|
||||
.subVectors(cameraTarget, camera.position)
|
||||
.normalize()
|
||||
|
||||
const batches = this.viewer
|
||||
.getRenderer()
|
||||
.batcher.getBatches(undefined, GeometryType.MESH) as MeshBatch[]
|
||||
let minDist = Number.POSITIVE_INFINITY
|
||||
const minPoint = new Vector3()
|
||||
for (let b = 0; b < batches.length; b++) {
|
||||
const result = batches[b].mesh.TAS.closestPointToPoint(cameraPosition)
|
||||
const planarity = cameraDir.dot(
|
||||
new Vector3().subVectors(result.point, cameraPosition).normalize()
|
||||
)
|
||||
if (planarity > 0) {
|
||||
const dist = cameraPosition.distanceTo(result.point)
|
||||
if (dist < minDist) {
|
||||
minDist = dist
|
||||
minPoint.copy(result.point)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return minDist
|
||||
}
|
||||
}
|
|
@ -455,7 +455,7 @@ export default class Sandbox {
|
|||
title: 'Screenshot'
|
||||
})
|
||||
screenshot.on('click', async () => {
|
||||
// console.warn(await this.viewer.screenshot())
|
||||
console.warn(await this.viewer.screenshot())
|
||||
// const start = performance.now()
|
||||
// const nodes = this.viewer.getWorldTree().root.all(
|
||||
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
@ -465,10 +465,6 @@ export default class Sandbox {
|
|||
// this.viewer.cancelLoad(
|
||||
// 'https://latest.speckle.dev/streams/97750296c2/objects/c3138e24a866d447eb86b2a8107b2c09'
|
||||
// )
|
||||
|
||||
this.viewer
|
||||
.getExtension(FilteringExtension)
|
||||
.isolateObjects(this.ids /*['1c8f29e7d48e531f6acbf987a50467f9']*/)
|
||||
})
|
||||
|
||||
const rotate = this.tabs.pages[0].addButton({
|
||||
|
|
|
@ -347,16 +347,23 @@ const getStream = () => {
|
|||
// 'https://latest.speckle.dev/streams/97750296c2/objects/11a7752e40b4ef0620affc55ce9fdf5a'
|
||||
|
||||
// 'https://latest.speckle.dev/streams/92b620fb17/objects/7118603b197c00944f53be650ce721ec'
|
||||
|
||||
// Blender Mega Test Stream
|
||||
// 'https://latest.speckle.dev/streams/c1faab5c62/commits/2ecb757577'
|
||||
// 'https://latest.speckle.dev/streams/c1faab5c62/commits/3deaea94af'
|
||||
// Text and Dimensions
|
||||
// 'https://latest.speckle.dev/streams/3f895e614f/commits/fbc78286c9'
|
||||
// 'https://latest.speckle.dev/streams/55cc1cbf0a/commits/aa72674507'
|
||||
|
||||
// 'https://latest.speckle.dev/streams/55cc1cbf0a/commits/a7f74b6524'
|
||||
// 'https://latest.speckle.dev/streams/85e05b8c72/commits/53f4328211'
|
||||
// 'https://latest.speckle.dev/streams/aea12cab71/commits/787ade768e'
|
||||
|
||||
// 'https://latest.speckle.dev/streams/e9285828d7/commits/9b80b7a70c'
|
||||
// 'https://speckle.xyz/streams/b85d53c3b4/commits/be26146460'
|
||||
// Germany
|
||||
// 'https://latest.speckle.dev/streams/7117052f4e/commits/a646bf659e'
|
||||
// 'https://latest.speckle.dev/streams/aea12cab71/commits/787ade768e'
|
||||
// 'https://speckle.xyz/streams/a29e5c7772/commits/a8cfae2645'
|
||||
)
|
||||
}
|
||||
|
|
|
@ -59,6 +59,7 @@ import { SpeckleWebGLRenderer } from './objects/SpeckleWebGLRenderer'
|
|||
import { SpeckleTypeAllRenderables } from './loaders/GeometryConverter'
|
||||
import SpeckleInstancedMesh from './objects/SpeckleInstancedMesh'
|
||||
import { BaseSpecklePass } from './pipeline/SpecklePass'
|
||||
import { CameraController } from './extensions/core-extensions/CameraController'
|
||||
|
||||
export class RenderingStats {
|
||||
private renderTimeAcc = 0
|
||||
|
@ -236,6 +237,9 @@ export default class SpeckleRenderer {
|
|||
this.pipeline.reset()
|
||||
}
|
||||
})
|
||||
this.cameraProvider.on(CameraControllerEvent.ProjectionChanged, () => {
|
||||
;(this._cameraProvider as CameraController).setCameraPlanes(this.sceneBox)
|
||||
})
|
||||
}
|
||||
|
||||
public get renderingCamera() {
|
||||
|
@ -610,6 +614,7 @@ export default class SpeckleRenderer {
|
|||
/** We'll just update the shadowcatcher after all batches are loaded */
|
||||
this.updateShadowCatcher()
|
||||
this.updateClippingPlanes()
|
||||
;(this._cameraProvider as CameraController).setCameraPlanes(this.sceneBox)
|
||||
delete this.cancel[subtreeId]
|
||||
}
|
||||
|
||||
|
|
|
@ -26,6 +26,7 @@ import Logger from 'js-logger'
|
|||
import { ObjectLayers } from '../../IViewer'
|
||||
import { DrawGroup } from './InstancedMeshBatch'
|
||||
import Materials from '../materials/Materials'
|
||||
import SpeckleStandardColoredMaterial from '../materials/SpeckleStandardColoredMaterial'
|
||||
|
||||
export default class MeshBatch implements Batch {
|
||||
public id: string
|
||||
|
@ -247,6 +248,16 @@ export default class MeshBatch implements Batch {
|
|||
minGradientIndex = Math.min(minGradientIndex, minMaxIndices.minIndex)
|
||||
maxGradientIndex = Math.max(maxGradientIndex, minMaxIndices.maxIndex)
|
||||
}
|
||||
/** We need to update the texture here, because each batch uses it's own clone for any material we use on it
|
||||
* because otherwise three.js won't properly update our custom uniforms
|
||||
*/
|
||||
if (range[k].materialOptions.rampTexture !== undefined) {
|
||||
if (range[k].material instanceof SpeckleStandardColoredMaterial) {
|
||||
;(range[k].material as SpeckleStandardColoredMaterial).setGradientTexture(
|
||||
range[k].materialOptions.rampTexture
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (minGradientIndex < Infinity && maxGradientIndex > 0)
|
||||
|
@ -485,7 +496,7 @@ export default class MeshBatch implements Batch {
|
|||
})
|
||||
.slice()
|
||||
|
||||
this.geometry.groups.sort((a, b) => {
|
||||
groups.sort((a, b) => {
|
||||
const materialA: Material = (this.mesh.material as Array<Material>)[
|
||||
a.materialIndex
|
||||
]
|
||||
|
|
|
@ -2,7 +2,7 @@ import * as THREE from 'three'
|
|||
import CameraControls from 'camera-controls'
|
||||
import { Extension } from './Extension'
|
||||
import { SpeckleCameraControls } from '../../objects/SpeckleCameraControls'
|
||||
import { Box3, OrthographicCamera, PerspectiveCamera, Vector3 } from 'three'
|
||||
import { Box3, OrthographicCamera, PerspectiveCamera, Sphere, Vector3 } from 'three'
|
||||
import { KeyboardKeyHold, HOLD_EVENT_TYPE } from 'hold-event'
|
||||
import { CanonicalView, SpeckleView, InlineView, IViewer } from '../../..'
|
||||
import {
|
||||
|
@ -11,6 +11,7 @@ import {
|
|||
ICameraProvider,
|
||||
PolarView
|
||||
} from './Providers'
|
||||
import Logger from 'js-logger'
|
||||
|
||||
export class CameraController extends Extension implements ICameraProvider {
|
||||
get provide() {
|
||||
|
@ -146,9 +147,9 @@ export class CameraController extends Extension implements ICameraProvider {
|
|||
this.viewer.getContainer().offsetWidth / this.viewer.getContainer().offsetHeight
|
||||
this.perspectiveCamera.updateProjectionMatrix()
|
||||
|
||||
const lineOfSight = new THREE.Vector3()
|
||||
const lineOfSight = new Vector3()
|
||||
this.perspectiveCamera.getWorldDirection(lineOfSight)
|
||||
const target = new THREE.Vector3()
|
||||
const target = new Vector3()
|
||||
this._controls.getTarget(target)
|
||||
const distance = target.clone().sub(this.perspectiveCamera.position)
|
||||
const depth = distance.dot(lineOfSight)
|
||||
|
@ -191,9 +192,9 @@ export class CameraController extends Extension implements ICameraProvider {
|
|||
protected setupOrthoCamera() {
|
||||
this._controls.mouseButtons.wheel = CameraControls.ACTION.ZOOM
|
||||
|
||||
const lineOfSight = new THREE.Vector3()
|
||||
const lineOfSight = new Vector3()
|
||||
this.perspectiveCamera.getWorldDirection(lineOfSight)
|
||||
const target = new THREE.Vector3().copy(this.viewer.World.worldOrigin)
|
||||
const target = new Vector3().copy(this.viewer.World.worldOrigin)
|
||||
const distance = target.clone().sub(this.perspectiveCamera.position)
|
||||
const depth = distance.length()
|
||||
const dims = {
|
||||
|
@ -346,6 +347,30 @@ export class CameraController extends Extension implements ICameraProvider {
|
|||
)
|
||||
}
|
||||
|
||||
public setCameraPlanes(targetVolume: Box3, offsetScale: number = 1) {
|
||||
if (targetVolume.isEmpty()) {
|
||||
Logger.error('Cannot set camera planes for empty volume')
|
||||
return
|
||||
}
|
||||
|
||||
const size = targetVolume.getSize(new Vector3())
|
||||
const maxSize = Math.max(size.x, size.y, size.z)
|
||||
const camFov =
|
||||
this._renderingCamera === this.perspectiveCamera ? this.fieldOfView : 55
|
||||
const camAspect =
|
||||
this._renderingCamera === this.perspectiveCamera ? this.aspect : 1.2
|
||||
const fitHeightDistance = maxSize / (2 * Math.atan((Math.PI * camFov) / 360))
|
||||
const fitWidthDistance = fitHeightDistance / camAspect
|
||||
const distance = offsetScale * Math.max(fitHeightDistance, fitWidthDistance)
|
||||
|
||||
this._controls.minDistance = distance / 100
|
||||
this._controls.maxDistance = distance * 100
|
||||
|
||||
this._renderingCamera.near = distance / 100
|
||||
this._renderingCamera.far = distance * 100
|
||||
this._renderingCamera.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
protected zoom(objectIds?: string[], fit?: number, transition?: boolean) {
|
||||
if (!objectIds) {
|
||||
this.zoomExtents(fit, transition)
|
||||
|
@ -356,7 +381,7 @@ export class CameraController extends Extension implements ICameraProvider {
|
|||
|
||||
private zoomExtents(fit = 1.2, transition = true) {
|
||||
if (this.viewer.getRenderer().clippingVolume.isEmpty()) {
|
||||
const box = new THREE.Box3(new Vector3(-1, -1, -1), new Vector3(1, 1, 1))
|
||||
const box = new Box3(new Vector3(-1, -1, -1), new Vector3(1, 1, 1))
|
||||
this.zoomToBox(box, fit, transition)
|
||||
return
|
||||
}
|
||||
|
@ -376,36 +401,17 @@ export class CameraController extends Extension implements ICameraProvider {
|
|||
|
||||
private zoomToBox(box, fit = 1.2, transition = true) {
|
||||
if (box.max.x === Infinity || box.max.x === -Infinity) {
|
||||
box = new THREE.Box3(new Vector3(-1, -1, -1), new Vector3(1, 1, 1))
|
||||
box = new Box3(new Vector3(-1, -1, -1), new Vector3(1, 1, 1))
|
||||
}
|
||||
const fitOffset = fit
|
||||
|
||||
const size = box.getSize(new Vector3())
|
||||
const target = new THREE.Sphere()
|
||||
const target = new Sphere()
|
||||
box.getBoundingSphere(target)
|
||||
target.radius = target.radius * fitOffset
|
||||
|
||||
const maxSize = Math.max(size.x, size.y, size.z)
|
||||
const camFov =
|
||||
this._renderingCamera === this.perspectiveCamera ? this.fieldOfView : 55
|
||||
const camAspect =
|
||||
this._renderingCamera === this.perspectiveCamera ? this.aspect : 1.2
|
||||
const fitHeightDistance = maxSize / (2 * Math.atan((Math.PI * camFov) / 360))
|
||||
const fitWidthDistance = fitHeightDistance / camAspect
|
||||
const distance = fitOffset * Math.max(fitHeightDistance, fitWidthDistance)
|
||||
|
||||
target.radius = target.radius * fit
|
||||
this._controls.fitToSphere(target, transition)
|
||||
|
||||
this._controls.minDistance = distance / 100
|
||||
this._controls.maxDistance = distance * 100
|
||||
this._renderingCamera.near = Math.max(distance / 100, 0.1)
|
||||
this._renderingCamera.far = distance * 100
|
||||
this._renderingCamera.updateProjectionMatrix()
|
||||
this.setCameraPlanes(box, fit)
|
||||
|
||||
if (this._renderingCamera === this.orthographicCamera) {
|
||||
this._renderingCamera.far = distance * 100
|
||||
this._renderingCamera.updateProjectionMatrix()
|
||||
|
||||
// fit the camera inside, so we don't have clipping plane issues.
|
||||
// WIP implementation
|
||||
const camPos = this._renderingCamera.position
|
||||
|
@ -537,10 +543,10 @@ export class CameraController extends Extension implements ICameraProvider {
|
|||
default: {
|
||||
let box
|
||||
if (this.viewer.getRenderer().allObjects.children.length === 0)
|
||||
box = new THREE.Box3(new Vector3(-1, -1, -1), new Vector3(1, 1, 1))
|
||||
else box = new THREE.Box3().setFromObject(this.viewer.getRenderer().allObjects)
|
||||
box = new Box3(new Vector3(-1, -1, -1), new Vector3(1, 1, 1))
|
||||
else box = new Box3().setFromObject(this.viewer.getRenderer().allObjects)
|
||||
if (box.max.x === Infinity || box.max.x === -Infinity) {
|
||||
box = new THREE.Box3(new Vector3(-1, -1, -1), new Vector3(1, 1, 1))
|
||||
box = new Box3(new Vector3(-1, -1, -1), new Vector3(1, 1, 1))
|
||||
}
|
||||
this._controls.setPosition(box.max.x, box.max.y, box.max.z, transition)
|
||||
this.zoomExtents()
|
||||
|
|
|
@ -278,6 +278,10 @@ export class TopLevelAccelerationStructure {
|
|||
return ret
|
||||
}
|
||||
|
||||
public closestPointToPoint(point: Vector3) {
|
||||
return this.accelerationStructure.bvh.closestPointToPoint(point)
|
||||
}
|
||||
|
||||
public getBoundingBox(target: Box3): Box3 {
|
||||
this.accelerationStructure.getBoundingBox(target)
|
||||
return target
|
||||
|
|
|
@ -44,7 +44,7 @@ spec:
|
|||
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
path: /api/status
|
||||
port: www
|
||||
failureThreshold: 3
|
||||
initialDelaySeconds: 10
|
||||
|
@ -53,7 +53,7 @@ spec:
|
|||
timeoutSeconds: 5
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
path: /api/status
|
||||
port: www
|
||||
failureThreshold: 1
|
||||
initialDelaySeconds: 5
|
||||
|
|
|
@ -20,7 +20,7 @@ spec:
|
|||
spec:
|
||||
containers:
|
||||
- name: main
|
||||
image: speckle/speckle-server:{{ .Values.docker_image_tag }}
|
||||
image: {{ default (printf "speckle/speckle-server:%s" .Values.docker_image_tag) .Values.objects.image }}
|
||||
imagePullPolicy: {{ .Values.imagePullPolicy }}
|
||||
args: #overwrites the Dockerfile CMD statement
|
||||
{{- if .Values.objects.inspect.enabled }}
|
||||
|
@ -285,26 +285,9 @@ spec:
|
|||
value: "{{ .Values.server.email.from }}"
|
||||
{{- end }}
|
||||
|
||||
# *** Newsletter ***
|
||||
{{- if (default false (.Values.server.mailchimp).enabled) }}
|
||||
# *** Newsletter will not be generated by objects pods ***
|
||||
- name: MAILCHIMP_ENABLED
|
||||
value: "true"
|
||||
- name: MAILCHIMP_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default .Values.secretName ((.Values.server.mailchimp).apikey).secretName }}
|
||||
key: {{ default "mailchimp_apikey" ((.Values.server.mailchimp).apikey).secretKey }}
|
||||
- name: MAILCHIMP_SERVER_PREFIX
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default .Values.secretName ((.Values.server.mailchimp).serverprefix).secretName }}
|
||||
key: {{ default "mailchimp_serverprefix" ((.Values.server.mailchimp).serverprefix).secretKey }}
|
||||
- name: MAILCHIMP_LIST_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default .Values.secretName ((.Values.server.mailchimp).listid).secretName }}
|
||||
key: {{ default "mailchimp_listid" ((.Values.server.mailchimp).listid).secretKey }}
|
||||
{{- end }}
|
||||
value: "false"
|
||||
|
||||
# *** Tracking / Tracing ***
|
||||
- name: SENTRY_DSN
|
||||
|
|
|
@ -26,4 +26,17 @@ spec:
|
|||
name: speckle-frontend
|
||||
port:
|
||||
name: www
|
||||
{{- end }}
|
||||
- pathType: Exact
|
||||
path: "/api/status"
|
||||
backend:
|
||||
service:
|
||||
{{- if .Values.frontend_2.enabled }}
|
||||
name: speckle-frontend-2
|
||||
port:
|
||||
name: web
|
||||
{{- else }}
|
||||
name: speckle-frontend
|
||||
port:
|
||||
name: www
|
||||
{{- end }}
|
||||
|
|
|
@ -302,24 +302,28 @@ spec:
|
|||
{{- end }}
|
||||
|
||||
# *** Newsletter ***
|
||||
{{- if (default false (.Values.server.mailchimp).enabled) }}
|
||||
{{- if .Values.server.mailchimp.enabled }}
|
||||
- name: MAILCHIMP_ENABLED
|
||||
value: "true"
|
||||
- name: MAILCHIMP_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default .Values.secretName ((.Values.server.mailchimp).apikey).secretName }}
|
||||
key: {{ default "mailchimp_apikey" ((.Values.server.mailchimp).apikey).secretKey }}
|
||||
name: {{ default .Values.secretName .Values.server.mailchimp.apikey.secretName }}
|
||||
key: {{ .Values.server.mailchimp.apikey.secretKey }}
|
||||
- name: MAILCHIMP_SERVER_PREFIX
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default .Values.secretName ((.Values.server.mailchimp).serverprefix).secretName }}
|
||||
key: {{ default "mailchimp_serverprefix" ((.Values.server.mailchimp).serverprefix).secretKey }}
|
||||
- name: MAILCHIMP_LIST_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default .Values.secretName ((.Values.server.mailchimp).listid).secretName }}
|
||||
key: {{ default "mailchimp_listid" ((.Values.server.mailchimp).listid).secretKey }}
|
||||
value: "{{ .Values.server.mailchimp.serverPrefix}}"
|
||||
|
||||
- name: MAILCHIMP_NEWSLETTER_LIST_ID
|
||||
value: "{{ .Values.server.mailchimp.newsletterListId}}"
|
||||
|
||||
- name: MAILCHIMP_ONBOARDING_LIST_ID
|
||||
value: "{{ .Values.server.mailchimp.onboardingListId}}"
|
||||
|
||||
- name: MAILCHIMP_ONBOARDING_JOURNEY_ID
|
||||
value: "{{ .Values.server.mailchimp.onboardingJourneyId}}"
|
||||
|
||||
- name: MAILCHIMP_ONBOARDING_STEP_ID
|
||||
value: "{{ .Values.server.mailchimp.onboardingStepId}}"
|
||||
{{- end }}
|
||||
|
||||
# *** Tracking / Tracing ***
|
||||
|
|
|
@ -983,6 +983,56 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"mailchimp": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Mailchimp integration feature flag",
|
||||
"default": false
|
||||
},
|
||||
"apikey": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"secretName": {
|
||||
"type": "string",
|
||||
"description": "The name of the Kubernetes Secret containing the Mailchimp API key.",
|
||||
"default": ""
|
||||
},
|
||||
"secretKey": {
|
||||
"type": "string",
|
||||
"description": "The key within the Kubernetes Secret holding the Mailchimp API key as its value.",
|
||||
"default": "mailchimp_apikey"
|
||||
}
|
||||
}
|
||||
},
|
||||
"serverPrefix": {
|
||||
"type": "string",
|
||||
"description": "Mailchimp api server prefix",
|
||||
"default": ""
|
||||
},
|
||||
"newsletterListId": {
|
||||
"type": "string",
|
||||
"description": "Audience id for the newsletter list",
|
||||
"default": ""
|
||||
},
|
||||
"onboardingListId": {
|
||||
"type": "string",
|
||||
"description": "Audience id for the onboarding list",
|
||||
"default": ""
|
||||
},
|
||||
"onboardingJourneyId": {
|
||||
"type": "string",
|
||||
"description": "Id of the onboarding journey",
|
||||
"default": ""
|
||||
},
|
||||
"onboardingStepId": {
|
||||
"type": "string",
|
||||
"description": "Id of the onboarding journey step we trigger",
|
||||
"default": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"migration": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
@ -1095,6 +1145,11 @@
|
|||
"description": "The number of instances of the Server pod to be deployed within the cluster.",
|
||||
"default": 1
|
||||
},
|
||||
"image": {
|
||||
"type": "string",
|
||||
"description": "The Docker image to be used for the Speckle Objects component. If blank, defaults to speckle/speckle-server:{{ .Values.docker_image_tag }}. If provided, this value should be the full path including tag. The docker_image_tag value will be ignored.",
|
||||
"default": ""
|
||||
},
|
||||
"logLevel": {
|
||||
"type": "string",
|
||||
"description": "The minimum level of logs which will be output. Suitable values are trace, debug, info, warn, error, fatal, or silent",
|
||||
|
|
|
@ -647,6 +647,25 @@ server:
|
|||
## @param server.fileUploads.enabled If enabled, file uploads on the server will be flagged as enabled
|
||||
enabled: true
|
||||
|
||||
mailchimp:
|
||||
## @param server.mailchimp.enabled Mailchimp integration feature flag
|
||||
enabled: false
|
||||
apikey:
|
||||
## @param server.mailchimp.apikey.secretName The name of the Kubernetes Secret containing the Mailchimp API key.
|
||||
secretName: '' # defaults to .secretName
|
||||
## @param server.mailchimp.apikey.secretKey The key within the Kubernetes Secret holding the Mailchimp API key as its value.
|
||||
secretKey: 'mailchimp_apikey'
|
||||
## @param server.mailchimp.serverPrefix Mailchimp api server prefix
|
||||
serverPrefix: ''
|
||||
## @param server.mailchimp.newsletterListId Audience id for the newsletter list
|
||||
newsletterListId: ''
|
||||
## @param server.mailchimp.onboardingListId Audience id for the onboarding list
|
||||
onboardingListId: ''
|
||||
## @param server.mailchimp.onboardingJourneyId Id of the onboarding journey
|
||||
onboardingJourneyId: ''
|
||||
## @param server.mailchimp.onboardingStepId Id of the onboarding journey step we trigger
|
||||
onboardingStepId: ''
|
||||
|
||||
migration:
|
||||
## @param server.migration.movedFrom Indicate the URL where the server moved from
|
||||
movedFrom: ''
|
||||
|
@ -676,6 +695,7 @@ server:
|
|||
## @param server.sentry_dns (Optional) The Data Source Name that was provided by Sentry.io
|
||||
## Sentry.io allows events within Speckle to be monitored
|
||||
##
|
||||
|
||||
sentry_dns: ''
|
||||
## @param server.disable_tracking If set to true, will prevent tracking metrics from being collected
|
||||
## Setting this value to false requires `sentry_dns` to be set
|
||||
|
@ -717,6 +737,9 @@ objects:
|
|||
## @param objects.replicas The number of instances of the Server pod to be deployed within the cluster.
|
||||
##
|
||||
replicas: 1
|
||||
## @param objects.image The Docker image to be used for the Speckle Objects component. If blank, defaults to speckle/speckle-server:{{ .Values.docker_image_tag }}. If provided, this value should be the full path including tag. The docker_image_tag value will be ignored.
|
||||
##
|
||||
image: ''
|
||||
## @param objects.logLevel The minimum level of logs which will be output. Suitable values are trace, debug, info, warn, error, fatal, or silent
|
||||
##
|
||||
logLevel: 'info'
|
||||
|
|
75
yarn.lock
75
yarn.lock
|
@ -13902,7 +13902,7 @@ __metadata:
|
|||
vee-validate: ^4.7.0
|
||||
vue-advanced-cropper: ^2.8.8
|
||||
vue-tippy: ^6.0.0
|
||||
vue-tsc: 1.8.22
|
||||
vue-tsc: 1.8.27
|
||||
wait-on: ^6.0.1
|
||||
ws: ^8.9.0
|
||||
languageName: unknown
|
||||
|
@ -18241,6 +18241,15 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@volar/language-core@npm:1.11.1, @volar/language-core@npm:~1.11.1":
|
||||
version: 1.11.1
|
||||
resolution: "@volar/language-core@npm:1.11.1"
|
||||
dependencies:
|
||||
"@volar/source-map": 1.11.1
|
||||
checksum: 7f98fbeb96ff1093dbaa47e790575a98d1fd2103d9bb1598ec7b0ae787fc6af2ffcea12fdea0f0a4e057f38f6ee3a60bd54f2af3985159319021771f79df9451
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@volar/language-core@npm:1.4.0-alpha.4":
|
||||
version: 1.4.0-alpha.4
|
||||
resolution: "@volar/language-core@npm:1.4.0-alpha.4"
|
||||
|
@ -18268,6 +18277,15 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@volar/source-map@npm:1.11.1, @volar/source-map@npm:~1.11.1":
|
||||
version: 1.11.1
|
||||
resolution: "@volar/source-map@npm:1.11.1"
|
||||
dependencies:
|
||||
muggle-string: ^0.3.1
|
||||
checksum: 1ec1034432ee51a0afe187ba9158292dd607a90d01120ee8a36cf27f5d464da5282c8fe7b0de82f52f45474a840c63eba666254c5c21ca5466dc02d0c95cd147
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@volar/source-map@npm:1.4.0-alpha.4":
|
||||
version: 1.4.0-alpha.4
|
||||
resolution: "@volar/source-map@npm:1.4.0-alpha.4"
|
||||
|
@ -18305,6 +18323,16 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@volar/typescript@npm:~1.11.1":
|
||||
version: 1.11.1
|
||||
resolution: "@volar/typescript@npm:1.11.1"
|
||||
dependencies:
|
||||
"@volar/language-core": 1.11.1
|
||||
path-browserify: ^1.0.1
|
||||
checksum: 0db2fc32db133e493f05dbafd248560a6d4e5b071a0d80422c67b1875bd36980c113915d876a83e855d55c2880b2e7b9f04f803ce3504a4d6fafcc0b801c621b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@volar/vue-language-core@npm:1.3.4":
|
||||
version: 1.3.4
|
||||
resolution: "@volar/vue-language-core@npm:1.3.4"
|
||||
|
@ -19105,6 +19133,28 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vue/language-core@npm:1.8.27":
|
||||
version: 1.8.27
|
||||
resolution: "@vue/language-core@npm:1.8.27"
|
||||
dependencies:
|
||||
"@volar/language-core": ~1.11.1
|
||||
"@volar/source-map": ~1.11.1
|
||||
"@vue/compiler-dom": ^3.3.0
|
||||
"@vue/shared": ^3.3.0
|
||||
computeds: ^0.0.1
|
||||
minimatch: ^9.0.3
|
||||
muggle-string: ^0.3.1
|
||||
path-browserify: ^1.0.1
|
||||
vue-template-compiler: ^2.7.14
|
||||
peerDependencies:
|
||||
typescript: "*"
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
checksum: 8660c05319be8dc5daacc2cd929171434215d29f3ad5bfbe0038d1967db05b8bf640286b25f338845cc1e3890b4aaa239ac9e8cb832cc8a50a5bbdff31b2edd1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vue/language-core@npm:1.8.8":
|
||||
version: 1.8.8
|
||||
resolution: "@vue/language-core@npm:1.8.8"
|
||||
|
@ -41540,8 +41590,8 @@ __metadata:
|
|||
linkType: hard
|
||||
|
||||
"sanitize-html@npm:^2.7.1":
|
||||
version: 2.8.1
|
||||
resolution: "sanitize-html@npm:2.8.1"
|
||||
version: 2.12.1
|
||||
resolution: "sanitize-html@npm:2.12.1"
|
||||
dependencies:
|
||||
deepmerge: ^4.2.2
|
||||
escape-string-regexp: ^4.0.0
|
||||
|
@ -41549,7 +41599,7 @@ __metadata:
|
|||
is-plain-object: ^5.0.0
|
||||
parse-srcset: ^1.0.2
|
||||
postcss: ^8.3.11
|
||||
checksum: 0d35503b261800b736a02648e8b9b2a5206cbc621248cf8dd86d5b9bdd470d0146d74704222d287bd7359a599e8a186cc5b015401237b0244352f18f37465daa
|
||||
checksum: fb96ea7170d51b5af2607f5cfd84464c78fc6f47e339407f55783e781c6a0288a8d40bbf97ea6a8758924ba9b2d33dcc4846bb94caacacd90d7f2de10ed8541a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
@ -46632,7 +46682,22 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"vue-tsc@npm:1.8.22, vue-tsc@npm:^1.8.20, vue-tsc@npm:^1.8.22":
|
||||
"vue-tsc@npm:1.8.27":
|
||||
version: 1.8.27
|
||||
resolution: "vue-tsc@npm:1.8.27"
|
||||
dependencies:
|
||||
"@volar/typescript": ~1.11.1
|
||||
"@vue/language-core": 1.8.27
|
||||
semver: ^7.5.4
|
||||
peerDependencies:
|
||||
typescript: "*"
|
||||
bin:
|
||||
vue-tsc: bin/vue-tsc.js
|
||||
checksum: 98c2986df01000a3245b5f08b9db35d0ead4f46fb12f4fe771257b4aa61aa4c26dda359aaa0e6c484a6240563d5188aaa6ed312dd37cc2315922d5e079260001
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"vue-tsc@npm:^1.8.20, vue-tsc@npm:^1.8.22":
|
||||
version: 1.8.22
|
||||
resolution: "vue-tsc@npm:1.8.22"
|
||||
dependencies:
|
||||
|
|
Загрузка…
Ссылка в новой задаче