Versions panel

This commit is contained in:
andrewwallacespeckle
2025-07-29 12:52:50 +02:00
parent 000bb1742b
commit 85d8b1fba8
6 changed files with 230 additions and 188 deletions
@@ -10,24 +10,9 @@
Versions
</FormButton>
</div>
<div
v-tippy="removeEnabled ? 'Remove model' : 'You cannot remove the last model'"
class="flex"
>
<FormButton
color="subtle"
:disabled="!removeEnabled"
:icon-left="IconMinus"
hide-text
@click="$emit('remove')"
>
{{ showRemove ? 'Done' : 'Remove' }}
</FormButton>
</div>
<div v-tippy="'Add model'" class="flex">
<FormButton
color="subtle"
:disabled="showRemove"
hide-text
:icon-left="IconPlus"
@click="$emit('addModel')"
@@ -39,20 +24,11 @@
</template>
<script setup lang="ts">
interface Props {
removeEnabled: boolean
showRemove: boolean
}
defineProps<Props>()
defineEmits<{
showVersions: []
remove: []
addModel: []
}>()
const IconPlus = resolveComponent('IconPlus')
const IconVersions = resolveComponent('IconVersions')
const IconMinus = resolveComponent('IconMinus')
</script>
@@ -2,7 +2,6 @@
<template>
<div class="relative border-b border-outline-3">
<div
:class="showRemove ? 'pointer-events-none' : ''"
@mouseenter="highlightObject"
@mouseleave="unhighlightObject"
@focusin="highlightObject"
@@ -13,14 +12,9 @@
<!-- Model Header -->
<div
class="group flex items-center px-1 py-3 select-none cursor-pointer hover:bg-highlight-1"
:class="isExpanded && !showRemove ? 'border-b border-outline-3' : ''"
:class="isExpanded ? 'border-b border-outline-3' : ''"
>
<FormButton
v-if="!showRemove"
size="sm"
color="subtle"
@click.stop="isExpanded = !isExpanded"
>
<FormButton size="sm" color="subtle" @click.stop="isExpanded = !isExpanded">
<IconTriangle
class="w-4 h-4 -ml-1.5 -mr-1.5 text-foreground-2"
:class="isExpanded ? 'rotate-90' : ''"
@@ -29,7 +23,6 @@
{{ isExpanded ? 'Collapse' : 'Expand' }}
</span>
</FormButton>
<div v-else class="w-2" />
<div class="h-12 w-12 rounded-md overflow-hidden border border-outline-3 mr-3">
<NuxtImg
:src="loadedVersion?.previewUrl"
@@ -52,6 +45,22 @@
</div>
<div class="flex items-center gap-2 ml-auto">
<div class="flex text-foreground">
<LayoutMenu
v-model:open="showActionsMenu"
:items="actionsItems"
:menu-position="HorizontalDirection.Left"
mount-menu-on-body
@click.stop.prevent
@chosen="onActionChosen"
>
<FormButton
color="subtle"
class="group-hover:opacity-100 opacity-0"
hide-text
:icon-right="EllipsisHorizontalIcon"
@click="showActionsMenu = !showActionsMenu"
/>
</LayoutMenu>
<FormButton
color="subtle"
class="group-hover:opacity-100"
@@ -84,7 +93,7 @@
<!-- Scene Explorer Content -->
<div
v-if="isExpanded && rootNodeChildren.length && !showRemove"
v-if="isExpanded && rootNodeChildren.length"
class="relative flex flex-col gap-y-2"
>
<div v-for="(childNode, idx) in rootNodeChildren" :key="idx" class="rounded-xl">
@@ -98,41 +107,32 @@
</div>
</div>
</div>
<!-- Remove Overlay -->
<div
v-if="showRemove"
class="absolute top-0 right-2 h-full z-10 flex items-center justify-end"
>
<FormButton
color="danger"
size="sm"
hide-text
:icon-left="XMarkIcon"
@click="$emit('remove', props.model.id)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import dayjs from 'dayjs'
import { XMarkIcon, FunnelIcon } from '@heroicons/vue/24/solid'
import { FunnelIcon, EllipsisHorizontalIcon } from '@heroicons/vue/24/solid'
import { FunnelIcon as FunnelIconOutline } from '@heroicons/vue/24/outline'
import type { ViewerLoadedResourcesQuery } from '~~/lib/common/generated/gql/graphql'
import type { Get } from 'type-fest'
import type { ExplorerNode } from '~~/lib/viewer/helpers/sceneExplorer'
import type { LayoutMenuItem } from '~~/lib/layout/helpers/components'
import { HorizontalDirection } from '~~/lib/common/composables/window'
import {
useHighlightedObjectsUtilities,
useFilterUtilities,
useSelectionUtilities
} from '~~/lib/viewer/composables/ui'
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
import {
useInjectedViewerState,
useInjectedViewerRequestedResources
} from '~~/lib/viewer/composables/setup'
import { containsAll } from '~~/lib/common/helpers/utils'
type ModelItem = NonNullable<Get<ViewerLoadedResourcesQuery, 'project.models.items[0]'>>
defineEmits<{
const emit = defineEmits<{
(e: 'remove', val: string): void
(e: 'expanded', depth: number): void
}>()
@@ -140,7 +140,6 @@ defineEmits<{
const props = defineProps<{
model: ModelItem
versionId: string
showRemove: boolean
last: boolean
expandLevel: number
manualExpandLevel: number
@@ -151,6 +150,7 @@ const { highlightObjects, unhighlightObjects } = useHighlightedObjectsUtilities(
const { hideObjects, showObjects, isolateObjects, unIsolateObjects } =
useFilterUtilities()
const { setSelectionFromObjectIds } = useSelectionUtilities()
const { items } = useInjectedViewerRequestedResources()
const {
viewer: {
metadata: { filteringState }
@@ -158,10 +158,24 @@ const {
} = useInjectedViewerState()
const isExpanded = ref(false)
const showActionsMenu = ref(false)
const IconEye = resolveComponent('IconEye')
const IconEyeClosed = resolveComponent('IconEyeClosed')
const removeEnabled = computed(() => items.value.length > 1)
const actionsItems = computed<LayoutMenuItem[][]>(() => [
[
{
title: 'Remove model',
id: 'remove-model',
disabled: !removeEnabled.value,
disabledTooltip: 'You cannot remove the last model'
}
]
])
const rootNodeChildren = computed(() => {
const children: ExplorerNode[] = []
for (const rootNode of props.rootNodes) {
@@ -272,4 +286,16 @@ const selectObject = () => {
if (modelObjectIds.value.length === 0) return
setSelectionFromObjectIds(modelObjectIds.value)
}
const onActionChosen = (params: { item: LayoutMenuItem }) => {
const { item } = params
switch (item.id) {
case 'remove-model':
if (removeEnabled.value) {
emit('remove', props.model.id)
}
break
}
}
</script>
@@ -1,27 +1,14 @@
<template>
<div>
<div class="select-none">
<ViewerModelsVersions v-if="showVersions" @close="showVersions = false" />
<ViewerModelsAddPanel v-else-if="showAddModel" @close="showAddModel = false" />
<ViewerLayoutSidePanel v-else>
<template #title>
<FormButton
v-if="showRemove"
:icon-left="ChevronLeftIcon"
color="subtle"
class="-ml-3"
@click="showRemove = false"
>
Exit
</FormButton>
<span v-else>Models</span>
<span>Models</span>
</template>
<template #actions>
<ViewerModelsActions
v-if="!showRemove"
:remove-enabled="removeEnabled"
:show-remove="showRemove"
@show-versions="showVersions = true"
@remove="handleRemove()"
@add-model="showAddModel = true"
/>
</template>
@@ -37,7 +24,6 @@
:model="model"
:version-id="versionId"
:last="index === modelsAndVersionIds.length - 1"
:show-remove="showRemove"
:expand-level="expandLevel"
:manual-expand-level="manualExpandLevel"
:root-nodes="getRootNodesForModel(model.id)"
@@ -50,7 +36,7 @@
v-for="object in objects"
:key="object.objectId"
:object="object"
:show-remove="showRemove"
:show-remove="false"
@remove="(id: string) => removeModel(id)"
/>
</template>
@@ -84,11 +70,9 @@ import { ViewerEvent } from '@speckle/viewer'
import { useViewerEventListener } from '~~/lib/viewer/composables/viewer'
import type { ExplorerNode } from '~~/lib/viewer/helpers/sceneExplorer'
import { sortBy, flatten } from 'lodash-es'
import { ChevronLeftIcon } from '@heroicons/vue/24/solid'
defineEmits(['close'])
const showRemove = ref(false)
const showVersions = ref(false)
const showAddModel = ref(false)
const { resourceItems, modelsAndVersionIds, objects } =
@@ -109,8 +93,6 @@ const showRaw = ref(false)
const mp = useMixpanel()
const removeEnabled = computed(() => items.value.length > 1)
const removeModel = async (modelId: string) => {
// Convert requested resource string to references to specific models
// to ensure remove works even when we have "all" or "$folder" in the URL
@@ -194,12 +176,4 @@ const getRootNodesForModel = (modelId: string) => {
return nodes
}
const handleRemove = () => {
showRemove.value = true
}
watch(modelsAndVersionIds, (newVal) => {
if (newVal.length <= 1) showRemove.value = false
})
</script>
@@ -1,9 +1,12 @@
<!-- eslint-disable vuejs-accessibility/click-events-have-key-events -->
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
<template>
<div class="relative border-b border-outline-3">
<div
class="relative border-b border-outline-3"
:class="showVersions ? 'bg-foundation-page' : 'bg-foundation hover:bg-highlight-1'"
>
<div
:class="showVersions ? 'max-h-96 shadow-md' : ''"
:class="showVersions ? 'max-h-96' : ''"
@mouseenter="highlightObject"
@mouseleave="unhighlightObject"
@focusin="highlightObject"
@@ -11,77 +14,40 @@
>
<!-- Model Header -->
<div
class="group sticky cursor-pointer top-0 z-20 flex min-w-0 max-w-full items-center justify-between gap-3 pl-1 pr-4 py-2 select-none"
:class="showVersions ? 'bg-primary' : 'bg-foundation hover:bg-foundation-2'"
class="group sticky cursor-pointer flex items-center gap-3 py-2 px-1"
@click="showVersions = !showVersions"
>
<div class="h-12 w-12 rounded-md overflow-hidden border border-outline-3">
<NuxtImg
:src="loadedVersion?.previewUrl"
class="object-cover h-full w-full"
<PreviewImage
v-if="loadedVersion?.previewUrl"
:preview-url="loadedVersion?.previewUrl"
/>
</div>
<div class="flex min-w-0 flex-grow flex-col">
<div
<div class="flex flex-col">
<span
v-tippy="modelName.subheader ? model.name : null"
:class="`${
showVersions ? 'text-foundation' : ''
} text-body-xs truncate min-w-0`"
class="text-foreground text-body-2xs font-medium"
>
{{ modelName.header }}
</div>
<div class="truncate -mt-1.5">
<span
v-tippy="createdAtFormatted.full"
:class="`${
showVersions ? 'text-foundation' : 'text-foreground-2'
} text-body-3xs`"
>
{{ isLatest ? 'Latest version' : createdAtFormatted.relative }}
</span>
</div>
</span>
<span v-if="isLatest" class="text-body-3xs text-foreground">
Latest version
</span>
<span
v-tippy="createdAtFormatted.full"
class="text-body-3xs text-foreground-2"
>
{{ createdAtFormatted.relative }}
</span>
</div>
<span v-if="!showVersions" class="text-foreground-2 text-body-2xs font-medium">
<span class="text-foreground-2 text-body-3xs font-medium ml-auto pr-3">
{{ model.versions?.totalCount }}
</span>
<div
v-else
:class="`${
showVersions ? 'text-white' : ''
} flex flex-none items-center space-x-2 text-xs font-medium opacity-80 transition-opacity group-hover:opacity-100`"
>
<ChevronUpIcon class="h-4 w-4" />
</div>
</div>
<!-- Active Version Card (when expanded but no scene data) -->
<ViewerModelsActiveVersionCard
v-if="loadedVersion && showVersions"
:version="loadedVersion"
/>
</div>
<!-- Remove Overlay -->
<Transition>
<div
v-if="showRemove"
class="to-foundation group absolute inset-0 z-[21] flex h-full w-full items-center justify-end space-x-2 rounded bg-gradient-to-r from-blue-500/0 p-4"
>
<FormButton
color="danger"
size="sm"
hide-text
:icon-left="XMarkIcon"
@click="$emit('remove', props.model.id)"
/>
</div>
</Transition>
<!-- Version List (when expanded and not in remove mode) -->
<div
v-show="showVersions && !showRemove"
class="mt-2 ml-4 flex h-auto flex-col space-y-0"
>
<!-- Version List -->
<div v-show="showVersions" class="mt-2 ml-4 flex h-auto flex-col space-y-0">
<ViewerResourcesVersionCard
v-for="(version, index) in props.model.versions.items"
:key="version.id"
@@ -92,22 +58,39 @@
:last="index === props.model.versions.totalCount - 1"
:last-loaded="index === props.model.versions.items.length - 1"
:clickable="version.id !== loadedVersion?.id"
:total-versions="props.model.versions.totalCount"
@change-version="handleVersionChange"
@view-changes="handleViewChanges"
@remove-version="handleRemoveVersion"
/>
<div class="mt-4 px-2 py-2">
<FormButton full-width text :disabled="!showLoadMore" @click="onLoadMore">
<div class="mt-4 pr-2 py-2 -ml-3">
<FormButton
full-width
text
color="subtle"
:disabled="!showLoadMore"
@click="onLoadMore"
>
{{ showLoadMore ? 'View older versions' : 'No more versions' }}
</FormButton>
</div>
</div>
<!-- Version Delete Dialog -->
<ProjectModelPageDialogDelete
v-if="project?.id"
v-model:open="showDeleteDialog"
:project-id="project.id"
:model-id="model.id"
:versions="versionsToDelete"
@deleted="onVersionDeleted"
/>
</div>
</template>
<script setup lang="ts">
import dayjs from 'dayjs'
import { graphql } from '~~/lib/common/generated/gql'
import { XMarkIcon, ChevronUpIcon } from '@heroicons/vue/24/solid'
import type {
ViewerLoadedResourcesQuery,
ViewerModelVersionCardItemFragment
@@ -115,7 +98,8 @@ import type {
import type { Get } from 'type-fest'
import {
useInjectedViewerLoadedResources,
useInjectedViewerRequestedResources
useInjectedViewerRequestedResources,
useInjectedViewerState
} from '~~/lib/viewer/composables/setup'
import {
useDiffUtilities,
@@ -124,14 +108,9 @@ import {
type ModelItem = NonNullable<Get<ViewerLoadedResourcesQuery, 'project.models.items[0]'>>
defineEmits<{
(e: 'remove', val: string): void
}>()
const props = defineProps<{
model: ModelItem
versionId: string
showRemove: boolean
last: boolean
}>()
@@ -139,8 +118,15 @@ const { switchModelToVersion } = useInjectedViewerRequestedResources()
const { loadMoreVersions } = useInjectedViewerLoadedResources()
const { diffModelVersions } = useDiffUtilities()
const { highlightObjects, unhighlightObjects } = useHighlightedObjectsUtilities()
const {
resources: {
response: { project }
}
} = useInjectedViewerState()
const showVersions = ref(false)
const showDeleteDialog = ref(false)
const versionsToDelete = ref<{ id: string; message?: string | null }[]>([])
graphql(`
fragment ViewerModelVersionCardItem on Version {
@@ -229,4 +215,26 @@ const unhighlightObject = () => {
const refObject = props.model.loadedVersion.items[0]?.referencedObject
if (refObject) unhighlightObjects([refObject])
}
const handleRemoveVersion = (versionId: string) => {
// Find the version to delete
const versionToDelete = versions.value.find((v) => v.id === versionId)
if (versionToDelete) {
versionsToDelete.value = [
{ id: versionToDelete.id, message: versionToDelete.message }
]
showDeleteDialog.value = true
}
}
const onVersionDeleted = () => {
// Refresh the versions list after successful deletion
loadMoreVersions(props.model.id)
}
watch(showDeleteDialog, (isOpen) => {
if (!isOpen) {
versionsToDelete.value = []
}
})
</script>
@@ -21,8 +21,6 @@
:model="model"
:version-id="versionId"
:last="index === modelsAndVersionIds.length - 1"
:show-remove="false"
@remove="(id: string) => removeModel(id)"
/>
</div>
<template v-if="objects.length !== 0">
@@ -1,43 +1,41 @@
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
<template>
<div
:class="`group relative block w-full space-y-2 rounded-md pb-2 text-left ${
class="group relative w-full rounded-md pb-2 text-left"
:class="
clickable && !isLimited
? 'hover:bg-primary-muted cursor-pointer'
: 'cursor-default'
}
${isLoaded ? 'bg-highlight-3' : 'bg-highlight-1'}
`"
"
@click="handleClick"
@keypress="keyboardClick(handleClick)"
>
<!-- Timeline left border -->
<div
v-if="showTimeline"
:class="`absolute top-3 ml-[2px] h-[99%] w-1 ${
isLoaded
? 'border-primary border-r-4 border'
: 'border-dashed border-outline-3 border-r-2'
} group-hover:border-primary left-[7px] z-10`"
></div>
<div
v-if="last"
class="bg-primary absolute -bottom-5 ml-2 h-2 w-2 rounded-sm"
></div>
<div
v-if="lastLoaded && !last"
class="bg-primary absolute -bottom-6 z-10 ml-[4px] flex h-4 w-4 items-center justify-center rounded-full text-foreground-on-primary"
class="absolute top-3 ml-[2px] h-[99%] w-1 border-l border-outline-3 group-hover:border-primary left-0 z-10"
>
<ChevronDownIcon class="h-3 w-3" />
</div>
<div class="flex items-center gap-1 pl-1">
<div class="z-20 -ml-2">
<UserAvatar :user="author" />
<div
v-if="isLoaded"
class="absolute -top-1.5 -left-2 flex items-center justify-center h-4 w-4 bg-foundation-2 rounded-full"
>
<IconCheck class="h-4 w-4 text-foreground" />
</div>
<div
v-else
class="absolute top-0 -left-[2px] h-[3px] w-[3px] bg-foreground rounded-full"
/>
<div
v-if="last"
class="absolute bottom-0 -left-[2px] h-[3px] w-[3px] bg-foreground rounded-full"
/>
</div>
<div class="flex items-center gap-1 pl-1">
<div
v-show="showTimeline"
v-tippy="createdAt.full"
class="bg-foundation-focus inline-block rounded-full px-2 text-body-xs font-medium shrink-0"
class="rounded-full px-2 text-body-xs font-medium ml-2.5"
>
<span>
{{ isLatest ? 'Latest' : createdAt.relative }}
@@ -54,14 +52,30 @@
>
View changes
</FormButton>
<FormButton v-else size="sm" text class="cursor-not-allowed">
Currently viewing
</FormButton>
<CommonBadge v-else rounded>Viewing</CommonBadge>
<LayoutMenu
v-model:open="showActionsMenu"
class="ml-auto mr-2"
:items="actionsItems"
:menu-position="HorizontalDirection.Left"
mount-menu-on-body
@click.stop.prevent
@chosen="onActionChosen"
>
<FormButton
color="subtle"
class="opacity-0 group-hover:opacity-100"
hide-text
size="sm"
:icon-right="EllipsisHorizontalIcon"
@click.stop="showActionsMenu = !showActionsMenu"
/>
</LayoutMenu>
</div>
<!-- Main stuff -->
<div class="flex items-center space-x-1 pl-5">
<div
class="bg-foundation h-16 w-16 flex-shrink-0 rounded-md border border-outline-3"
class="bg-foundation h-12 w-12 flex-shrink-0 rounded-md border border-outline-3"
:class="isLimited ? 'diagonal-stripes' : ''"
>
<div v-if="isLimited" class="flex items-center justify-center w-full h-full">
@@ -81,27 +95,28 @@
variant="inline"
:project="project"
/>
<div v-else class="truncate text-xs">
{{ version.message || 'no message' }}
<div v-else class="truncate">
<div v-if="author" class="text-body-2xs">
{{ author.name }}
</div>
<div class="text-body-3xs text-foreground-2">
{{ version.message || 'no message' }}
</div>
</div>
</div>
<div
v-if="!isLimited"
class="text-primary inline-block rounded-full pl-1 text-xs font-medium"
>
{{ version.sourceApplication }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ChevronDownIcon, LockClosedIcon } from '@heroicons/vue/24/solid'
import { keyboardClick } from '@speckle/ui-components'
import { LockClosedIcon, EllipsisHorizontalIcon } from '@heroicons/vue/24/solid'
import { CommonBadge, keyboardClick } from '@speckle/ui-components'
import dayjs from 'dayjs'
import localizedFormat from 'dayjs/plugin/localizedFormat'
import { useInjectedViewerState } from '~/lib/viewer/composables/setup'
import type { ViewerModelVersionCardItemFragment } from '~~/lib/common/generated/gql/graphql'
import type { LayoutMenuItem } from '~~/lib/layout/helpers/components'
import { HorizontalDirection } from '~~/lib/common/composables/window'
import { useMixpanel } from '~~/lib/core/composables/mp'
dayjs.extend(localizedFormat)
@@ -115,6 +130,8 @@ const props = withDefaults(
showTimeline?: boolean
last: boolean
lastLoaded: boolean
modelId?: string
totalVersions?: number
}>(),
{
clickable: true,
@@ -128,6 +145,7 @@ const props = withDefaults(
const emit = defineEmits<{
(e: 'changeVersion', version: string): void
(e: 'viewChanges', version: ViewerModelVersionCardItemFragment): void
(e: 'removeVersion', versionId: string): void
}>()
const mp = useMixpanel()
@@ -154,6 +172,36 @@ const createdAt = computed(() => {
const author = computed(() => props.version.authorUser)
const IconCheck = resolveComponent('IconCheck')
const showActionsMenu = ref(false)
const canDeleteVersion = computed(() => {
if (isLoaded.value) return false
if (props.totalVersions && props.totalVersions <= 1) return false
return true
})
const deleteDisabledReason = computed(() => {
if (isLoaded.value) {
return 'Cannot delete the currently viewed version'
}
if (props.totalVersions && props.totalVersions <= 1) {
return 'Cannot delete the last version'
}
return undefined
})
const actionsItems = computed<LayoutMenuItem[][]>(() => [
[
{
title: 'Remove version',
id: 'remove-version',
disabled: !canDeleteVersion.value,
disabledTooltip: deleteDisabledReason.value
}
]
])
const handleClick = () => {
if (isLimited.value) return
if (props.clickable) emit('changeVersion', props.version.id)
@@ -171,4 +219,16 @@ const handleViewChanges = () => {
action: 'enable'
})
}
const onActionChosen = (params: { item: LayoutMenuItem }) => {
const { item } = params
switch (item.id) {
case 'remove-version':
if (canDeleteVersion.value) {
emit('removeVersion', props.version.id)
}
break
}
}
</script>