Merge branch 'main' into iain/circleci-test-viewer

This commit is contained in:
Iain Sproat 2024-04-02 09:57:03 +01:00
Родитель 2cabf8c680 34214edcde
Коммит 00cf704509
Не найден ключ, соответствующий данной подписи
62 изменённых файлов: 1251 добавлений и 494 удалений

Просмотреть файл

@ -28,10 +28,6 @@ workflows:
- test-viewer:
filters: *filters-allow-all
## TODO: Re-enable when dui3 gets further into development
# - test-dui-3:
# filters: *filters-allow-all
- test-ui-components:
filters: *filters-allow-all
@ -553,36 +549,6 @@ jobs:
command: yarn test
working_directory: 'packages/viewer'
test-dui-3:
docker: &docker-node-image
- image: cimg/node:18.19.0
resource_class: medium+
steps:
- checkout
- restore_cache:
name: Restore Yarn Package Cache
keys:
- yarn-packages-server-{{ checksum "yarn.lock" }}
- run:
name: Install Dependencies
command: yarn
- run:
name: Install Dependencies v2 (.node files missing bug)
command: yarn
- save_cache:
name: Save Yarn Package Cache
key: yarn-packages-server-{{ checksum "yarn.lock" }}
paths:
- .yarn/cache
- .yarn/unplugged
- run:
name: Lint everything
command: yarn lint
working_directory: 'packages/dui3'
test-ui-components:
docker: *docker-node-browsers-image
resource_class: xlarge
@ -637,7 +603,8 @@ jobs:
ui-components-chromatic:
resource_class: medium+
docker: *docker-node-image
docker: &docker-node-image
- image: cimg/node:18.19.0
steps:
- checkout
- restore_cache:

Просмотреть файл

@ -0,0 +1,22 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
{
"name": "Node.js & TypeScript",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-18-bullseye",
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "yarn && yarn build:public"
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

Просмотреть файл

@ -1,6 +1,6 @@
ARG NODE_ENV=production
FROM node:18-bookworm-slim@sha256:246bf34b0c7cf8d9ff7cbe0c1ff44b178051f06c432c8e7df1645f1bd20b0352 as build-stage
FROM node:18-bookworm-slim@sha256:a7423cbf419ccea2723be0af141b663b643c30bea56d19bf2e8fe171e904fde9 as build-stage
ARG NODE_ENV
ENV NODE_ENV=${NODE_ENV}
@ -47,7 +47,7 @@ RUN apt-get update && \
COPY packages/fileimport-service/requirements.txt /speckle-server/
RUN /venv/bin/pip install --disable-pip-version-check --no-cache-dir --requirement /speckle-server/requirements.txt
FROM node:18-bookworm-slim@sha256:246bf34b0c7cf8d9ff7cbe0c1ff44b178051f06c432c8e7df1645f1bd20b0352 as dependency-stage
FROM node:18-bookworm-slim@sha256:a7423cbf419ccea2723be0af141b663b643c30bea56d19bf2e8fe171e904fde9 as dependency-stage
# installing just the production dependencies
# separate stage to avoid including development dependencies
ARG NODE_ENV
@ -67,7 +67,7 @@ RUN yarn workspaces focus --production
FROM gcr.io/distroless/python3-debian12:nonroot@sha256:95f5fa82f7cc7da0e133a8a895900447337ef0830870ad8387eb4c696be17057 as python-image
FROM gcr.io/distroless/nodejs18-debian12:nonroot@sha256:00c21305bf7dacba81dbe9ae503ddfe34703a986a61246dacb198e425311cd84 as distributable-stage
FROM gcr.io/distroless/nodejs18-debian12:nonroot@sha256:7b32127ea43d86b7a5b8e0d86dfe59146f25517ca15e6223046b5a72de36119b as distributable-stage
ARG NODE_ENV
ENV NODE_ENV=${NODE_ENV}

Просмотреть файл

@ -34,4 +34,10 @@ NUXT_PUBLIC_DATADOG_SERVICE=
NUXT_PUBLIC_DATADOG_ENV=
# Debug core web vitals in the console
NUXT_PUBLIC_DEBUG_CORE_WEB_VITALS=false
NUXT_PUBLIC_DEBUG_CORE_WEB_VITALS=false
# Survicate
NUXT_PUBLIC_SURVICATE_WORKSPACE_KEY=
# Enable direct preview image loading - way quicker, but requres server & frontend to be on the same origin
NUXT_PUBLIC_ENABLE_DIRECT_PREVIEWS=true

Просмотреть файл

@ -1,4 +1,4 @@
FROM node:18-bookworm-slim@sha256:246bf34b0c7cf8d9ff7cbe0c1ff44b178051f06c432c8e7df1645f1bd20b0352 as build-stage
FROM node:18-bookworm-slim@sha256:a7423cbf419ccea2723be0af141b663b643c30bea56d19bf2e8fe171e904fde9 as build-stage
ARG NODE_ENV=production
ARG SPECKLE_SERVER_VERSION=custom
@ -34,7 +34,7 @@ ENV TINI_VERSION v0.19.0
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
RUN chmod +x /tini
FROM gcr.io/distroless/nodejs18-debian12:nonroot@sha256:00c21305bf7dacba81dbe9ae503ddfe34703a986a61246dacb198e425311cd84 as production-stage
FROM gcr.io/distroless/nodejs18-debian12:nonroot@sha256:7b32127ea43d86b7a5b8e0d86dfe59146f25517ca15e6223046b5a72de36119b as production-stage
ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}

Двоичные данные
packages/frontend-2/assets/images/preview_placeholder.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 3.8 KiB

Просмотреть файл

@ -0,0 +1,240 @@
<script lang="ts">
import {
waitIntervalUntil,
type Nullable,
timeoutAt,
WaitIntervalUntilCanceledError
} from '@speckle/shared'
import { until } from '@vueuse/core'
import type { CSSProperties } from 'vue'
/**
* A component that transitions between two sets of contents with a crossfade effect. You only
* have to use a single slot - the component will not update any contents inside of the slot
* until you call the `triggerTransition` method, and then the update will happen with a smooth
* transition.
*/
export default defineComponent({
props: {
duration: {
type: Number,
default: 1000
},
debug: {
type: Boolean,
default: false
}
},
setup(props, { slots, expose }) {
const transitioning = ref(false)
const newWrapperRef = ref(null as Nullable<HTMLDivElement>)
const oldWrapperRef = ref(null as Nullable<HTMLDivElement>)
const newContents = shallowRef(slots.default?.())
const oldContents: typeof newContents = shallowRef(undefined)
const newOpacity = ref(1)
const oldOpacity = ref(1)
const newTransitionEnabled = ref(false)
const oldTransitionEnabled = ref(false)
const waitForDomUpdate = async (params: {
ref: Ref<Nullable<HTMLElement>>
expectStyle?: Partial<CSSProperties>
expectClasses?: string[]
shouldNotHaveClasses?: string[]
}) => {
const { ref, expectClasses, expectStyle, shouldNotHaveClasses } = params
let attempt = 0
const promise = waitIntervalUntil(100, () => {
if (attempt > 20) {
promise.cancel()
}
attempt++
const el = ref.value
if (!el) return false
if (expectStyle) {
for (const [key, value] of Object.entries(expectStyle)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
if (el.style[key as any] !== value) {
return false
}
}
}
if (expectClasses) {
for (const className of expectClasses) {
if (!el.classList.contains(className)) {
return false
}
}
}
if (shouldNotHaveClasses) {
for (const className of shouldNotHaveClasses) {
if (el.classList.contains(className)) {
return false
}
}
}
return true
})
try {
await promise
} catch (e) {
if (e instanceof WaitIntervalUntilCanceledError) {
if (props.debug) {
throw e
}
} else {
throw e
}
}
}
/**
* Cause default slot to update with an opacity transition
*/
const updateContents = async () => {
// Stage 1: Just move new -> old w/o any transitions (visually should look the same)
oldContents.value = newContents.value
newContents.value = slots.default?.()
newTransitionEnabled.value = false
newOpacity.value = 0
oldTransitionEnabled.value = false
oldOpacity.value = 1
await Promise.all([
waitForDomUpdate({
ref: newWrapperRef,
expectStyle: { opacity: '0' },
shouldNotHaveClasses: ['transition-opacity']
}),
waitForDomUpdate({
ref: oldWrapperRef,
expectStyle: { opacity: '1' },
shouldNotHaveClasses: ['transition-opacity']
})
])
// Stage 2: Transition both
oldTransitionEnabled.value = newTransitionEnabled.value = true
await Promise.all([
waitForDomUpdate({
ref: newWrapperRef,
expectClasses: ['transition-opacity']
}),
waitForDomUpdate({
ref: oldWrapperRef,
expectClasses: ['transition-opacity']
})
])
newOpacity.value = 1
oldOpacity.value = 0
await Promise.all([
waitForDomUpdate({
ref: newWrapperRef,
expectStyle: { opacity: '1' }
}),
waitForDomUpdate({
ref: oldWrapperRef,
expectStyle: { opacity: '0' }
})
])
}
const triggerTransition = async () => {
if (!transitioning.value) {
transitioning.value = true
await updateContents()
return
}
await Promise.race([
until(transitioning).toBe(false),
timeoutAt(props.duration + 1000)
])
await updateContents()
}
const buildItemProps = (params: {
zIndex: number
withTransitions: boolean
opacity: number
}) => {
const { zIndex, withTransitions, opacity } = params
const classParts = ['absolute inset-0']
const style: CSSProperties = {
zIndex,
opacity,
...(withTransitions
? {
transitionDuration: `${props.duration}ms`
}
: {})
}
if (withTransitions) {
classParts.push('transition-opacity')
}
return {
class: classParts.join(' '),
style
}
}
expose({
triggerTransition
})
return () => {
return h('div', { class: 'relative' }, [
h(
'div',
{
...buildItemProps({
zIndex: 2,
withTransitions: newTransitionEnabled.value,
opacity: newOpacity.value
}),
ref: newWrapperRef
},
[newContents.value]
),
...(oldContents.value
? [
h(
'div',
{
...buildItemProps({
zIndex: 1,
withTransitions: oldTransitionEnabled.value,
opacity: oldOpacity.value
}),
ref: oldWrapperRef,
onTransitionend: () => {
// Stage 3: Clean up
oldContents.value = undefined
transitioning.value = false
}
},
[oldContents.value]
)
]
: [])
])
}
}
})
</script>

Просмотреть файл

