Merge branch 'main' into iain/circleci-test-viewer
This commit is contained in:
Коммит
00cf704509
|
@ -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}
|
||||
|
||||
|
|
Двоичный файл не отображается.
После Ширина: | Высота: | Размер: 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 }}
|
||||
|
|
51
yarn.lock
51
yarn.lock
|
@ -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
|
||||
|
||||
|
|
Загрузка…
Ссылка в новой задаче