@ -6,68 +6,82 @@
@mouseenter="hovered = true"
@mouseleave="hovered = false"
>
<ClientOnly>
<Transition
enter-from-class="opacity-0"
enter-active-class="transition duration-300"
leave-to-class="opacity-0"
leave-active-class="transition duration-300"
>
<div
v-show="
(!hovered && finalPreviewUrl) || isLoadingPanorama || !props.panoramaOnHover
"
class="w-full h-full bg-contain bg-no-repeat bg-center transition"
:style="{
backgroundImage: `url('${finalPreviewUrl}')`
}"
/>
</Transition>
<Transition
enter-from-class="opacity-0"
enter-active-class="transition duration-300"
leave-to-class="opacity-0"
leave-active-class="transition duration-300"
>
<!-- eslint-disable-next-line vuejs-accessibility/mouse-events-have-key-events -->
<div
v-show="hovered && panoramaPreviewUrl && props.panoramaOnHover"
ref="panorama"
:style="{
backgroundImage: `url('${panoramaPreviewUrl}')`,
backgroundSize: 'cover',
backgroundPosition: `${positionMagic}px 0`,
position: 'absolute',
top: '0',
width: '100%',
height: '100%'
}"
@mousemove="(e: MouseEvent) => calculatePanoramaStyle(e)"
@touchmove="(e:TouchEvent) =>
<Transition
enter-from-class="opacity-0"
enter-active-class="transition duration-300"
leave-to-class="opacity-0"
leave-active-class="transition duration-300"
>
<div v-if="shouldShowMainPreview" class="relative w-full h-full">
<CommonTransitioningContents
ref="finalPreviewTransitioner"
class="w-full h-full"
>
<div
v-if="!hasDoneFirstLoad || !finalPreviewUrl?.length"
:class="[mainPreviewClasses, 'flex items-center justify-center']"
>
<div class="lds-ripple">
<div></div>
<div></div>
</div>
</div>
<div
v-else
:class="mainPreviewClasses"
:style="{
backgroundImage: `url('${finalPreviewUrl}')`
}"
/>
</CommonTransitioningContents>
</div>
</Transition>
<Transition
enter-from-class="opacity-0"
enter-active-class="transition duration-300"
leave-to-class="opacity-0"
leave-active-class="transition duration-300"
>
<!-- eslint-disable-next-line vuejs-accessibility/mouse-events-have-key-events -->
<div
v-show="shouldShowPanoramicPreview"
ref="panorama"
:style="{
backgroundImage: panoramaPreviewUrl
? `url('${panoramaPreviewUrl}')`
: undefined,
backgroundSize: 'cover',
backgroundPosition: `${positionMagic}px 0`,
position: 'absolute',
top: '0',
width: '100%',
height: '100%'
}"
@mousemove="(e: MouseEvent) => calculatePanoramaStyle(e)"
@touchmove="(e:TouchEvent) =>
calculatePanoramaStyle({
target: e.target,
clientX: e.touches[0].clientX,
clientY: e.touches[0].clientY
} as MouseEvent)"
/>
</Transition>
<Transition
enter-from-class="opacity-0"
leave-to-class="opacity-0"
enter-active-class="transition duration-300"
leave-active-class="transition duration-300"
>
<CommonLoadingBar
:loading="isLoadingPanorama && hovered"
class="absolute bottom-0 w-full"
/>
</Transition>
</ClientOnly>
/>
</Transition>
<Transition
enter-from-class="opacity-0"
leave-to-class="opacity-0"
enter-active-class="transition duration-300"
leave-active-class="transition duration-300"
>
<CommonLoadingBar
:loading="isLoadingPanorama && hovered"
class="absolute bottom-0 w-full"
/>
</Transition>
</div>
</template>
<script setup lang="ts">
import type { Nullable } from '@speckle/shared'
import { useResizeObserver } from '@vueuse/core'
import { type Nullable } from '@speckle/shared'
import { useElementVisibility, useResizeObserver } from '@vueuse/core'
import { usePreviewImageBlob } from '~~/lib/projects/composables/previewImage'
const props = withDefaults(
@ -80,23 +94,27 @@ const props = withDefaults(
}
)
const parent = ref(null as Nullable<HTMLDivElement>)
const finalPreviewTransitioner = ref(
null as Nullable<{ triggerTransition: () => Promise<void> }>
)
const isInViewport = useElementVisibility(parent)
const basePreviewUrl = computed(() => props.previewUrl)
const {
previewUrl: finalPreviewUrl,
panoramaPreviewUrl,
shouldLoadPanorama,
isLoadingPanorama
} = usePreviewImageBlob(basePreviewUrl)
isLoadingPanorama,
hasDoneFirstLoad
} = usePreviewImageBlob(basePreviewUrl, { enabled: isInViewport })
const hovered = ref(false)
watch(hovered, (newVal) => {
if (newVal && !panoramaPreviewUrl.value && props.panoramaOnHover)
shouldLoadPanorama.value = true
})
const panorama = ref(null as Nullable<HTMLDivElement>)
const parent = ref(null as Nullable<HTMLDivElement>)
const mainPreviewClasses = computed(
() => 'w-full h-full bg-contain bg-no-repeat bg-center'
)
let parentWidth = 1
let parentHeight = 1
@ -104,7 +122,6 @@ const setParentDimensions = () => {
parentWidth = parent.value?.getBoundingClientRect().width as number
parentHeight = parent.value?.getBoundingClientRect().height as number
}
onMounted(() => setParentDimensions())
if (process.client) useResizeObserver(document.body, () => setParentDimensions())
const positionMagic = ref(0)
@ -122,4 +139,85 @@ const calculatePanoramaStyle = (e: MouseEvent) => {
const widthDiff = (parentWidth - actualWidth) * 0.5
positionMagic.value = -(actualWidth * (2 * index + 1) - widthDiff)
}
const shouldShowMainPreview = computed(
() =>
(!hovered.value && finalPreviewUrl.value) ||
isLoadingPanorama.value ||
!props.panoramaOnHover
)
const shouldShowPanoramicPreview = computed(
() => hovered.value && panoramaPreviewUrl.value && props.panoramaOnHover
)
onMounted(() => setParentDimensions())
watch(hovered, (newVal) => {
if (newVal && !panoramaPreviewUrl.value && props.panoramaOnHover)
shouldLoadPanorama.value = true
})
if (process.client) {
// Trigger transitions when preview image changes
watch(finalPreviewUrl, (newVal, oldVal) => {
if (newVal === oldVal) return
finalPreviewTransitioner.value?.triggerTransition()
})
}
</script>
<style>
/** https://loading.io/css/ */
.lds-ripple {
display: inline-block;
position: relative;
width: 80px;
height: 80px;
}
.lds-ripple div {
position: absolute;
border: 4px solid #cef;
opacity: 1;
border-radius: 50%;
animation: lds-ripple 1s cubic-bezier(0, 0.2, 0.8, 1) infinite;
}
.lds-ripple div:nth-child(2) {
animation-delay: -0.5s;
}
@keyframes lds-ripple {
0% {
top: 36px;
left: 36px;
width: 0;
height: 0;
opacity: 0;
}
4.9% {
top: 36px;
left: 36px;
width: 0;
height: 0;
opacity: 0;
}
5% {
top: 36px;
left: 36px;
width: 0;
height: 0;
opacity: 1;
}
100% {
top: 0;
left: 0;
width: 72px;
height: 72px;
opacity: 0;
}
}
</style>

Просмотреть файл

@ -58,15 +58,12 @@
</NuxtLink>
<div class="hidden sm:flex grow" />
<div class="flex items-center">
<div
<ProjectPageModelsCardUpdatedTime
:updated-at="updatedAt"
:class="`text-xs w-full text-foreground-2 sm:mr-1 truncate transition ${
hovered ? 'sm:w-auto' : 'sm:w-0'
}`"
>
updated
<b>{{ updatedAt }}</b>
</div>
/>
<FormButton
v-if="finalShowVersions"
v-tippy="'View Version Gallery'"

Просмотреть файл

@ -1,31 +0,0 @@
<template>
<div class="w-full h-full">
<ClientOnly>
<div
v-if="previewUrl"
class="w-full h-full bg-contain bg-no-repeat bg-center"
:style="{
backgroundImage: `url('${previewUrl}')`
}"
/>
</ClientOnly>
</div>
</template>
<script setup lang="ts">
import { graphql } from '~~/lib/common/generated/gql'
import type { ModelPreviewFragment } from '~~/lib/common/generated/gql/graphql'
import { usePreviewImageBlob } from '~~/lib/projects/composables/previewImage'
graphql(`
fragment ModelPreview on Model {
previewUrl
}
`)
const props = defineProps<{
model: ModelPreviewFragment
}>()
const basePreviewUrl = computed(() => props.model.previewUrl)
const { previewUrl } = usePreviewImageBlob(basePreviewUrl)
</script>

Просмотреть файл

@ -0,0 +1,16 @@
<template>
<div>
updated
<b>{{ updatedAt }}</b>
</div>
</template>
<script setup lang="ts">
/**
* Separate component so that hydration mismatches only cause this component to re-render, not the entire model card.
* Hydration mismatches can happen here when the server resolves the update as X minutes ago, but the client resolves it as X minutes and 1 second ago.
*/
defineProps<{
updatedAt: string
}>()
</script>

Просмотреть файл

@ -11,10 +11,6 @@
v-if="projectsPanelResult?.activeUser?.projectInvites?.length"
:invites="projectsPanelResult?.activeUser"
/>
<ProjectsFeedbackRequestBanner
v-if="showFeedbackRequest"
@feedback-dismissed-or-opened="onDismissOrOpenFeedback"
/>
<ProjectsNewSpeckleBanner
v-if="showNewSpeckleBanner"
@dismissed="onDismissNewSpeckleBanner"
@ -171,11 +167,6 @@ const updateSearchImmediately = () => {
debouncedSearch.value = search.value.trim()
}
const onDismissOrOpenFeedback = () => {
onboardingOrFeedbackDate.value = undefined
hasDismissedOrOpenedFeedback.value = true
}
const onDismissNewSpeckleBanner = () => {
hasDismissedNewSpeckleBanner.value = true
}
@ -278,14 +269,6 @@ function getFutureDateByDays(daysToAdd: number) {
return dayjs().add(daysToAdd, 'day').toDate()
}
const onboardingOrFeedbackDate = useSynchronizedCookie<string | undefined>(
`onboardingOrFeedbackDate`,
{
default: () => undefined,
expires: getFutureDateByDays(180)
}
)
const hasCompletedChecklistV1 = useSynchronizedCookie<boolean>(
`hasCompletedChecklistV1`,
{
@ -307,11 +290,6 @@ const hasDismissedChecklistForever = useSynchronizedCookie<boolean | undefined>(
}
)
const hasDismissedOrOpenedFeedback = useSynchronizedCookie<boolean | undefined>(
`hasDismissedOrOpenedFeedback`,
{ default: () => false, expires: getFutureDateByDays(180) }
)
const hasDismissedChecklistTimeAgo = computed(() => {
return (
new Date().getTime() -
@ -336,27 +314,6 @@ const showChecklist = computed(() => {
return false
})
const showFeedbackRequest = computed(() => {
let storedDateString = onboardingOrFeedbackDate.value
const currentDate = dayjs()
if (!storedDateString) {
const formattedDate = currentDate.format('YYYY-MM-DD')
onboardingOrFeedbackDate.value = formattedDate
storedDateString = formattedDate
}
if (showNewSpeckleBanner.value) return false
if (hasDismissedOrOpenedFeedback.value) return false
if (showChecklist.value) return false
if (projectsPanelResult?.value?.activeUser?.projectInvites.length) return false
const firstVisitDate = dayjs(storedDateString)
const daysDifference = currentDate.diff(firstVisitDate, 'day')
return daysDifference > 14
})
const showNewSpeckleBanner = computed(() => {
if (hasDismissedNewSpeckleBanner.value) return false
if (projectsPanelResult?.value?.activeUser?.projectInvites.length) return false

Просмотреть файл

@ -1,46 +0,0 @@
<template>
<div>
<div
class="flex flex-col space-y-4 sm:space-y-0 sm:space-x-2 sm:items-center sm:flex-row px-4 py-5 sm:py-2 transition hover:bg-primary-muted"
>
<div class="flex space-x-2 items-center grow text-sm">
<div class="text-foreground">
How are we doing? Share your feedback in our survey!
</div>
</div>
<div class="flex space-x-2 w-full sm:w-auto shrink-0">
<FormButton size="sm" color="default" text @click="showSkipDialog = true">
Skip
</FormButton>
<FormButton
size="sm"
class="px-4"
:icon-right="ArrowTopRightOnSquareIcon"
to="https://forms.gle/ngyffiVaZKzEKmtr7"
external
target="_blank"
@click="emitDismissedOrOpened"
>
Open Survey
</FormButton>
</div>
</div>
<ProjectsFeedbackRequestSkipDialog
v-model:open="showSkipDialog"
@skip-dialog-dismissed="emitDismissedOrOpened"
/>
</div>
</template>
<script setup lang="ts">
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid'
const emit = defineEmits<{ 'feedback-dismissed-or-opened': [] }>()
const emitDismissedOrOpened = () => {
showSkipDialog.value = false
emit('feedback-dismissed-or-opened')
}
const showSkipDialog = ref(false)
</script>

Просмотреть файл

@ -1,145 +0,0 @@
<template>
<LayoutDialog
v-model:open="isOpen"
max-width="sm"
:buttons="dialogButtons"
prevent-close-on-click-outside
title="Feedback Option"
hide-closer
>
<CommonAnimationInstructional
:initial-position="{ top: 56, left: 71 }"
:actions="dialogActions"
:slots-config="slotsConfig"
>
<template #background>
<div class="flex flex-col">
<div
class="px-2 py-1.5 flex justify-end items-center border-b border-outline-3"
>
<UserCircleIcon class="h-6 w-6 opacity-70" />
</div>
<div class="pr-12">
<div class="flex gap-2 items-center py-4 opacity-30">
<div class="h-5 w-full bg-outline-3 rounded-r"></div>
<div class="h-5 w-full bg-outline-3 rounded"></div>
<div class="h-5 w-full bg-outline-3 rounded"></div>
</div>
<div
class="border-r border-t border-b border-outline-3 h-40 mt-1 rounded-r flex gap-2 p-2 pl-0"
>
<div class="w-5/12 rounded-r bg-outline-3 opacity-30"></div>
<div class="w-7/12 rounded bg-outline-3 opacity-30"></div>
</div>
<div
class="border-r border-t border-b border-outline-3 h-40 mt-2 rounded-r flex gap-2 p-2 pl-0"
>
<div class="w-5/12 rounded-r bg-outline-3 opacity-30"></div>
<div class="w-7/12 rounded bg-outline-3 opacity-30"></div>
</div>
</div>
</div>
</template>
<template #slot1>
<div
class="absolute z-10 top-8 right-3 h-40 w-28 border border-outline-3 bg-foundation shadow-lg rounded-lg flex flex-col gap-2 p-2"
>
<div class="h-full w-full bg-outline-3 opacity-20 rounded"></div>
<div class="h-full w-full bg-outline-3 opacity-20 rounded"></div>
<div class="h-full w-full bg-outline-3 opacity-20 rounded"></div>
<div
class="relative h-full w-full text-xs p-1 flex gap-2 items-center justify-center"
>
<div class="absolute inset-0 bg-outline-3 rounded opacity-20"></div>
<ChatBubbleLeftRightIcon class="h-4 w-4 opacity-50" />
<span class="opacity-50">Feedback</span>
</div>
<div class="h-full w-full bg-outline-3 opacity-20 rounded"></div>
</div>
</template>
<template #slot2>
<div
class="absolute z-20 top-8 right-3 h-40 w-28 border border-outline-3 bg-foundation shadow-lg rounded-lg flex flex-col gap-2 p-2"
>
<div class="h-full w-full bg-outline-3 opacity-20 rounded"></div>
<div class="h-full w-full bg-outline-3 opacity-20 rounded"></div>
<div class="h-full w-full bg-outline-3 opacity-20 rounded"></div>
<div
class="relative h-full w-full text-xs p-1 flex gap-2 items-center justify-center"
>
<div class="absolute inset-0 bg-outline-3 opacity-40 rounded"></div>
<ChatBubbleLeftRightIcon class="h-4 w-4" />
Feedback
</div>
<div class="h-full w-full bg-outline-3 opacity-20 rounded"></div>
</div>
</template>
</CommonAnimationInstructional>
<p>
Want to share your thoughts later? The Feedback option is always available in your
<strong>Profile Menu.</strong>
</p>
</LayoutDialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { CommonAnimationInstructional } from '@speckle/ui-components'
import { UserCircleIcon, ChatBubbleLeftRightIcon } from '@heroicons/vue/24/outline'
type AnimationAction = {
type: 'animation'
top: number
left: number
duration: number
}
type ClickAction = { type: 'click' }
type DelayAction = {
type: 'delay'
duration: number
}
type SlotAction = {
type: 'slot'
slot: string
}
type Action = AnimationAction | ClickAction | DelayAction | SlotAction
const isOpen = defineModel<boolean>('open', { required: true })
const emit = defineEmits<{ 'skip-dialog-dismissed': [] }>()
const slotsConfig = computed(() => [
{ name: 'slot1', visible: false },
{ name: 'slot2', visible: false }
])
const dialogButtons = computed(() => [
{
text: 'OK',
props: { color: 'default', fullWidth: true, outline: true },
onClick: () => {
emit('skip-dialog-dismissed')
}
}
])
const dialogActions = computed((): Action[] => [
{ type: 'animation', top: 4, left: 89, duration: 1500 },
{ type: 'delay', duration: 2000 },
{ type: 'click' },
{ type: 'slot', slot: 'slot1' },
{ type: 'delay', duration: 1000 },
{ type: 'animation', top: 56, left: 71, duration: 1500 },
{ type: 'delay', duration: 800 },
{ type: 'slot', slot: 'slot1' },
{ type: 'slot', slot: 'slot2' },
{ type: 'delay', duration: 1000 },
{ type: 'click' },
{ type: 'delay', duration: 3000 },
{ type: 'slot', slot: 'slot2' }
])
</script>

Просмотреть файл

@ -7,7 +7,7 @@
<!-- eslint-disable-next-line vuejs-accessibility/mouse-events-have-key-events -->
<div
v-show="step === 0"
class="border border-outline text-foreground backdrop-blur shadow-lg rounded-xl p-4 space-y-4 absolute pointer-events-auto mx-2"
class="border border-outline bg-foundation text-foreground backdrop-blur shadow-lg rounded-xl p-4 space-y-4 absolute pointer-events-auto mx-2"
@mouseenter="rotateGently(Math.random() * 2)"
@mouseleave="rotateGently(Math.random() * 2)"
>
@ -38,7 +38,7 @@
<!-- eslint-disable-next-line vuejs-accessibility/mouse-events-have-key-events -->
<div
v-show="step === 1"
class="bg-white/60 dark:bg-neutral-900/60 border dark:border-neutral-800 text-foreground backdrop-blur shadow-lg rounded-xl p-4 space-y-4 absolute pointer-events-auto mx-2"
class="bg-foundation border dark:border-neutral-800 text-foreground backdrop-blur shadow-lg rounded-xl p-4 space-y-4 absolute pointer-events-auto mx-2"
@mouseenter="rotateGently(Math.random() * 2)"
@mouseleave="rotateGently(Math.random() * 2)"
>

Просмотреть файл

@ -17,6 +17,9 @@ export const activeUserQuery = graphql(`
createdAt
verified
notificationPreferences
versions(limit: 0) {
totalCount
}
}
}
`)
@ -62,6 +65,8 @@ export function useActiveUser() {
const isGuest = computed(() => activeUser.value?.role === Roles.Server.Guest)
const isAdmin = computed(() => activeUser.value?.role === Roles.Server.Admin)
const projectVersionCount = computed(() => activeUser.value?.versions.totalCount)
return {
activeUser,
userId,
@ -70,7 +75,8 @@ export function useActiveUser() {
refetch,
onResult,
isGuest,
isAdmin
isAdmin,
projectVersionCount
}
}

Просмотреть файл

@ -41,7 +41,6 @@ const documents = {
"\n fragment ProjectPageModelsActions_Project on Project {\n id\n ...ProjectsModelPageEmbed_Project\n }\n": types.ProjectPageModelsActions_ProjectFragmentDoc,
"\n fragment ProjectPageModelsCardProject on Project {\n id\n role\n visibility\n ...ProjectPageModelsActions_Project\n }\n": types.ProjectPageModelsCardProjectFragmentDoc,
"\n fragment ProjectModelsPageHeader_Project on Project {\n id\n name\n sourceApps\n role\n team {\n user {\n ...FormUsersSelectItem\n }\n }\n }\n": types.ProjectModelsPageHeader_ProjectFragmentDoc,
"\n fragment ModelPreview on Model {\n previewUrl\n }\n": types.ModelPreviewFragmentDoc,
"\n fragment ProjectModelsPageResults_Project on Project {\n ...ProjectPageLatestItemsModels\n }\n": types.ProjectModelsPageResults_ProjectFragmentDoc,
"\n fragment ProjectPageModelsStructureItem_Project on Project {\n id\n ...ProjectPageModelsActions_Project\n }\n": types.ProjectPageModelsStructureItem_ProjectFragmentDoc,
"\n fragment SingleLevelModelTreeItem on ModelsTreeItem {\n id\n name\n fullName\n model {\n ...ProjectPageLatestItemsModelItem\n }\n hasChildren\n updatedAt\n }\n": types.SingleLevelModelTreeItemFragmentDoc,
@ -68,7 +67,7 @@ const documents = {
"\n fragment ThreadCommentAttachment on Comment {\n text {\n attachments {\n id\n fileName\n fileType\n fileSize\n }\n }\n }\n": types.ThreadCommentAttachmentFragmentDoc,
"\n fragment ViewerCommentsListItem on Comment {\n id\n rawText\n archived\n author {\n ...LimitedUserAvatar\n }\n createdAt\n viewedAt\n replies {\n totalCount\n cursor\n items {\n ...ViewerCommentsReplyItem\n }\n }\n replyAuthors(limit: 4) {\n totalCount\n items {\n ...FormUsersSelectItem\n }\n }\n resources {\n resourceId\n resourceType\n }\n }\n": types.ViewerCommentsListItemFragmentDoc,
"\n fragment ViewerModelVersionCardItem on Version {\n id\n message\n referencedObject\n sourceApplication\n createdAt\n previewUrl\n authorUser {\n ...LimitedUserAvatar\n }\n }\n": types.ViewerModelVersionCardItemFragmentDoc,
"\n query ActiveUserMainMetadata {\n activeUser {\n id\n email\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n }\n }\n": types.ActiveUserMainMetadataDocument,
"\n query ActiveUserMainMetadata {\n activeUser {\n id\n email\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n versions(limit: 0) {\n totalCount\n }\n }\n }\n": types.ActiveUserMainMetadataDocument,
"\n mutation CreateOnboardingProject {\n projectMutations {\n createForOnboarding {\n ...ProjectPageProject\n ...ProjectDashboardItem\n }\n }\n }\n ": types.CreateOnboardingProjectDocument,
"\n mutation FinishOnboarding {\n activeUserMutations {\n finishOnboarding\n }\n }\n": types.FinishOnboardingDocument,
"\n mutation RequestVerificationByEmail($email: String!) {\n requestVerificationByEmail(email: $email)\n }\n": types.RequestVerificationByEmailDocument,
@ -303,10 +302,6 @@ export function graphql(source: "\n fragment ProjectPageModelsCardProject on Pr
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment ProjectModelsPageHeader_Project on Project {\n id\n name\n sourceApps\n role\n team {\n user {\n ...FormUsersSelectItem\n }\n }\n }\n"): (typeof documents)["\n fragment ProjectModelsPageHeader_Project on Project {\n id\n name\n sourceApps\n role\n team {\n user {\n ...FormUsersSelectItem\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment ModelPreview on Model {\n previewUrl\n }\n"): (typeof documents)["\n fragment ModelPreview on Model {\n previewUrl\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@ -414,7 +409,7 @@ export function graphql(source: "\n fragment ViewerModelVersionCardItem on Vers
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query ActiveUserMainMetadata {\n activeUser {\n id\n email\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n }\n }\n"): (typeof documents)["\n query ActiveUserMainMetadata {\n activeUser {\n id\n email\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n }\n }\n"];
export function graphql(source: "\n query ActiveUserMainMetadata {\n activeUser {\n id\n email\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n versions(limit: 0) {\n totalCount\n }\n }\n }\n"): (typeof documents)["\n query ActiveUserMainMetadata {\n activeUser {\n id\n email\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n versions(limit: 0) {\n totalCount\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/

Просмотреть файл

@ -612,6 +612,15 @@ export type CommitsMoveInput = {
targetBranch: Scalars['String'];
};
/**
* Can be used instead of a full item collection, when the implementation doesn't call for it yet. Because
* of the structure, it can be swapped out to a full item collection in the future
*/
export type CountOnlyCollection = {
__typename?: 'CountOnlyCollection';
totalCount: Scalars['Int'];
};
export type CreateCommentInput = {
content: CommentContentInput;
projectId: Scalars['String'];
@ -2646,6 +2655,13 @@ export type User = {
/** Total amount of favorites attached to streams owned by the user */
totalOwnedStreamsFavorites: Scalars['Int'];
verified?: Maybe<Scalars['Boolean']>;
/**
* Get (count of) user's versions. By default gets all versions of all projects the user has access to.
* Set authoredOnly=true to only retrieve versions authored by the user.
*
* Note: Only count resolution is currently implemented
*/
versions: CountOnlyCollection;
};
@ -2714,6 +2730,16 @@ export type UserTimelineArgs = {
limit?: Scalars['Int'];
};
/**
* Full user type, should only be used in the context of admin operations or
* when a user is reading/writing info about himself
*/
export type UserVersionsArgs = {
authoredOnly?: Scalars['Boolean'];
limit?: Scalars['Int'];
};
export type UserDeleteInput = {
email: Scalars['String'];
};
@ -2999,8 +3025,6 @@ export type ProjectPageModelsCardProjectFragment = { __typename?: 'Project', id:
export type ProjectModelsPageHeader_ProjectFragment = { __typename?: 'Project', id: string, name: string, sourceApps: Array<string>, role?: string | null, team: Array<{ __typename?: 'ProjectCollaborator', user: { __typename?: 'LimitedUser', id: string, name: string, avatar?: string | null } }> };
export type ModelPreviewFragment = { __typename?: 'Model', previewUrl?: string | null };
export type ProjectModelsPageResults_ProjectFragment = { __typename?: 'Project', id: string, role?: string | null, visibility: ProjectVisibility, allowPublicComments: boolean, modelCount: { __typename?: 'ModelCollection', totalCount: number } };
export type ProjectPageModelsStructureItem_ProjectFragment = { __typename?: 'Project', id: string, visibility: ProjectVisibility, allowPublicComments: boolean, role?: string | null };
@ -3059,7 +3083,7 @@ export type ViewerModelVersionCardItemFragment = { __typename?: 'Version', id: s
export type ActiveUserMainMetadataQueryVariables = Exact<{ [key: string]: never; }>;
export type ActiveUserMainMetadataQuery = { __typename?: 'Query', activeUser?: { __typename?: 'User', id: string, email?: string | null, company?: string | null, bio?: string | null, name: string, role?: string | null, avatar?: string | null, isOnboardingFinished?: boolean | null, createdAt?: string | null, verified?: boolean | null, notificationPreferences: {} } | null };
export type ActiveUserMainMetadataQuery = { __typename?: 'Query', activeUser?: { __typename?: 'User', id: string, email?: string | null, company?: string | null, bio?: string | null, name: string, role?: string | null, avatar?: string | null, isOnboardingFinished?: boolean | null, createdAt?: string | null, verified?: boolean | null, notificationPreferences: {}, versions: { __typename?: 'CountOnlyCollection', totalCount: number } } | null };
export type CreateOnboardingProjectMutationVariables = Exact<{ [key: string]: never; }>;
@ -3791,7 +3815,6 @@ export const ProjectDiscussionsPageResults_ProjectFragmentDoc = {"kind":"Documen
export const FormUsersSelectItemFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"FormUsersSelectItem"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LimitedUser"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}}]}}]} as unknown as DocumentNode<FormUsersSelectItemFragment, unknown>;
export const ProjectPageLatestItemsCommentItemFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProjectPageLatestItemsCommentItem"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comment"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FormUsersSelectItem"}}]}},{"kind":"Field","name":{"kind":"Name","value":"screenshot"}},{"kind":"Field","name":{"kind":"Name","value":"rawText"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"archived"}},{"kind":"Field","alias":{"kind":"Name","value":"repliesCount"},"name":{"kind":"Name","value":"replies"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"0"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"replyAuthors"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"4"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FormUsersSelectItem"}}]}}]}}]}}]} as unknown as DocumentNode<ProjectPageLatestItemsCommentItemFragment, unknown>;
export const ProjectModelsPageHeader_ProjectFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProjectModelsPageHeader_Project"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"sourceApps"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FormUsersSelectItem"}}]}}]}}]}}]} as unknown as DocumentNode<ProjectModelsPageHeader_ProjectFragment, unknown>;
export const ModelPreviewFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ModelPreview"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Model"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"previewUrl"}}]}}]} as unknown as DocumentNode<ModelPreviewFragment, unknown>;
export const ProjectPageModelsActions_ProjectFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProjectPageModelsActions_Project"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectsModelPageEmbed_Project"}}]}}]} as unknown as DocumentNode<ProjectPageModelsActions_ProjectFragment, unknown>;
export const ProjectPageModelsStructureItem_ProjectFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProjectPageModelsStructureItem_Project"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectPageModelsActions_Project"}}]}}]} as unknown as DocumentNode<ProjectPageModelsStructureItem_ProjectFragment, unknown>;
export const ProjectPageLatestItemsModelsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProjectPageLatestItemsModels"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","alias":{"kind":"Name","value":"modelCount"},"name":{"kind":"Name","value":"models"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"0"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectPageModelsStructureItem_Project"}}]}}]} as unknown as DocumentNode<ProjectPageLatestItemsModelsFragment, unknown>;
@ -3834,7 +3857,7 @@ export const RegisterPanelServerInviteDocument = {"kind":"Document","definitions
export const EmailVerificationBannerStateDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EmailVerificationBannerState"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}},{"kind":"Field","name":{"kind":"Name","value":"hasPendingVerification"}}]}}]}}]} as unknown as DocumentNode<EmailVerificationBannerStateQuery, EmailVerificationBannerStateQueryVariables>;
export const RequestVerificationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RequestVerification"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"requestVerification"}}]}}]} as unknown as DocumentNode<RequestVerificationMutation, RequestVerificationMutationVariables>;
export const OnUserProjectsUpdateDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"OnUserProjectsUpdate"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userProjectsUpdated"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"project"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectDashboardItem"}}]}}]}}]}},...ProjectDashboardItemFragmentDoc.definitions,...ProjectDashboardItemNoModelsFragmentDoc.definitions,...ProjectPageModelsCardProjectFragmentDoc.definitions,...ProjectPageModelsActions_ProjectFragmentDoc.definitions,...ProjectsModelPageEmbed_ProjectFragmentDoc.definitions,...ProjectsPageTeamDialogManagePermissions_ProjectFragmentDoc.definitions,...ProjectPageLatestItemsModelItemFragmentDoc.definitions,...PendingFileUploadFragmentDoc.definitions,...ProjectPageModelsCardRenameDialogFragmentDoc.definitions,...ProjectPageModelsCardDeleteDialogFragmentDoc.definitions,...ProjectPageModelsActionsFragmentDoc.definitions,...ModelCardAutomationStatus_ModelFragmentDoc.definitions,...ModelCardAutomationStatus_AutomationsStatusFragmentDoc.definitions]} as unknown as DocumentNode<OnUserProjectsUpdateSubscription, OnUserProjectsUpdateSubscriptionVariables>;
export const ActiveUserMainMetadataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ActiveUserMainMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"isOnboardingFinished"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}},{"kind":"Field","name":{"kind":"Name","value":"notificationPreferences"}}]}}]}}]} as unknown as DocumentNode<ActiveUserMainMetadataQuery, ActiveUserMainMetadataQueryVariables>;
export const ActiveUserMainMetadataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ActiveUserMainMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"isOnboardingFinished"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}},{"kind":"Field","name":{"kind":"Name","value":"notificationPreferences"}},{"kind":"Field","name":{"kind":"Name","value":"versions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"0"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}}]}}]}}]} as unknown as DocumentNode<ActiveUserMainMetadataQuery, ActiveUserMainMetadataQueryVariables>;
export const CreateOnboardingProjectDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateOnboardingProject"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createForOnboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectPageProject"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectDashboardItem"}}]}}]}}]}},...ProjectPageProjectFragmentDoc.definitions,...ProjectPageProjectHeaderFragmentDoc.definitions,...ProjectPageStatsBlockTeamFragmentDoc.definitions,...LimitedUserAvatarFragmentDoc.definitions,...ProjectPageTeamDialogFragmentDoc.definitions,...ProjectsPageTeamDialogManagePermissions_ProjectFragmentDoc.definitions,...ProjectPageStatsBlockVersionsFragmentDoc.definitions,...ProjectPageStatsBlockModelsFragmentDoc.definitions,...ProjectPageStatsBlockCommentsFragmentDoc.definitions,...ProjectPageLatestItemsModelsFragmentDoc.definitions,...ProjectPageModelsStructureItem_ProjectFragmentDoc.definitions,...ProjectPageModelsActions_ProjectFragmentDoc.definitions,...ProjectsModelPageEmbed_ProjectFragmentDoc.definitions,...ProjectPageLatestItemsCommentsFragmentDoc.definitions,...ProjectDashboardItemFragmentDoc.definitions,...ProjectDashboardItemNoModelsFragmentDoc.definitions,...ProjectPageModelsCardProjectFragmentDoc.definitions,...ProjectPageLatestItemsModelItemFragmentDoc.definitions,...PendingFileUploadFragmentDoc.definitions,...ProjectPageModelsCardRenameDialogFragmentDoc.definitions,...ProjectPageModelsCardDeleteDialogFragmentDoc.definitions,...ProjectPageModelsActionsFragmentDoc.definitions,...ModelCardAutomationStatus_ModelFragmentDoc.definitions,...ModelCardAutomationStatus_AutomationsStatusFragmentDoc.definitions]} as unknown as DocumentNode<CreateOnboardingProjectMutation, CreateOnboardingProjectMutationVariables>;
export const FinishOnboardingDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"FinishOnboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUserMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"finishOnboarding"}}]}}]}}]} as unknown as DocumentNode<FinishOnboardingMutation, FinishOnboardingMutationVariables>;
export const RequestVerificationByEmailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RequestVerificationByEmail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"email"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"requestVerificationByEmail"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"email"},"value":{"kind":"Variable","name":{"kind":"Name","value":"email"}}}]}]}}]} as unknown as DocumentNode<RequestVerificationByEmailMutation, RequestVerificationByEmailMutationVariables>;

Просмотреть файл

@ -0,0 +1,4 @@
import { H3Error } from 'h3'
export const isH3Error = (error: unknown): error is H3Error =>
!!(error && error instanceof H3Error)

Просмотреть файл

@ -144,6 +144,10 @@ function createCache(): InMemoryCache {
projects: {
keyArgs: ['filter', 'limit'],
merge: buildAbstractCollectionMergeFunction('ProjectCollection')
},
versions: {
keyArgs: ['authoredOnly', 'limit'],
merge: buildAbstractCollectionMergeFunction('CountOnlyCollection')
}
}
},

Просмотреть файл

@ -4,6 +4,7 @@ import { useAuthCookie } from '~~/lib/auth/composables/auth'
import { onProjectVersionsPreviewGeneratedSubscription } from '~~/lib/projects/graphql/subscriptions'
import { useSubscription } from '@vue/apollo-composable'
import { useLock } from '~~/lib/common/composables/singleton'
import PreviewPlaceholder from '~~/assets/images/preview_placeholder.png'
const previewUrlProjectIdRegexp = /\/preview\/([\w\d]+)\//i
const previewUrlCommitIdRegexp = /\/commits\/([\w\d]+)/i
@ -14,22 +15,53 @@ class AngleNotFoundError extends Error {}
/**
* Get authenticated preview image URL and subscribes to preview image generation events so that the preview image URL
* is updated whenever generation finishes
* NOTE: Returns null during SSR, so make sure you wrap any components that render the image
* in <ClientOnly> to prevent hydration errors
*/
export function usePreviewImageBlob(previewUrl: MaybeRef<string | null | undefined>) {
export function usePreviewImageBlob(
previewUrl: MaybeRef<string | null | undefined>,
options?: Partial<{
/**
* Allows disabling the mechanism conditionally (e.g. if image not in viewport)
*/
enabled: MaybeRef<boolean>
}>
) {
const { enabled = ref(true) } = options || {}
const authToken = useAuthCookie()
const logger = useLogger()
const {
public: { enableDirectPreviews }
} = useRuntimeConfig()
const url = ref(null as Nullable<string>)
const url = ref(PreviewPlaceholder as Nullable<string>)
const hasDoneFirstLoad = ref(false)
const panoramaUrl = ref(null as Nullable<string>)
const isLoadingPanorama = ref(false)
const shouldLoadPanorama = ref(false)
const basePanoramaUrl = computed(() => unref(previewUrl) + '/all')
const isEnabled = computed(() => (process.server ? true : unref(enabled)))
const ret = {
previewUrl: computed(() => url.value),
panoramaPreviewUrl: computed(() => panoramaUrl.value),
isLoadingPanorama,
shouldLoadPanorama
shouldLoadPanorama,
hasDoneFirstLoad: computed(() => hasDoneFirstLoad.value)
}
if (enableDirectPreviews) {
const directPreviewUrl = unref(previewUrl)
// const directPanoramicUrl = basePanoramaUrl.value
useHead({
link: [
...(directPreviewUrl?.length
? [{ rel: 'preload', as: <const>'image', href: directPreviewUrl }]
: [])
// ...(directPanoramicUrl?.length
// ? [{ rel: 'prefetch', as: <const>'image', href: directPanoramicUrl }]
// : [])
]
})
}
if (process.server) return ret
@ -71,7 +103,7 @@ export function usePreviewImageBlob(previewUrl: MaybeRef<string | null | undefin
() => ({
id: projectId.value || ''
}),
() => ({ enabled: !!projectId.value && hasLock.value })
() => ({ enabled: !!projectId.value && hasLock.value && isEnabled.value })
)
onProjectPreviewGenerated((res) => {
@ -92,62 +124,101 @@ export function usePreviewImageBlob(previewUrl: MaybeRef<string | null | undefin
})
async function processBasePreviewUrl(basePreviewUrl: MaybeNullOrUndefined<string>) {
if (!isEnabled.value) return
try {
if (!basePreviewUrl) {
url.value = null
url.value = PreviewPlaceholder
hasDoneFirstLoad.value = true
return
}
const res = await fetch(basePreviewUrl, {
headers: authToken.value ? { Authorization: `Bearer ${authToken.value}` } : {}
})
let blobUrl: string
if (enableDirectPreviews || process.server) {
blobUrl = basePreviewUrl
} else {
const res = await fetch(basePreviewUrl, {
headers: authToken.value ? { Authorization: `Bearer ${authToken.value}` } : {}
})
if (res.headers.has('X-Preview-Error')) {
throw new Error('Failed getting preview')
if (res.headers.has('X-Preview-Error')) {
throw new Error('Failed getting preview')
}
const blob = await res.blob()
blobUrl = URL.createObjectURL(blob)
}
// Load img in browser first, before we set the url
if (process.client) {
const img = new Image()
img.src = blobUrl
await new Promise((resolve, reject) => {
img.onload = resolve
img.onerror = reject
})
}
const blob = await res.blob()
const blobUrl = URL.createObjectURL(blob)
url.value = blobUrl
} catch (e) {
logger.error('Preview image load error', e)
url.value = basePreviewUrl || null
url.value = PreviewPlaceholder
} finally {
hasDoneFirstLoad.value = true
}
}
async function processPanoramaPreviewUrl() {
if (!isEnabled.value) return
const basePreviewUrl = unref(previewUrl)
try {
isLoadingPanorama.value = true
if (!basePreviewUrl) {
url.value = null
url.value = PreviewPlaceholder
return
}
const res = await fetch(basePreviewUrl + '/all', {
headers: authToken.value ? { Authorization: `Bearer ${authToken.value}` } : {}
})
let blobUrl: string
if (enableDirectPreviews || process.server) {
blobUrl = basePanoramaUrl.value
} else {
const res = await fetch(basePanoramaUrl.value, {
headers: authToken.value ? { Authorization: `Bearer ${authToken.value}` } : {}
})
const errCode = res.headers.get('X-Preview-Error-Code')
if (errCode?.length) {
if (errCode === 'ANGLE_NOT_FOUND') {
throw new AngleNotFoundError()
const errCode = res.headers.get('X-Preview-Error-Code')
if (errCode?.length) {
if (errCode === 'ANGLE_NOT_FOUND') {
throw new AngleNotFoundError()
}
}
if (res.headers.has('X-Preview-Error')) {
throw new Error('Failed getting preview')
}
const blob = await res.blob()
blobUrl = URL.createObjectURL(blob)
}
if (res.headers.has('X-Preview-Error')) {
throw new Error('Failed getting preview')
// Load img in browser first, before we set the url
if (process.client) {
const img = new Image()
img.src = blobUrl
await new Promise((resolve, reject) => {
img.onload = resolve
img.onerror = reject
})
}
const blob = await res.blob()
const blobUrl = URL.createObjectURL(blob)
panoramaUrl.value = blobUrl
} catch (e) {
if (!(e instanceof AngleNotFoundError)) {
logger.error('Panorama preview image load error:', e)
}
panoramaUrl.value = basePreviewUrl || null
panoramaUrl.value = null
} finally {
isLoadingPanorama.value = false
}
@ -166,6 +237,16 @@ export function usePreviewImageBlob(previewUrl: MaybeRef<string | null | undefin
{ immediate: true }
)
watch(
() => isEnabled.value,
(newVal) => {
if (!newVal) return
processBasePreviewUrl(unref(previewUrl))
if (shouldLoadPanorama.value) processPanoramaPreviewUrl()
}
)
return ret
}

Просмотреть файл

@ -3,7 +3,7 @@ import { useMixpanel } from '~~/lib/core/composables/mp'
export default defineNuxtRouteMiddleware((to) => {
if (process.server) return
const mp = useMixpanel()
const pathDefinition = to.matched[to.matched.length - 1].path
const pathDefinition = getRouteDefinition(to)
const path = to.path
mp.track('Route Visited', {
path,

Просмотреть файл

@ -50,6 +50,7 @@ export default defineNuxtConfig({
baseUrl: '',
mixpanelApiHost: 'UNDEFINED',
mixpanelTokenId: 'UNDEFINED',
survicateWorkspaceKey: '',
logLevel: NUXT_PUBLIC_LOG_LEVEL,
logPretty: isLogPretty,
logCsrEmitProps: false,
@ -67,7 +68,8 @@ export default defineNuxtConfig({
datadogClientToken: '',
datadogSite: '',
datadogService: '',
datadogEnv: ''
datadogEnv: '',
enableDirectPreviews: true
}
},

Просмотреть файл

@ -29,6 +29,7 @@
"@speckle/ui-components": "workspace:^",
"@speckle/ui-components-nuxt": "workspace:^",
"@speckle/viewer": "workspace:^",
"@survicate/survicate-web-surveys-wrapper": "^1.2.1",
"@tiptap/core": "2.0.0-beta.220",
"@tiptap/extension-bold": "2.0.0-beta.220",
"@tiptap/extension-document": "2.0.0-beta.220",

Просмотреть файл

@ -252,7 +252,9 @@ export default defineNuxtPlugin(async (nuxtApp) => {
logger.error(error, 'Unhandled error in Vue app', info)
}
nuxtApp.hook('app:error', (error) => {
logger.error(error, 'Unhandled app error')
logger.error(error, 'Unhandled app error', {
isAppError: true
})
})
return {

Просмотреть файл

@ -5,6 +5,7 @@ import {
} from '~/lib/auth/composables/auth'
import { useCreateErrorLoggingTransport } from '~/lib/core/composables/error'
import type { Plugin } from 'nuxt/dist/app/nuxt'
import { isH3Error } from '~/lib/common/helpers/error'
type PluginNuxtApp = Parameters<Plugin>[0]
@ -96,12 +97,48 @@ async function initRumClient(app: PluginNuxtApp) {
)
router.beforeEach((to) => {
const pathDefinition = to.matched[to.matched.length - 1].path
const pathDefinition = getRouteDefinition(to)
const routeName = to.meta.datadogName || pathDefinition
const realPath = to.path
window.DD_RUM_START_VIEW?.(realPath, routeName)
})
const resolveH3Data = (error: unknown) =>
error && isH3Error(error)
? {
statusCode: error.statusCode,
fatal: error.fatal,
statusMessage: error.statusMessage,
h3Data: error.data
}
: {}
registerErrorTransport({
onError: ({ args, firstError, firstString, otherData, nonObjectOtherData }) => {
if (!datadog || !('addError' in datadog)) return
const error = firstError || firstString || args[0]
datadog.addError(error, {
...otherData,
...resolveH3Data(firstError),
extraData: nonObjectOtherData,
mainErrorMessage: firstString,
isProperlySentError: true
})
},
onUnhandledError: ({ isUnhandledRejection, error, message }) => {
if (!datadog || !('addError' in datadog)) return
datadog.addError(error || message, {
...resolveH3Data(error),
isUnhandledRejection,
message,
mainErrorMessage: message,
isProperlySentError: true
})
}
})
}
}
@ -204,7 +241,7 @@ async function initRumServer(app: PluginNuxtApp) {
app.hook('app:rendered', (context) => {
const route = app._route
const pathDefinition = route.matched[route.matched.length - 1].path
const pathDefinition = getRouteDefinition(route)
const pathReal = route.path
const routeName = route.meta.datadogName || pathDefinition
@ -236,7 +273,14 @@ async function initRumServer(app: PluginNuxtApp) {
trackResources: true,
trackLongTasks: true,
defaultPrivacyLevel: 'mask-user-input',
trackViewsManually: true
trackViewsManually: true,
beforeSend: (event) => {
if (event?.type === 'error') {
if (!event.context?.isProperlySentError) return false
delete event.context.isProperlySentError
}
return true
}
});
window.DD_RUM_START_VIEW = (path, name) => {

Просмотреть файл

@ -0,0 +1,114 @@
import { useOnAuthStateChange } from '~/lib/auth/composables/auth'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import { useSynchronizedCookie } from '~/lib/common/composables/reactiveCookie'
import dayjs from 'dayjs'
import type { Survicate } from '@survicate/survicate-web-surveys-wrapper'
import type { Nullable } from '@speckle/shared'
export default defineNuxtPlugin(async () => {
const { isLoggedIn } = useActiveUser()
let survicateInstance = null as Nullable<Survicate>
if (!isLoggedIn.value) {
return {
provide: {
survicate: survicateInstance
}
}
}
const {
public: { survicateWorkspaceKey }
} = useRuntimeConfig()
const logger = useLogger()
const onAuthStateChange = useOnAuthStateChange()
const prepareSurvey = useInitMainSurvey()
// Skip initialization if the survicateWorkspaceKey is empty or undefined
if (!survicateWorkspaceKey?.length) {
return {
provide: {
survicate: survicateInstance
}
}
}
try {
const { initSurvicate, getSurvicateInstance } = await import(
'@survicate/survicate-web-surveys-wrapper/widget_wrapper'
)
// Skip await, cause the survicate codebase stupidly does not handle adblock preventing their script from being loaded
// which causes this promise to never resolve (or reject!)
// Thus we're initializing survicate asynchronously and letting the plugin (& the app) finish running before that happens
void initSurvicate({ workspaceKey: survicateWorkspaceKey })
.then(async () => {
survicateInstance = getSurvicateInstance()
if (!survicateInstance) {
throw new Error('Survicate instance is not available after initialization.')
}
// Handle authentication state changes
await onAuthStateChange(
(user, { resolveDistinctId }) => {
const distinctId = resolveDistinctId(user)
if (distinctId && survicateInstance) {
survicateInstance.setVisitorTraits({ distinctId })
}
},
{ immediate: true }
)
prepareSurvey(survicateInstance)
})
.catch(logger.error)
} catch (error) {
logger.error('Survicate failed to load:', error)
}
return {
provide: {
survicate: survicateInstance
}
}
})
function useInitMainSurvey() {
const onboardingOrFeedbackDateString = useSynchronizedCookie<string | undefined>(
'onboardingOrFeedbackDate',
{
default: () => dayjs().startOf('day').format('YYYY-MM-DD'),
expires: dayjs().add(999, 'day').toDate()
}
)
const { projectVersionCount } = useActiveUser()
return (survicateInstance: Survicate) => {
const onboardingOrFeedbackDate = onboardingOrFeedbackDateString.value
? new Date(onboardingOrFeedbackDateString.value)
: new Date()
const shouldShowSurvey = checkSurveyDisplayConditions(
onboardingOrFeedbackDate,
projectVersionCount
)
if (shouldShowSurvey) {
survicateInstance.invokeEvent('nps-survey')
}
}
}
function checkSurveyDisplayConditions(
onboardingOrFeedbackDate: Date,
projectVersionCount: ComputedRef<number | undefined>
): boolean {
const threeDaysAfterOnboarding = dayjs(onboardingOrFeedbackDate).add(3, 'day')
const isAfterOnboardingPeriod = dayjs().isAfter(threeDaysAfterOnboarding)
const minimumThreeVersions = (projectVersionCount?.value ?? 0) > 2
return isAfterOnboardingPeriod && minimumThreeVersions
}

Просмотреть файл

@ -36,7 +36,7 @@ More info: https://the-guild.dev/blog/unleash-the-power-of-fragments-with-graphq
#### ESLint results doesn't update after GQL type regeneration
Restart the ESLint plugin through VSCode's command palette, this is a bug with the ESLint plugin
Restart the ESLint plugin through VSCode's command palette, this is a bug with the ESLint plugin.
#### GraphQL codegen throws an error like "Unknown fragment XXX" or something else that doesn't make sense

Просмотреть файл

@ -1,5 +1,7 @@
export default defineNitroPlugin((nitroApp) => {
const logger = useLogger()
logger.info('Starting up the server, hello!')
nitroApp.hooks.hook('close', () => {
logger.warn('Closing down the server, bye bye!')
})

Просмотреть файл

@ -0,0 +1,11 @@
const noStaticAssetFoundRgx = /Cannot find static asset/i
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('error', (err, { event }) => {
if (!event) return
if (noStaticAssetFoundRgx.test(err.message)) {
setHeader(event, 'Cache-Control', 'no-store')
}
})
})

Просмотреть файл

@ -1,3 +1,4 @@
import type { RouteLocationNormalized } from 'vue-router'
import { noop } from 'lodash-es'
import { wrapRefWithTracking } from '~/lib/common/helpers/debugging'
import { ToastNotificationType } from '~~/lib/common/composables/toast'
@ -7,4 +8,12 @@ import { ToastNotificationType } from '~~/lib/common/composables/toast'
*/
export const markUsed = noop
/**
* Will attempt to resolve the current route definition in various ways.
*/
export const getRouteDefinition = (route?: RouteLocationNormalized) => {
const matchedPath = route ? route.matched[route.matched.length - 1]?.path : undefined
return matchedPath || '/404'
}
export { ToastNotificationType, wrapRefWithTracking }

Просмотреть файл

@ -2,7 +2,7 @@
ARG NODE_ENV=production
ARG SPECKLE_SERVER_VERSION=custom
# build stage
FROM node:18-bullseye-slim@sha256:a4edd54dcfdcacc8a4100fee71498e8671d99556a1acf5614539214a70092426 as build-stage
FROM node:18-bullseye-slim@sha256:cf2cfd6631c5c80e6dbef767bbdf40766af1b58244080a7e3111bc811b58c784 as build-stage
ARG NODE_ENV
ARG SPECKLE_SERVER_VERSION

Просмотреть файл

@ -292,7 +292,19 @@ export default class ObjectLoader {
processLine(chunk) {
const pieces = chunk.split('\t')
return { id: pieces[0], obj: JSON.parse(pieces[1]) }
const [id, unparsedObj] = pieces
let obj
try {
obj = JSON.parse(unparsedObj)
} catch (e) {
throw new Error(`Error parsing object ${id}: ${e.message}`)
}
return {
id,
obj
}
}
supportsCache() {
@ -508,6 +520,10 @@ export default class ObjectLoader {
if (cachedRootObject[this.objectId]) return cachedRootObject[this.objectId]
const response = await this.fetch(this.requestUrlRootObj, { headers: this.headers })
const responseText = await response.text()
if ([401, 403].includes(response.status)) {
throw new ObjectLoaderRuntimeError('You do not have access to the root object!')
}
this.cacheStoreObjects([`${this.objectId}\t${responseText}`])
return responseText
}

Просмотреть файл

@ -1,7 +1,7 @@
# NOTE: Docker context should be set to git root directory, to include the viewer
ARG NODE_ENV=production
FROM node:18-bookworm-slim@sha256:246bf34b0c7cf8d9ff7cbe0c1ff44b178051f06c432c8e7df1645f1bd20b0352 as build-stage
FROM node:18-bookworm-slim@sha256:a7423cbf419ccea2723be0af141b663b643c30bea56d19bf2e8fe171e904fde9 as build-stage
ARG NODE_ENV
ENV NODE_ENV=${NODE_ENV}
@ -36,7 +36,7 @@ COPY packages/preview-service ./packages/preview-service/
# This way the foreach only builds the frontend and its deps
RUN yarn workspaces foreach run build
FROM node:18-bookworm-slim@sha256:246bf34b0c7cf8d9ff7cbe0c1ff44b178051f06c432c8e7df1645f1bd20b0352 as node
FROM node:18-bookworm-slim@sha256:a7423cbf419ccea2723be0af141b663b643c30bea56d19bf2e8fe171e904fde9 as node
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y \

Просмотреть файл

@ -27,6 +27,7 @@ async function pageFunction(objectUrl) {
// Main call failed. Wait some time for other objects to load inside the viewer and generate the preview anyway
await waitForAnimation(1000)
}
window.v.resize()
window.v.zoom(undefined, 0.95, false)
await waitForAnimation(100)

Просмотреть файл

@ -1,7 +1,7 @@
ARG NODE_ENV=production
ARG SPECKLE_SERVER_VERSION=custom
FROM node:18-bookworm-slim@sha256:246bf34b0c7cf8d9ff7cbe0c1ff44b178051f06c432c8e7df1645f1bd20b0352 as build-stage
FROM node:18-bookworm-slim@sha256:a7423cbf419ccea2723be0af141b663b643c30bea56d19bf2e8fe171e904fde9 as build-stage
ARG NODE_ENV
ARG SPECKLE_SERVER_VERSION
WORKDIR /speckle-server
@ -39,7 +39,7 @@ RUN yarn workspaces foreach run build
# install only production dependencies
# we need a clean environment, free of build dependencies
FROM node:18-bookworm-slim@sha256:246bf34b0c7cf8d9ff7cbe0c1ff44b178051f06c432c8e7df1645f1bd20b0352 as dependency-stage
FROM node:18-bookworm-slim@sha256:a7423cbf419ccea2723be0af141b663b643c30bea56d19bf2e8fe171e904fde9 as dependency-stage
ARG NODE_ENV
ARG SPECKLE_SERVER_VERSION
@ -56,7 +56,7 @@ COPY packages/objectloader/package.json ./packages/objectloader/
WORKDIR /speckle-server/packages/server
RUN yarn workspaces focus --production
FROM node:18-bookworm-slim@sha256:246bf34b0c7cf8d9ff7cbe0c1ff44b178051f06c432c8e7df1645f1bd20b0352 as production-stage
FROM node:18-bookworm-slim@sha256:a7423cbf419ccea2723be0af141b663b643c30bea56d19bf2e8fe171e904fde9 as production-stage
ARG NODE_ENV
ARG SPECKLE_SERVER_VERSION
ARG FILE_SIZE_LIMIT_MB=100

Просмотреть файл

@ -7,6 +7,7 @@ import express, { Express } from 'express'
// `express-async-errors` patches express to catch errors in async handlers. no variable needed
import 'express-async-errors'
import compression from 'compression'
import cookieParser from 'cookie-parser'
import { createTerminus } from '@godaddy/terminus'
import * as Sentry from '@sentry/node'
@ -330,6 +331,7 @@ export async function init() {
// Should perhaps be done manually?
await knex.migrate.latest()
app.use(cookieParser())
app.use(DetermineRequestIdMiddleware)
app.use(determineClientIpAddressMiddleware)
app.use(LoggingExpressMiddleware)

Просмотреть файл

@ -25,3 +25,11 @@ enum SortDirection {
ASC
DESC
}
"""
Can be used instead of a full item collection, when the implementation doesn't call for it yet. Because
of the structure, it can be swapped out to a full item collection in the future
"""
type CountOnlyCollection {
totalCount: Int!
}

Просмотреть файл

@ -42,6 +42,17 @@ extend type Project {
version(id: String!): Version
}
extend type User {
"""
Get (count of) user's versions. By default gets all versions of all projects the user has access to.
Set authoredOnly=true to only retrieve versions authored by the user.
Note: Only count resolution is currently implemented
"""
versions(authoredOnly: Boolean! = false, limit: Int! = 25): CountOnlyCollection!
@isOwner
}
input ProjectModelsTreeFilter {
"""
Search for specific models. If used, tree items from different levels may be mixed.

Просмотреть файл

@ -12,7 +12,7 @@ const command: CommandModule<
commentAuthorId?: string
}
> = {
command: 'commit <commitUrl> <targetStreamId> [branchName] [token] [commentAuthorId]',
command: 'commit <commitUrl> <targetStreamId> [branchName] [commentAuthorId]',
describe: 'Download a commit from an external Speckle server instance',
builder: {
commitUrl: {
@ -31,8 +31,7 @@ const command: CommandModule<
},
token: {
describe: 'Target server auth token, in case the stream is private',
type: 'string',
default: ''
type: 'string'
},
commentAuthorId: {
describe:

Просмотреть файл

@ -8,6 +8,7 @@ const command: CommandModule<
projectUrl: string
authorId: string
syncComments: boolean
token?: string
}
> = {
command: 'project <projectUrl> <authorId> [syncComments]',
@ -26,6 +27,10 @@ const command: CommandModule<
describe: 'Whether or not to sync comments as well',
type: 'boolean',
default: true
},
token: {
describe: 'Target server auth token, in case the stream is private',
type: 'string'
}
},
handler: async (argv) => {

Просмотреть файл

@ -625,6 +625,15 @@ export type CommitsMoveInput = {
targetBranch: Scalars['String'];
};
/**
* Can be used instead of a full item collection, when the implementation doesn't call for it yet. Because
* of the structure, it can be swapped out to a full item collection in the future
*/
export type CountOnlyCollection = {
__typename?: 'CountOnlyCollection';
totalCount: Scalars['Int'];
};
export type CreateCommentInput = {
content: CommentContentInput;
projectId: Scalars['String'];
@ -2659,6 +2668,13 @@ export type User = {
/** Total amount of favorites attached to streams owned by the user */
totalOwnedStreamsFavorites: Scalars['Int'];
verified?: Maybe<Scalars['Boolean']>;
/**
* Get (count of) user's versions. By default gets all versions of all projects the user has access to.
* Set authoredOnly=true to only retrieve versions authored by the user.
*
* Note: Only count resolution is currently implemented
*/
versions: CountOnlyCollection;
};
@ -2727,6 +2743,16 @@ export type UserTimelineArgs = {
limit?: Scalars['Int'];
};
/**
* Full user type, should only be used in the context of admin operations or
* when a user is reading/writing info about himself
*/
export type UserVersionsArgs = {
authoredOnly?: Scalars['Boolean'];
limit?: Scalars['Int'];
};
export type UserDeleteInput = {
email: Scalars['String'];
};
@ -3065,6 +3091,7 @@ export type ResolversTypes = {
CommitUpdateInput: CommitUpdateInput;
CommitsDeleteInput: CommitsDeleteInput;
CommitsMoveInput: CommitsMoveInput;
CountOnlyCollection: ResolverTypeWrapper<CountOnlyCollection>;
CreateCommentInput: CreateCommentInput;
CreateCommentReplyInput: CreateCommentReplyInput;
CreateModelInput: CreateModelInput;
@ -3243,6 +3270,7 @@ export type ResolversParentTypes = {
CommitUpdateInput: CommitUpdateInput;
CommitsDeleteInput: CommitsDeleteInput;
CommitsMoveInput: CommitsMoveInput;
CountOnlyCollection: CountOnlyCollection;
CreateCommentInput: CreateCommentInput;
CreateCommentReplyInput: CreateCommentReplyInput;
CreateModelInput: CreateModelInput;
@ -3660,6 +3688,11 @@ export type CommitCollectionResolvers<ContextType = GraphQLContext, ParentType e
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type CountOnlyCollectionResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['CountOnlyCollection'] = ResolversParentTypes['CountOnlyCollection']> = {
totalCount?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export interface DateTimeScalarConfig extends GraphQLScalarTypeConfig<ResolversTypes['DateTime'], any> {
name: 'DateTime';
}
@ -4267,6 +4300,7 @@ export type UserResolvers<ContextType = GraphQLContext, ParentType extends Resol
timeline?: Resolver<Maybe<ResolversTypes['ActivityCollection']>, ParentType, ContextType, RequireFields<UserTimelineArgs, 'limit'>>;
totalOwnedStreamsFavorites?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
verified?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
versions?: Resolver<ResolversTypes['CountOnlyCollection'], ParentType, ContextType, RequireFields<UserVersionsArgs, 'authoredOnly' | 'limit'>>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
@ -4403,6 +4437,7 @@ export type Resolvers<ContextType = GraphQLContext> = {
CommentThreadActivityMessage?: CommentThreadActivityMessageResolvers<ContextType>;
Commit?: CommitResolvers<ContextType>;
CommitCollection?: CommitCollectionResolvers<ContextType>;
CountOnlyCollection?: CountOnlyCollectionResolvers<ContextType>;
DateTime?: GraphQLScalarType;
EmailAddress?: GraphQLScalarType;
FileUpload?: FileUploadResolvers<ContextType>;

Просмотреть файл

@ -27,6 +27,16 @@ import { BranchNotFoundError } from '@/modules/core/errors/branch'
import { CommitNotFoundError } from '@/modules/core/errors/commit'
export = {
User: {
async versions(parent, args, ctx) {
const authoredOnly = args.authoredOnly
return {
totalCount: authoredOnly
? await ctx.loaders.users.getAuthoredCommitCount.load(parent.id)
: await ctx.loaders.users.getStreamCommitCount.load(parent.id)
}
}
},
Project: {
async models(parent, args, ctx) {
// If limit=0 & no filter, short-cut full execution and use data loader

Просмотреть файл

@ -185,6 +185,15 @@ export = {
},
User: {
async projects(_parent, args, ctx) {
// If limit=0 & no filter, short-cut full execution and use data loader
if (!args.filter && args.limit === 0) {
return {
totalCount: await ctx.loaders.users.getOwnStreamCount.load(ctx.userId!),
items: [],
cursor: null
}
}
const totalCount = await getUserStreamsCount({
userId: ctx.userId!,
forOtherUser: false,

Просмотреть файл

@ -8,7 +8,8 @@ import {
getStreamRoles,
getStreamsSourceApps,
getCommitStreams,
StreamWithCommitId
StreamWithCommitId,
getUserStreamCounts
} from '@/modules/core/repositories/streams'
import { UserWithOptionalRole, getUsers } from '@/modules/core/repositories/users'
import { keyBy } from 'lodash'
@ -28,7 +29,9 @@ import {
getCommitBranches,
getCommits,
getSpecificBranchCommits,
getStreamCommitCounts
getStreamCommitCounts,
getUserAuthoredCommitCounts,
getUserStreamCommitCounts
} from '@/modules/core/repositories/commits'
import { ResourceIdentifier, Scope } from '@/modules/core/graph/generated/graphql'
import {
@ -412,7 +415,42 @@ export function buildRequestLoaders(
})
},
{ cacheKeyFn: (key) => `${key.userId}:${key.key}` }
)
),
/**
* Get user stream count. Includes private streams.
*/
getOwnStreamCount: createLoader<string, number>(async (userIds) => {
const results = await getUserStreamCounts({
publicOnly: false,
userIds: userIds.slice()
})
return userIds.map((i) => results[i] || 0)
}),
/**
* Get authored commit count. Includes commits from private streams.
*/
getAuthoredCommitCount: createLoader<string, number>(async (userIds) => {
const results = await getUserAuthoredCommitCounts({
userIds: userIds.slice(),
publicOnly: false
})
return userIds.map((i) => results[i] || 0)
}),
/**
* Get count of commits in streams that the user is a contributor in. Includes private streams.
*/
getStreamCommitCount: createLoader<string, number>(async (userIds) => {
const results = await getUserStreamCommitCounts({
userIds: userIds.slice(),
publicOnly: false
})
return userIds.map((i) => results[i] || 0)
})
},
invites: {
/**

Просмотреть файл

@ -3,7 +3,9 @@ import {
Branches,
Commits,
knex,
StreamCommits
StreamAcl,
StreamCommits,
Streams
} from '@/modules/core/dbSchema'
import {
BranchCommitRecord,
@ -11,7 +13,7 @@ import {
CommitRecord,
StreamCommitRecord
} from '@/modules/core/helpers/types'
import { clamp, uniq, uniqBy, reduce } from 'lodash'
import { clamp, uniq, uniqBy, reduce, keyBy, mapValues } from 'lodash'
const CommitWithStreamBranchMetadataFields = [
...Commits.cols,
@ -434,3 +436,63 @@ export async function getAllBranchCommits(params: {
{} as Record<string, CommitRecord[]>
)
}
export async function getUserStreamCommitCounts(params: {
userIds: string[]
/**
* Only include commits from public/discoverable streams
*/
publicOnly?: boolean
}) {
const { userIds, publicOnly } = params
if (!userIds?.length) return {}
const q = StreamAcl.knex()
.select<{ userId: string; count: string }[]>([
StreamAcl.col.userId,
knex.raw('COUNT(*)')
])
.join(StreamCommits.name, StreamCommits.col.streamId, StreamAcl.col.resourceId)
.whereIn(StreamAcl.col.userId, userIds)
.groupBy(StreamAcl.col.userId)
if (publicOnly) {
q.join(Streams.name, Streams.col.id, StreamAcl.col.resourceId)
q.andWhere((q1) => {
q1.where(Streams.col.isPublic, true).orWhere(Streams.col.isDiscoverable, true)
})
}
const res = await q
return mapValues(keyBy(res, 'userId'), (r) => parseInt(r.count))
}
export async function getUserAuthoredCommitCounts(params: {
userIds: string[]
/**
* Only include commits from public/discoverable streams
*/
publicOnly?: boolean
}) {
const { userIds, publicOnly } = params
if (!userIds?.length) return {}
const q = Commits.knex()
.select<{ authorId: string; count: string }[]>([
Commits.col.author,
knex.raw('COUNT(*)')
])
.whereIn(Commits.col.author, userIds)
.groupBy(Commits.col.author)
if (publicOnly) {
q.join(StreamCommits.name, StreamCommits.col.commitId, Commits.col.id)
q.join(Streams.name, Streams.col.id, StreamCommits.col.streamId)
q.andWhere((q1) => {
q1.where(Streams.col.isPublic, true).orWhere(Streams.col.isDiscoverable, true)
})
}
const res = await q
return mapValues(keyBy(res, 'author'), (r) => parseInt(r.count))
}

Просмотреть файл

@ -797,6 +797,34 @@ export async function createStream(
return newStream
}
export async function getUserStreamCounts(params: {
userIds: string[]
/**
* If true, will only count public & discoverable streams
*/
publicOnly?: boolean
}) {
const { userIds, publicOnly = false } = params
if (!userIds.length) return {}
const q = StreamAcl.knex()
.select<{ userId: string; count: string }[]>([
StreamAcl.col.userId,
knex.raw('COUNT(*)')
])
.whereIn(StreamAcl.col.userId, userIds)
.groupBy(StreamAcl.col.userId)
if (publicOnly) {
q.join(Streams.name, Streams.col.id, StreamAcl.col.resourceId).andWhere((q1) => {
q1.where(Streams.col.isPublic, true).orWhere(Streams.col.isDiscoverable, true)
})
}
const results = await q
return _.mapValues(_.keyBy(results, 'userId'), (r) => parseInt(r.count))
}
export async function deleteStream(streamId: string) {
// Delete stream commits (not automatically cascaded)
await knex.raw(

Просмотреть файл

@ -615,6 +615,15 @@ export type CommitsMoveInput = {
targetBranch: Scalars['String'];
};
/**
* Can be used instead of a full item collection, when the implementation doesn't call for it yet. Because
* of the structure, it can be swapped out to a full item collection in the future
*/
export type CountOnlyCollection = {
__typename?: 'CountOnlyCollection';
totalCount: Scalars['Int'];
};
export type CreateCommentInput = {
content: CommentContentInput;
projectId: Scalars['String'];
@ -2649,6 +2658,13 @@ export type User = {
/** Total amount of favorites attached to streams owned by the user */
totalOwnedStreamsFavorites: Scalars['Int'];
verified?: Maybe<Scalars['Boolean']>;
/**
* Get (count of) user's versions. By default gets all versions of all projects the user has access to.
* Set authoredOnly=true to only retrieve versions authored by the user.
*
* Note: Only count resolution is currently implemented
*/
versions: CountOnlyCollection;
};
@ -2717,6 +2733,16 @@ export type UserTimelineArgs = {
limit?: Scalars['Int'];
};
/**
* Full user type, should only be used in the context of admin operations or
* when a user is reading/writing info about himself
*/
export type UserVersionsArgs = {
authoredOnly?: Scalars['Boolean'];
limit?: Scalars['Int'];
};
export type UserDeleteInput = {
email: Scalars['String'];
};

Просмотреть файл

@ -516,6 +516,7 @@ const loadAllObjectsFromParent = async (
targetStreamId: string
sourceCommit: CommitMetadata
parsedCommitUrl: ParsedCommitUrl
token?: string
},
options?: Partial<{
logger: typeof crossServerSyncLogger
@ -525,7 +526,8 @@ const loadAllObjectsFromParent = async (
const {
targetStreamId,
sourceCommit,
parsedCommitUrl: { origin, streamId: sourceStreamId }
parsedCommitUrl: { origin, streamId: sourceStreamId },
token
} = params
// Initialize ObjectLoader
@ -533,7 +535,8 @@ const loadAllObjectsFromParent = async (
serverUrl: origin,
streamId: sourceStreamId,
objectId: sourceCommit.referencedObject,
options: { fetch, customLogger: noop }
options: { fetch, customLogger: noop },
token
})
// Iterate over all objects and download them into the DB
@ -645,7 +648,8 @@ export const downloadCommit = async (
{
targetStreamId,
sourceCommit: commit,
parsedCommitUrl
parsedCommitUrl,
token
},
{ logger }
)

Просмотреть файл

@ -131,9 +131,17 @@ const importVersions = async (params: {
localAuthorId: string
origin: string
syncComments?: boolean
token?: string
}) => {
const { logger, projectInfo, origin, localProjectId, syncComments, localAuthorId } =
params
const {
logger,
projectInfo,
origin,
localProjectId,
syncComments,
localAuthorId,
token
} = params
const projectId = projectInfo.projectInfo.id
logger.debug(`Serially downloading ${projectInfo.versions.length} versions...`)
@ -156,7 +164,8 @@ const importVersions = async (params: {
commitUrl: url.toString(),
targetStreamId: localProjectId,
commentAuthorId: syncComments ? localAuthorId : undefined,
branchName
branchName,
token
},
{ logger }
)
@ -177,19 +186,23 @@ export const downloadProject = async (
*/
authorId: string
syncComments?: boolean
/**
* Specify if target project is private
*/
token?: string
},
options?: Partial<{
logger: Logger
}>
) => {
const { projectUrl, authorId, syncComments } = params
const { projectUrl, authorId, syncComments, token } = params
const { logger = crossServerSyncLogger } = options || {}
logger.info(`Project download started at: ${new Date().toISOString()}`)
const localResources = await getLocalResources({ authorId })
const parsedUrl = parseIncomingUrl(projectUrl)
const client = await createApolloClient(parsedUrl.origin)
const client = await createApolloClient(parsedUrl.origin, { token })
logger.debug(`Resolving project metadata and associated versions...`)
const projectInfo = await getProjectMetadata({
@ -209,7 +222,8 @@ export const downloadProject = async (
localProjectId: project.id,
localAuthorId: localResources.user.id,
origin: parsedUrl.origin,
syncComments
syncComments,
token
})
logger.info(`Project download completed at: ${new Date().toISOString()}`)

Просмотреть файл

@ -120,6 +120,7 @@ exports.init = (app, isInitial) => {
res.set('X-Preview-Error-Code', previewBufferOrFile.errorCode)
}
if (previewBufferOrFile.type === 'file') {
res.set('Cache-Control', 'public, max-age=604800')
res.sendFile(previewBufferOrFile.file)
} else {
res.contentType('image/png')

Просмотреть файл

@ -50,8 +50,17 @@ export const authMiddlewareCreator = (steps: AuthPipelineFunction[]) => {
return middleware
}
export const getTokenFromRequest = (req: Request | null | undefined): string | null =>
req?.headers?.authorization ?? null
export const getTokenFromRequest = (req: Request | null | undefined): string | null => {
const removeBearerPrefix = (token: string) => token.replace('Bearer ', '')
const fromHeader = req?.headers?.authorization || null
if (fromHeader?.length) return removeBearerPrefix(fromHeader)
const fromCookie = (req?.cookies?.authn as Nullable<string>) || null
if (fromCookie?.length) return removeBearerPrefix(fromCookie)
return null
}
/**
* Create an AuthContext from a raw token value

Просмотреть файл

@ -53,6 +53,7 @@
"busboy": "^1.4.0",
"compression": "^1.7.4",
"connect-redis": "^6.1.1",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"cross-fetch": "^3.1.5",
"crypto-random-string": "^3.2.0",
@ -107,7 +108,7 @@
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@apollo/rover": "^0.14.1",
"@apollo/rover": "^0.23.0",
"@bull-board/express": "^4.2.2",
"@faker-js/faker": "^7.1.0",
"@graphql-codegen/cli": "^2.16.3",
@ -120,6 +121,7 @@
"@types/bcrypt": "^5.0.0",
"@types/bull": "^3.15.9",
"@types/compression": "^1.7.2",
"@types/cookie-parser": "^1.4.7",
"@types/debug": "^4.1.7",
"@types/deep-equal-in-any-order": "^1.0.1",
"@types/ejs": "^3.1.1",

Просмотреть файл

@ -616,6 +616,15 @@ export type CommitsMoveInput = {
targetBranch: Scalars['String'];
};
/**
* Can be used instead of a full item collection, when the implementation doesn't call for it yet. Because
* of the structure, it can be swapped out to a full item collection in the future
*/
export type CountOnlyCollection = {
__typename?: 'CountOnlyCollection';
totalCount: Scalars['Int'];
};
export type CreateCommentInput = {
content: CommentContentInput;
projectId: Scalars['String'];
@ -2650,6 +2659,13 @@ export type User = {
/** Total amount of favorites attached to streams owned by the user */
totalOwnedStreamsFavorites: Scalars['Int'];
verified?: Maybe<Scalars['Boolean']>;
/**
* Get (count of) user's versions. By default gets all versions of all projects the user has access to.
* Set authoredOnly=true to only retrieve versions authored by the user.
*
* Note: Only count resolution is currently implemented
*/
versions: CountOnlyCollection;
};
@ -2718,6 +2734,16 @@ export type UserTimelineArgs = {
limit?: Scalars['Int'];
};
/**
* Full user type, should only be used in the context of admin operations or
* when a user is reading/writing info about himself
*/
export type UserVersionsArgs = {
authoredOnly?: Scalars['Boolean'];
limit?: Scalars['Int'];
};
export type UserDeleteInput = {
email: Scalars['String'];
};

Просмотреть файл

@ -0,0 +1,24 @@
import { isSafari } from './os'
const shouldPolyfillIdleCallback = isSafari() || !globalThis.requestIdleCallback
/**
* requestIdleCallback w/ proper polyfills
*/
export const requestIdleCallback: typeof globalThis.requestIdleCallback =
shouldPolyfillIdleCallback
? function (cb: IdleRequestCallback) {
const start = Date.now()
return setTimeout(function () {
cb({
didTimeout: false,
timeRemaining() {
return Math.max(0, 50 - (Date.now() - start))
}
})
}, 1) as unknown as number // Timer is actually a number at the end, just w/ extra bits on top of it
}
: globalThis.requestIdleCallback
export const cancelIdleCallback: typeof globalThis.cancelIdleCallback =
shouldPolyfillIdleCallback ? clearTimeout : globalThis.cancelIdleCallback

Просмотреть файл

@ -73,3 +73,15 @@ export function getClientOperatingSystem() {
const userAgentPlatform = resolveOsFromUserAgent()
return userAgentPlatform || OperatingSystem.Other
}
/**
* Check if user is in Safari browser
*/
export function isSafari() {
if (!globalThis || !globalThis.navigator || !('userAgent' in globalThis.navigator)) {
return false
}
const userAgent = globalThis.navigator.userAgent
return /^((?!chrome|android).)*safari/i.test(userAgent)
}

Просмотреть файл

@ -3,12 +3,49 @@ import type { MaybeAsync } from './utilityTypes'
import { ensureError } from './error'
export class TimeoutError extends Error {}
export class WaitIntervalUntilCanceledError extends Error {}
/**
* Build promise that can be resolved/rejected manually outside of the promise's execution scope
*/
export const buildManualPromise = <T>() => {
let resolve: (value: T) => void
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let reject: (reason?: any) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
const resolveWrapper: typeof resolve = (...args) => resolve(...args)
const rejectWrapper: typeof reject = (...args) => reject(...args)
return { promise, resolve: resolveWrapper, reject: rejectWrapper }
}
export const isNullOrUndefined = (val: unknown): val is null | undefined =>
isNull(val) || isUndefined(val)
export const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
export const waitIntervalUntil = (ms: number, predicate: () => boolean) => {
const { promise, resolve, reject } = buildManualPromise<void>()
const interval = setInterval(() => {
if (predicate()) {
clearInterval(interval)
resolve()
}
}, ms)
const ret = promise as typeof promise & { cancel: () => void }
ret.cancel = () => {
clearInterval(interval)
reject(new WaitIntervalUntilCanceledError())
}
return ret
}
/**
* Not nullable type guard, useful in `.filter()` calls for proper TS typed
* results

Просмотреть файл

@ -8,3 +8,4 @@ export * from './helpers/tracking'
export * from './utils/localStorage'
export * from './utils/md5'
export * from './helpers/os'
export * from './helpers/optimization'

Просмотреть файл

@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { MaybeAsync } from '@speckle/shared'
import { type MaybeAsync, buildManualPromise } from '@speckle/shared'
import { computedAsync } from '@vueuse/core'
import type { AsyncComputedOptions } from '@vueuse/core'
import { computed } from 'vue'
@ -66,19 +66,4 @@ export function writableAsyncComputed<T>(
return getter
}
/**
* Build promise that can be resolved/rejected manually outside of the promise's execution scope
*/
export const buildManualPromise = <T>() => {
let resolve: (value: T) => void
let reject: (reason?: any) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
const resolveWrapper: typeof resolve = (...args) => resolve(...args)
const rejectWrapper: typeof reject = (...args) => reject(...args)
return { promise, resolve: resolveWrapper, reject: rejectWrapper }
}
export { buildManualPromise }

Просмотреть файл

@ -1,6 +1,6 @@
ARG NODE_ENV=production
FROM node:18-bookworm-slim@sha256:246bf34b0c7cf8d9ff7cbe0c1ff44b178051f06c432c8e7df1645f1bd20b0352 as build-stage
FROM node:18-bookworm-slim@sha256:a7423cbf419ccea2723be0af141b663b643c30bea56d19bf2e8fe171e904fde9 as build-stage
ARG NODE_ENV
ENV NODE_ENV=${NODE_ENV}
@ -32,7 +32,7 @@ ENV TINI_VERSION=${TINI_VERSION}
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini ./tini
RUN chmod +x ./tini
FROM node:18-bookworm-slim@sha256:246bf34b0c7cf8d9ff7cbe0c1ff44b178051f06c432c8e7df1645f1bd20b0352 as dependency-stage
FROM node:18-bookworm-slim@sha256:a7423cbf419ccea2723be0af141b663b643c30bea56d19bf2e8fe171e904fde9 as dependency-stage
# yarn install
ARG NODE_ENV
ENV NODE_ENV=${NODE_ENV}
@ -50,7 +50,7 @@ COPY packages/shared/package.json ./packages/shared/
WORKDIR /speckle-server/packages/webhook-service
RUN yarn workspaces focus --production
FROM gcr.io/distroless/nodejs18-debian12:nonroot@sha256:00c21305bf7dacba81dbe9ae503ddfe34703a986a61246dacb198e425311cd84 as production-stage
FROM gcr.io/distroless/nodejs18-debian12:nonroot@sha256:7b32127ea43d86b7a5b8e0d86dfe59146f25517ca15e6223046b5a72de36119b as production-stage
ARG NODE_ENV
ENV NODE_ENV=${NODE_ENV}

Просмотреть файл

@ -125,6 +125,10 @@ spec:
- name: NUXT_PUBLIC_DATADOG_ENV
value: {{ .Values.analytics.datadog_env | quote }}
{{- end }}
{{- if .Values.analytics.survicate_workspace_key }}
- name: NUXT_PUBLIC_SURVICATE_WORKSPACE_KEY
value: {{ .Values.analytics.survicate_workspace_key | quote }}
{{- end }}
priorityClassName: high-priority
{{- if .Values.frontend_2.affinity }}

Просмотреть файл

@ -145,17 +145,18 @@ __metadata:
languageName: node
linkType: hard
"@apollo/rover@npm:^0.14.1":
version: 0.14.1
resolution: "@apollo/rover@npm:0.14.1"
"@apollo/rover@npm:^0.23.0":
version: 0.23.0
resolution: "@apollo/rover@npm:0.23.0"
dependencies:
axios: ^1.6.5
axios-proxy-builder: ^0.1.1
binary-install: ^1.0.6
console.table: ^0.10.0
detect-libc: ^2.0.0
tar: ^6.2.0
bin:
rover: run.js
checksum: e797e6490606b1e20cfd5333e13c460815b42d05e64e03c54158615d8d6bb9580d7b28f79eb3e99f79e57354aaf7065da396755204615ee119584f68858a246b
checksum: 01e00b1435a7d10173e1f6ff6ec45acff41702404c7406eaba55b605e37d6c59ff65d5e50357cad068e52c6ef0a634afc85e068588957d55459fdf8389e547c0
languageName: node
linkType: hard
@ -13987,6 +13988,7 @@ __metadata:
"@speckle/ui-components": "workspace:^"
"@speckle/ui-components-nuxt": "workspace:^"
"@speckle/viewer": "workspace:^"
"@survicate/survicate-web-surveys-wrapper": ^1.2.1
"@tailwindcss/forms": ^0.5.3
"@tailwindcss/line-clamp": ^0.4.2
"@testing-library/vue": ^6.6.1
@ -14238,7 +14240,7 @@ __metadata:
resolution: "@speckle/server@workspace:packages/server"
dependencies:
"@apollo/client": ^3.7.0
"@apollo/rover": ^0.14.1
"@apollo/rover": ^0.23.0
"@aws-sdk/client-s3": ^3.276.0
"@aws-sdk/lib-storage": ^3.100.0
"@bull-board/express": ^4.2.2
@ -14260,6 +14262,7 @@ __metadata:
"@types/bcrypt": ^5.0.0
"@types/bull": ^3.15.9
"@types/compression": ^1.7.2
"@types/cookie-parser": ^1.4.7
"@types/debug": ^4.1.7
"@types/deep-equal-in-any-order": ^1.0.1
"@types/ejs": ^3.1.1
@ -14293,6 +14296,7 @@ __metadata:
compression: ^1.7.4
concurrently: ^7.0.0
connect-redis: ^6.1.1
cookie-parser: ^1.4.6
cors: ^2.8.5
cross-env: ^7.0.3
cross-fetch: ^3.1.5
@ -15713,6 +15717,13 @@ __metadata:
languageName: node
linkType: hard
"@survicate/survicate-web-surveys-wrapper@npm:^1.2.1":
version: 1.2.1
resolution: "@survicate/survicate-web-surveys-wrapper@npm:1.2.1"
checksum: 6becd74381174dc2c880cb30a7384981d8d49ebc74e332312d2281c0624866d2ddcf7773ee7be4e4a562094cc0ed0db36a3cce6de0da46d64b37e17750bc5ad4
languageName: node
linkType: hard
"@swc/core-android-arm-eabi@npm:1.2.222":
version: 1.2.222
resolution: "@swc/core-android-arm-eabi@npm:1.2.222"
@ -16813,6 +16824,15 @@ __metadata:
languageName: node
linkType: hard
"@types/cookie-parser@npm:^1.4.7":
version: 1.4.7
resolution: "@types/cookie-parser@npm:1.4.7"
dependencies:
"@types/express": "*"
checksum: 7b87c59420598e686a57e240be6e0db53967c3c8814be9326bf86609ee2fc39c4b3b9f2263e1deba43526090121d1df88684b64c19f7b494a80a4437caf3d40b
languageName: node
linkType: hard
"@types/cookiejar@npm:*":
version: 2.1.2
resolution: "@types/cookiejar@npm:2.1.2"
@ -21738,17 +21758,6 @@ __metadata:
languageName: node
linkType: hard
"binary-install@npm:^1.0.6":
version: 1.1.0
resolution: "binary-install@npm:1.1.0"
dependencies:
axios: ^0.26.1
rimraf: ^3.0.2
tar: ^6.1.11
checksum: 271344b49f42460f5e3ec29d681cd4b749aaf9592040d49f6ae86d267b997b5fd094fd7a710df4d477fc299a51833b8cb94206595db6c46edea3f1603266a0d2
languageName: node
linkType: hard
"bindings@npm:^1.4.0":
version: 1.5.0
resolution: "bindings@npm:1.5.0"
@ -23825,7 +23834,7 @@ __metadata:
languageName: node
linkType: hard
"cookie-parser@npm:~1.4.4":
"cookie-parser@npm:^1.4.6, cookie-parser@npm:~1.4.4":
version: 1.4.6
resolution: "cookie-parser@npm:1.4.6"
dependencies:
@ -45510,9 +45519,9 @@ __metadata:
linkType: hard
"ufo@npm:^1.0.0, ufo@npm:^1.0.1, ufo@npm:^1.1.0, ufo@npm:^1.1.1, ufo@npm:^1.1.2, ufo@npm:^1.2.0, ufo@npm:^1.3.0, ufo@npm:^1.3.2, ufo@npm:^1.4.0, ufo@npm:^1.5.0, ufo@npm:^1.5.2":
version: 1.5.2
resolution: "ufo@npm:1.5.2"
checksum: f0bdc651d4ca2eae02b6f11b628582ac58b7986a01d784ca21fffb52166b5180c794b5f8229fe20c9b5c55a7c899baa33e56ddccd3920adc2cb1d387cea00e1b
version: 1.5.3
resolution: "ufo@npm:1.5.3"
checksum: 2f54fa543b2e689cc4ab341fe2194937afe37c5ee43cd782e6ecc184e36859e84d4197a43ae4cd6e9a56f793ca7c5b950dfff3f16fadaeef9b6b88b05c88c8ef
languageName: node
linkType: hard