Feat: Rename and Delete for imported Models ☁️ (#6969)

## Summary

Add Rename and Delete options for Personal Models.

Also updates and standardizes some styles for Cards and adds a simple
Confirmation dialog.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6969-WIP-Feat-Rename-and-Delete-for-custom-Models-2b86d73d36508140a687e929b0544ae6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Alexander Brown
2025-12-01 17:16:05 -08:00
committed by GitHub
parent 072f1f6ced
commit 04158deb02
21 changed files with 457 additions and 162 deletions

View File

@@ -1,17 +1,15 @@
<template> <template>
<div :class="iconGroupClasses"> <div
:class="
cn(
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg bg-secondary-background shadow-sm transition-all duration-200 cursor-pointer'
)
"
>
<slot></slot> <slot></slot>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
const iconGroupClasses = cn(
'flex justify-center items-center shrink-0',
'outline-hidden border-none p-0 rounded-lg',
'bg-secondary-background shadow-sm',
'transition-all duration-200',
'cursor-pointer'
)
</script> </script>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="relative inline-flex items-center"> <div class="relative inline-flex items-center">
<IconButton :size="size" :type="type" @click="toggle"> <IconButton :size="size" :type="type" @click="popover?.toggle">
<i v-if="!isVertical" class="icon-[lucide--ellipsis] text-sm" /> <i v-if="!isVertical" class="icon-[lucide--ellipsis] text-sm" />
<i v-else class="icon-[lucide--more-vertical] text-sm" /> <i v-else class="icon-[lucide--more-vertical] text-sm" />
</IconButton> </IconButton>
@@ -25,8 +25,18 @@
) )
} }
}" }"
@show="$emit('menuOpened')" @show="
@hide="$emit('menuClosed')" () => {
isOpen = true
$emit('menuOpened')
}
"
@hide="
() => {
isOpen = false
$emit('menuClosed')
}
"
> >
<div class="flex min-w-40 flex-col gap-2 p-2"> <div class="flex min-w-40 flex-col gap-2 p-2">
<slot :close="hide" /> <slot :close="hide" />
@@ -48,8 +58,6 @@ interface MoreButtonProps extends BaseButtonProps {
isVertical?: boolean isVertical?: boolean
} }
const popover = ref<InstanceType<typeof Popover>>()
const { const {
size = 'md', size = 'md',
type = 'secondary', type = 'secondary',
@@ -61,15 +69,15 @@ defineEmits<{
menuClosed: [] menuClosed: []
}>() }>()
const toggle = (event: Event) => { const isOpen = ref(false)
popover.value?.toggle(event) const popover = ref<InstanceType<typeof Popover>>()
}
const hide = () => { function hide() {
popover.value?.hide() popover.value?.hide()
} }
defineExpose({ defineExpose({
hide hide,
isOpen
}) })
</script> </script>

View File

@@ -18,8 +18,8 @@
...inputAttrs ...inputAttrs
} }
}" }"
@keyup.enter="blurInputElement" @keyup.enter.capture.stop="blurInputElement"
@keyup.escape="cancelEditing" @keyup.escape.stop="cancelEditing"
@click.stop @click.stop
@pointerdown.stop.capture @pointerdown.stop.capture
@pointermove.stop.capture @pointermove.stop.capture
@@ -38,7 +38,7 @@ const {
} = defineProps<{ } = defineProps<{
modelValue: string modelValue: string
isEditing?: boolean isEditing?: boolean
inputAttrs?: Record<string, any> inputAttrs?: Record<string, string>
}>() }>()
const emit = defineEmits(['update:modelValue', 'edit', 'cancel']) const emit = defineEmits(['update:modelValue', 'edit', 'cancel'])

View File

@@ -14,6 +14,7 @@
<component <component
:is="item.headerComponent" :is="item.headerComponent"
v-if="item.headerComponent" v-if="item.headerComponent"
v-bind="item.headerProps"
:id="item.key" :id="item.key"
/> />
<h3 v-else :id="item.key"> <h3 v-else :id="item.key">

View File

@@ -0,0 +1,19 @@
<template>
<div
class="flex flex-col px-4 py-2 text-sm text-muted-foreground border-t border-border-default"
>
<p v-if="promptTextReal">
{{ promptTextReal }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
const { promptText } = defineProps<{
promptText?: MaybeRefOrGetter<string>
}>()
const promptTextReal = computed(() => toValue(promptText))
</script>

View File

@@ -0,0 +1,43 @@
<template>
<section class="w-full flex gap-2 justify-end px-2 pb-2">
<TextButton
:label="cancelTextX"
:disabled
type="transparent"
autofocus
@click="$emit('cancel')"
/>
<TextButton
:label="confirmTextX"
:disabled
type="transparent"
:class="confirmClass"
@click="$emit('confirm')"
/>
</section>
</template>
<script setup lang="ts">
import { computed, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { useI18n } from 'vue-i18n'
import TextButton from '@/components/button/TextButton.vue'
const { t } = useI18n()
const { cancelText, confirmText, confirmClass, optionsDisabled } = defineProps<{
cancelText?: string
confirmText?: string
confirmClass?: string
optionsDisabled?: MaybeRefOrGetter<boolean>
}>()
defineEmits<{
cancel: []
confirm: []
}>()
const confirmTextX = computed(() => confirmText || t('g.confirm'))
const cancelTextX = computed(() => cancelText || t('g.cancel'))
const disabled = computed(() => toValue(optionsDisabled))
</script>

View File

@@ -0,0 +1,12 @@
<template>
<div
class="flex items-center gap-2 p-4 font-bold text-sm text-base-foreground font-inter"
>
<span v-if="title" class="flex-auto">{{ title }}</span>
</div>
</template>
<script setup lang="ts">
defineProps<{
title?: string
}>()
</script>

View File

@@ -0,0 +1,31 @@
import ConfirmBody from '@/components/dialog/confirm/ConfirmBody.vue'
import ConfirmFooter from '@/components/dialog/confirm/ConfirmFooter.vue'
import ConfirmHeader from '@/components/dialog/confirm/ConfirmHeader.vue'
import { useDialogStore } from '@/stores/dialogStore'
import type { ComponentAttrs } from 'vue-component-type-helpers'
interface ConfirmDialogOptions {
headerProps?: ComponentAttrs<typeof ConfirmHeader>
props?: ComponentAttrs<typeof ConfirmBody>
footerProps?: ComponentAttrs<typeof ConfirmFooter>
}
export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
const dialogStore = useDialogStore()
const { headerProps, props, footerProps } = options
return dialogStore.showDialog({
headerComponent: ConfirmHeader,
component: ConfirmBody,
footerComponent: ConfirmFooter,
headerProps,
props,
footerProps,
dialogComponentProps: {
pt: {
header: 'py-0! px-0!',
content: 'p-0!',
footer: 'p-0!'
}
}
})
}

View File

@@ -9,7 +9,8 @@ export enum ServerFeatureFlag {
SUPPORTS_PREVIEW_METADATA = 'supports_preview_metadata', SUPPORTS_PREVIEW_METADATA = 'supports_preview_metadata',
MAX_UPLOAD_SIZE = 'max_upload_size', MAX_UPLOAD_SIZE = 'max_upload_size',
MANAGER_SUPPORTS_V4 = 'extension.manager.supports_v4', MANAGER_SUPPORTS_V4 = 'extension.manager.supports_v4',
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled' MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',
ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled'
} }
/** /**
@@ -31,6 +32,12 @@ export function useFeatureFlags() {
ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED, ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED,
false false
) )
},
get assetUpdateOptionsEnabled() {
return api.getServerFeature(
ServerFeatureFlag.ASSET_UPDATE_OPTIONS_ENABLED,
false
)
} }
}) })

View File

@@ -163,6 +163,7 @@ export const i18n = createI18n({
legacy: false, legacy: false,
locale: navigator.language.split('-')[0] || 'en', locale: navigator.language.split('-')[0] || 'en',
fallbackLocale: 'en', fallbackLocale: 'en',
escapeParameter: true,
messages, messages,
// Ignore warnings for locale options as each option is in its own language. // Ignore warnings for locale options as each option is in its own language.
// e.g. "English", "中文", "Русский", "日本語", "한국어", "Français", "Español" // e.g. "English", "中文", "Русский", "日本語", "한국어", "Français", "Español"

View File

@@ -1,6 +1,5 @@
{ {
"g": { "g": {
"beta": "Beta",
"user": "User", "user": "User",
"currentUser": "Current user", "currentUser": "Current user",
"empty": "Empty", "empty": "Empty",
@@ -125,6 +124,7 @@
"searchKeybindings": "Search Keybindings", "searchKeybindings": "Search Keybindings",
"searchExtensions": "Search Extensions", "searchExtensions": "Search Extensions",
"search": "Search", "search": "Search",
"searchPlaceholder": "Search...",
"noResultsFound": "No Results Found", "noResultsFound": "No Results Found",
"searchFailedMessage": "We couldn't find any settings matching your search. Try adjusting your search terms.", "searchFailedMessage": "We couldn't find any settings matching your search. Try adjusting your search terms.",
"noTasksFound": "No Tasks Found", "noTasksFound": "No Tasks Found",
@@ -2093,7 +2093,6 @@
"connectionError": "Please check your connection and try again", "connectionError": "Please check your connection and try again",
"failedToCreateNode": "Failed to create node. Please try again or check console for details.", "failedToCreateNode": "Failed to create node. Please try again or check console for details.",
"noModelsInFolder": "No {type} available in this folder", "noModelsInFolder": "No {type} available in this folder",
"searchAssetsPlaceholder": "Type to search...",
"uploadModel": "Import model", "uploadModel": "Import model",
"uploadModelFromCivitai": "Import a model from Civitai", "uploadModelFromCivitai": "Import a model from Civitai",
"uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.", "uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
@@ -2152,6 +2151,16 @@
"media": { "media": {
"threeDModelPlaceholder": "3D Model", "threeDModelPlaceholder": "3D Model",
"audioPlaceholder": "Audio" "audioPlaceholder": "Audio"
},
"deletion": {
"header": "Delete this model?",
"body": "This model will be permanently removed from your library.",
"inProgress": "Deleting {assetName}...",
"complete": "{assetName} has been deleted.",
"failed": "{assetName} could not be deleted."
},
"rename": {
"failed": "Could not rename asset."
} }
}, },
"mediaAsset": { "mediaAsset": {

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="absolute right-2 bottom-2 flex flex-wrap justify-end gap-1"> <div class="absolute left-2 bottom-2 flex flex-wrap justify-start gap-1">
<span <span
v-for="badge in badges" v-for="badge in badges"
:key="badge.label" :key="badge.label"

View File

@@ -26,7 +26,7 @@
v-model="searchQuery" v-model="searchQuery"
:autofocus="true" :autofocus="true"
size="lg" size="lg"
:placeholder="$t('assetBrowser.searchAssetsPlaceholder')" :placeholder="$t('g.searchPlaceholder')"
class="max-w-96" class="max-w-96"
/> />
<IconTextButton <IconTextButton

View File

@@ -1,53 +1,106 @@
<template> <template>
<component <div
:is="interactive ? 'button' : 'div'" v-if="!deletedLocal"
data-component-id="AssetCard" data-component-id="AssetCard"
:data-asset-id="asset.id" :data-asset-id="asset.id"
v-bind="elementProps" :aria-labelledby="titleId"
:class="cardClasses" :aria-describedby="descId"
@click="interactive && $emit('select', asset)" :tabindex="interactive ? 0 : -1"
@keydown.enter="interactive && $emit('select', asset)" :class="
cn(
'rounded-2xl overflow-hidden transition-all duration-200 bg-modal-card-background p-2 gap-2 flex flex-col h-full',
interactive &&
'group appearance-none bg-transparent m-0 outline-none text-left hover:bg-secondary-background focus:bg-secondary-background border-none focus:outline-solid outline-base-foreground outline-4'
)
"
@keydown.enter.self="interactive && $emit('select', asset)"
> >
<div class="relative aspect-square w-full overflow-hidden rounded-xl"> <div class="relative aspect-square w-full overflow-hidden rounded-xl">
<img
v-if="shouldShowImage"
:src="asset.preview_url"
class="h-full w-full object-contain"
/>
<div <div
v-if="isLoading || error"
class="flex size-full cursor-pointer items-center justify-center bg-gradient-to-br from-smoke-400 via-smoke-800 to-charcoal-400"
role="button"
@click.self="interactive && $emit('select', asset)"
/>
<img
v-else v-else
class="flex h-full w-full items-center justify-center bg-gradient-to-br from-smoke-400 via-smoke-800 to-charcoal-400" :src="asset.preview_url"
></div> :alt="displayName"
class="size-full object-contain cursor-pointer"
role="button"
@click.self="interactive && $emit('select', asset)"
/>
<AssetBadgeGroup :badges="asset.badges" /> <AssetBadgeGroup :badges="asset.badges" />
<IconGroup
v-if="flags.assetUpdateOptionsEnabled"
:class="
cn(
'absolute top-2 right-2 invisible group-hover:visible',
dropdownMenuButton?.isOpen && 'visible'
)
"
>
<IconButton v-if="false" size="sm">
<i class="icon-[lucide--file-text]" />
</IconButton>
<MoreButton ref="dropdown-menu-button" size="sm">
<template #default>
<IconTextButton
:label="$t('g.rename')"
type="secondary"
size="md"
@click="startAssetRename"
>
<template #icon>
<i class="icon-[lucide--pencil]" />
</template>
</IconTextButton>
<IconTextButton
:label="$t('g.delete')"
type="secondary"
size="md"
@click="confirmDeletion"
>
<template #icon>
<i class="icon-[lucide--trash-2]" />
</template>
</IconTextButton>
</template>
</MoreButton>
</IconGroup>
</div> </div>
<div :class="cn('p-4 h-32 flex flex-col justify-between')"> <div class="max-h-32 flex flex-col gap-2 justify-between flex-auto">
<div> <h3
<h3 :id="titleId"
:id="titleId" v-tooltip.top="{ value: displayName, showDelay: tooltipDelay }"
v-tooltip.top="{ value: asset.name, showDelay: tooltipDelay }" :class="
:class=" cn(
cn( 'mb-2 m-0 text-base font-semibold line-clamp-2 wrap-anywhere',
'mb-2 m-0 text-base font-semibold line-clamp-2 wrap-anywhere', 'text-base-foreground'
'text-base-foreground' )
) "
" >
> <EditableText
{{ asset.name }} :model-value="displayName"
</h3> :is-editing="isEditing"
<p :input-attrs="{ 'data-testid': 'asset-name-input' }"
:id="descId" @edit="assetRename"
v-tooltip.top="{ value: asset.description, showDelay: tooltipDelay }" @cancel="assetRename()"
:class=" />
cn( </h3>
'm-0 text-sm leading-6 overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box]', <p
'text-muted-foreground' :id="descId"
) v-tooltip.top="{ value: asset.description, showDelay: tooltipDelay }"
" :class="
> cn(
{{ asset.description }} 'm-0 text-sm leading-6 overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box] text-muted-foreground'
</p> )
</div> "
<div :class="cn('flex gap-4 text-xs text-muted-foreground')"> >
{{ asset.description }}
</p>
<div class="flex gap-4 text-xs text-muted-foreground mt-auto">
<span v-if="asset.stats.stars" class="flex items-center gap-1"> <span v-if="asset.stats.stars" class="flex items-center gap-1">
<i class="icon-[lucide--star] size-3" /> <i class="icon-[lucide--star] size-3" />
{{ asset.stats.stars }} {{ asset.stats.stars }}
@@ -62,73 +115,141 @@
</span> </span>
</div> </div>
</div> </div>
</component> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useImage } from '@vueuse/core' import { useImage } from '@vueuse/core'
import { computed, useId } from 'vue' import { computed, ref, toValue, useId, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconGroup from '@/components/button/IconGroup.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import MoreButton from '@/components/button/MoreButton.vue'
import EditableText from '@/components/common/EditableText.vue'
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue' import AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser' import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { assetService } from '@/platform/assets/services/assetService'
import { useSettingStore } from '@/platform/settings/settingStore' import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
const props = defineProps<{ const { asset, interactive } = defineProps<{
asset: AssetDisplayItem asset: AssetDisplayItem
interactive?: boolean interactive?: boolean
}>() }>()
defineEmits<{
select: [asset: AssetDisplayItem]
}>()
const { t } = useI18n()
const settingStore = useSettingStore() const settingStore = useSettingStore()
const { closeDialog } = useDialogStore()
const { flags } = useFeatureFlags()
const toastStore = useToastStore()
const dropdownMenuButton = useTemplateRef<InstanceType<typeof MoreButton>>(
'dropdown-menu-button'
)
const titleId = useId() const titleId = useId()
const descId = useId() const descId = useId()
const isEditing = ref(false)
const newNameRef = ref<string>()
const deletedLocal = ref(false)
const displayName = computed(() => newNameRef.value ?? asset.name)
const tooltipDelay = computed<number>(() => const tooltipDelay = computed<number>(() =>
settingStore.get('LiteGraph.Node.TooltipDelay') settingStore.get('LiteGraph.Node.TooltipDelay')
) )
const { error } = useImage({ const { isLoading, error } = useImage({
src: props.asset.preview_url ?? '', src: asset.preview_url ?? '',
alt: props.asset.name alt: asset.name
}) })
const shouldShowImage = computed(() => props.asset.preview_url && !error.value) function confirmDeletion() {
dropdownMenuButton.value?.hide()
const assetName = toValue(displayName)
const promptText = ref<string>(t('assetBrowser.deletion.body'))
const optionsDisabled = ref(false)
const confirmDialog = showConfirmDialog({
headerProps: {
title: t('assetBrowser.deletion.header')
},
props: {
promptText
},
footerProps: {
confirmText: t('g.delete'),
// TODO: These need to be put into the new Button Variants once we have them.
confirmClass: cn(
'bg-danger-200 text-base-foreground hover:bg-danger-200/80 focus:bg-danger-200/80 focus:ring ring-base-foreground'
),
optionsDisabled,
onCancel: () => {
closeDialog(confirmDialog)
},
onConfirm: async () => {
optionsDisabled.value = true
try {
promptText.value = t('assetBrowser.deletion.inProgress', {
assetName
})
await assetService.deleteAsset(asset.id)
promptText.value = t('assetBrowser.deletion.complete', {
assetName
})
// Give a second for the completion message
await new Promise((resolve) => setTimeout(resolve, 1_000))
deletedLocal.value = true
} catch (err: unknown) {
console.error(err)
promptText.value = t('assetBrowser.deletion.failed', {
assetName
})
// Give a second for the completion message
await new Promise((resolve) => setTimeout(resolve, 3_000))
} finally {
closeDialog(confirmDialog)
}
}
}
})
}
const cardClasses = computed(() => { function startAssetRename() {
const base = cn( dropdownMenuButton.value?.hide()
'rounded-xl overflow-hidden transition-all duration-200 bg-modal-card-background' isEditing.value = true
) }
if (!props.interactive) { async function assetRename(newName?: string) {
return base isEditing.value = false
if (newName) {
// Optimistic update
newNameRef.value = newName
try {
const result = await assetService.updateAsset(asset.id, {
name: newName
})
// Update with the actual name once the server responds
newNameRef.value = result.name
} catch (err: unknown) {
console.error(err)
toastStore.add({
severity: 'error',
summary: t('assetBrowser.rename.failed'),
life: 10_000
})
newNameRef.value = undefined
}
} }
}
return cn(
base,
'group',
'appearance-none bg-transparent p-0 m-0',
'font-inherit text-inherit outline-none cursor-pointer text-left',
'hover:bg-secondary-background',
'border-none',
'focus:outline-solid outline-azure-600 outline-4'
)
})
const elementProps = computed(() =>
props.interactive
? {
type: 'button',
'aria-labelledby': titleId,
'aria-describedby': descId
}
: {
'aria-labelledby': titleId,
'aria-describedby': descId
}
)
defineEmits<{
select: [asset: AssetDisplayItem]
}>()
</script> </script>

View File

@@ -1,7 +1,9 @@
<template> <template>
<div <div
data-component-id="AssetGrid" data-component-id="AssetGrid"
:style="gridStyle" :class="
cn('grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-4 p-2')
"
role="grid" role="grid"
:aria-label="$t('assetBrowser.assetCollection')" :aria-label="$t('assetBrowser.assetCollection')"
:aria-rowcount="-1" :aria-rowcount="-1"
@@ -34,7 +36,6 @@
:key="asset.id" :key="asset.id"
:asset="asset" :asset="asset"
:interactive="true" :interactive="true"
role="gridcell"
@select="$emit('assetSelect', $event)" @select="$emit('assetSelect', $event)"
/> />
</template> </template>
@@ -42,11 +43,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import AssetCard from '@/platform/assets/components/AssetCard.vue' import AssetCard from '@/platform/assets/components/AssetCard.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser' import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { createGridStyle } from '@/utils/gridUtil' import { cn } from '@/utils/tailwindUtil'
defineProps<{ defineProps<{
assets: AssetDisplayItem[] assets: AssetDisplayItem[]
@@ -56,7 +55,4 @@ defineProps<{
defineEmits<{ defineEmits<{
assetSelect: [asset: AssetDisplayItem] assetSelect: [asset: AssetDisplayItem]
}>() }>()
// Use same grid style as BaseModalLayout
const gridStyle = computed(() => createGridStyle())
</script> </script>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="upload-model-dialog flex flex-col justify-between gap-6 p-4 pt-6 border-t-[1px] border-border-default" class="upload-model-dialog flex flex-col justify-between gap-6 p-4 pt-6 border-t border-border-default"
> >
<!-- Step 1: Enter URL --> <!-- Step 1: Enter URL -->
<UploadModelUrlInput <UploadModelUrlInput

View File

@@ -1,7 +1,10 @@
import { fromZodError } from 'zod-validation-error' import { fromZodError } from 'zod-validation-error'
import { st } from '@/i18n' import { st } from '@/i18n'
import { assetResponseSchema } from '@/platform/assets/schemas/assetSchema' import {
assetItemSchema,
assetResponseSchema
} from '@/platform/assets/schemas/assetSchema'
import type { import type {
AssetItem, AssetItem,
AssetMetadata, AssetMetadata,
@@ -281,6 +284,43 @@ function createAssetService() {
} }
} }
/**
* Update metadata of an asset by ID
* Only available in cloud environment
*
* @param id - The asset ID (UUID)
* @param newData - The data to update
* @returns Promise<AssetItem>
* @throws Error if update fails
*/
async function updateAsset(
id: string,
newData: Partial<AssetMetadata>
): Promise<AssetItem> {
const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(newData)
})
if (!res.ok) {
throw new Error(
`Unable to update asset ${id}: Server returned ${res.status}`
)
}
const newAsset = assetItemSchema.safeParse(await res.json())
if (newAsset.success) {
return newAsset.data
}
throw new Error(
`Unable to update asset ${id}: Invalid response - ${newAsset.error}`
)
}
/** /**
* Retrieves metadata from a download URL without downloading the file * Retrieves metadata from a download URL without downloading the file
* *
@@ -360,6 +400,7 @@ function createAssetService() {
getAssetDetails, getAssetDetails,
getAssetsByTag, getAssetsByTag,
deleteAsset, deleteAsset,
updateAsset,
getAssetMetadata, getAssetMetadata,
uploadAssetFromUrl uploadAssetFromUrl
} }

View File

@@ -34,7 +34,7 @@ import NodeConflictDialogContent from '@/workbench/extensions/manager/components
import NodeConflictFooter from '@/workbench/extensions/manager/components/manager/NodeConflictFooter.vue' import NodeConflictFooter from '@/workbench/extensions/manager/components/manager/NodeConflictFooter.vue'
import NodeConflictHeader from '@/workbench/extensions/manager/components/manager/NodeConflictHeader.vue' import NodeConflictHeader from '@/workbench/extensions/manager/components/manager/NodeConflictHeader.vue'
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes' import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
import type { ComponentProps } from 'vue-component-type-helpers' import type { ComponentAttrs } from 'vue-component-type-helpers'
export type ConfirmationDialogType = export type ConfirmationDialogType =
| 'default' | 'default'
@@ -48,7 +48,7 @@ export const useDialogService = () => {
const dialogStore = useDialogStore() const dialogStore = useDialogStore()
function showLoadWorkflowWarning( function showLoadWorkflowWarning(
props: ComponentProps<typeof MissingNodesContent> props: ComponentAttrs<typeof MissingNodesContent>
) { ) {
dialogStore.showDialog({ dialogStore.showDialog({
key: 'global-missing-nodes', key: 'global-missing-nodes',
@@ -74,7 +74,7 @@ export const useDialogService = () => {
} }
function showMissingModelsWarning( function showMissingModelsWarning(
props: InstanceType<typeof MissingModelsWarning>['$props'] props: ComponentAttrs<typeof MissingModelsWarning>
) { ) {
dialogStore.showDialog({ dialogStore.showDialog({
key: 'global-missing-models-warning', key: 'global-missing-models-warning',
@@ -115,7 +115,7 @@ export const useDialogService = () => {
} }
function showExecutionErrorDialog(executionError: ExecutionErrorWsMessage) { function showExecutionErrorDialog(executionError: ExecutionErrorWsMessage) {
const props: InstanceType<typeof ErrorDialogContent>['$props'] = { const props: ComponentAttrs<typeof ErrorDialogContent> = {
error: { error: {
exceptionType: executionError.exception_type, exceptionType: executionError.exception_type,
exceptionMessage: executionError.exception_message, exceptionMessage: executionError.exception_message,
@@ -141,7 +141,7 @@ export const useDialogService = () => {
} }
function showManagerDialog( function showManagerDialog(
props: InstanceType<typeof ManagerDialogContent>['$props'] = {} props: ComponentAttrs<typeof ManagerDialogContent> = {}
) { ) {
dialogStore.showDialog({ dialogStore.showDialog({
key: 'global-manager', key: 'global-manager',
@@ -206,7 +206,7 @@ export const useDialogService = () => {
errorMessage: String(error) errorMessage: String(error)
} }
const props: InstanceType<typeof ErrorDialogContent>['$props'] = { const props: ComponentAttrs<typeof ErrorDialogContent> = {
error: { error: {
exceptionType: options.title ?? 'Unknown Error', exceptionType: options.title ?? 'Unknown Error',
exceptionMessage: errorProps.errorMessage, exceptionMessage: errorProps.errorMessage,
@@ -430,7 +430,7 @@ export const useDialogService = () => {
} }
function toggleManagerDialog( function toggleManagerDialog(
props?: InstanceType<typeof ManagerDialogContent>['$props'] props?: ComponentAttrs<typeof ManagerDialogContent>
) { ) {
if (dialogStore.isDialogOpen('global-manager')) { if (dialogStore.isDialogOpen('global-manager')) {
dialogStore.closeDialog({ key: 'global-manager' }) dialogStore.closeDialog({ key: 'global-manager' })
@@ -440,7 +440,7 @@ export const useDialogService = () => {
} }
function toggleManagerProgressDialog( function toggleManagerProgressDialog(
props?: InstanceType<typeof ManagerProgressDialogContent>['$props'] props?: ComponentAttrs<typeof ManagerProgressDialogContent>
) { ) {
if (dialogStore.isDialogOpen('global-manager-progress-dialog')) { if (dialogStore.isDialogOpen('global-manager-progress-dialog')) {
dialogStore.closeDialog({ key: 'global-manager-progress-dialog' }) dialogStore.closeDialog({ key: 'global-manager-progress-dialog' })

View File

@@ -7,6 +7,7 @@ import { markRaw, ref } from 'vue'
import type { Component } from 'vue' import type { Component } from 'vue'
import type GlobalDialog from '@/components/dialog/GlobalDialog.vue' import type GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import type { ComponentAttrs } from 'vue-component-type-helpers'
type DialogPosition = type DialogPosition =
| 'center' | 'center'
@@ -33,30 +34,40 @@ interface CustomDialogComponentProps {
headless?: boolean headless?: boolean
} }
export type DialogComponentProps = InstanceType<typeof GlobalDialog>['$props'] & export type DialogComponentProps = ComponentAttrs<typeof GlobalDialog> &
CustomDialogComponentProps CustomDialogComponentProps
interface DialogInstance { interface DialogInstance<
H extends Component = Component,
B extends Component = Component,
F extends Component = Component
> {
key: string key: string
visible: boolean visible: boolean
title?: string title?: string
headerComponent?: Component headerComponent?: H
component: Component headerProps?: ComponentAttrs<H>
contentProps: Record<string, any> component: B
footerComponent?: Component contentProps: ComponentAttrs<B>
footerProps?: Record<string, any> footerComponent?: F
footerProps?: ComponentAttrs<F>
dialogComponentProps: DialogComponentProps dialogComponentProps: DialogComponentProps
priority: number priority: number
} }
export interface ShowDialogOptions { export interface ShowDialogOptions<
H extends Component = Component,
B extends Component = Component,
F extends Component = Component
> {
key?: string key?: string
title?: string title?: string
headerComponent?: Component headerComponent?: H
footerComponent?: Component footerComponent?: F
component: Component component: B
props?: Record<string, any> props?: ComponentAttrs<B>
footerProps?: Record<string, any> headerProps?: ComponentAttrs<H>
footerProps?: ComponentAttrs<F>
dialogComponentProps?: DialogComponentProps dialogComponentProps?: DialogComponentProps
/** /**
* Optional priority for dialog stacking. * Optional priority for dialog stacking.
@@ -123,17 +134,11 @@ export const useDialogStore = defineStore('dialog', () => {
updateCloseOnEscapeStates() updateCloseOnEscapeStates()
} }
function createDialog(options: { function createDialog<
key: string H extends Component = Component,
title?: string B extends Component = Component,
headerComponent?: Component F extends Component = Component
footerComponent?: Component >(options: ShowDialogOptions<H, B, F> & { key: string }) {
component: Component
props?: Record<string, any>
footerProps?: Record<string, any>
dialogComponentProps?: DialogComponentProps
priority?: number
}) {
if (dialogStack.value.length >= 10) { if (dialogStack.value.length >= 10) {
dialogStack.value.shift() dialogStack.value.shift()
} }
@@ -149,6 +154,7 @@ export const useDialogStore = defineStore('dialog', () => {
? markRaw(options.footerComponent) ? markRaw(options.footerComponent)
: undefined, : undefined,
component: markRaw(options.component), component: markRaw(options.component),
headerProps: { ...options.headerProps },
contentProps: { ...options.props }, contentProps: { ...options.props },
footerProps: { ...options.footerProps }, footerProps: { ...options.footerProps },
priority: options.priority ?? 1, priority: options.priority ?? 1,
@@ -203,7 +209,11 @@ export const useDialogStore = defineStore('dialog', () => {
}) })
} }
function showDialog(options: ShowDialogOptions) { function showDialog<
H extends Component = Component,
B extends Component = Component,
F extends Component = Component
>(options: ShowDialogOptions<H, B, F>) {
const dialogKey = options.key || genDialogKey() const dialogKey = options.key || genDialogKey()
let dialog = dialogStack.value.find((d) => d.key === dialogKey) let dialog = dialogStack.value.find((d) => d.key === dialogKey)

View File

@@ -25,8 +25,7 @@ export const getButtonSizeClasses = (size: ButtonSize = 'md') => {
export const getButtonTypeClasses = (type: ButtonType = 'primary') => { export const getButtonTypeClasses = (type: ButtonType = 'primary') => {
const baseByType = { const baseByType = {
primary: primary: 'bg-base-foreground border-none text-base-background',
'bg-neutral-900 border-none text-white dark-theme:bg-white dark-theme:text-neutral-900',
secondary: cn( secondary: cn(
'bg-secondary-background border-none text-base-foreground hover:bg-secondary-background-hover' 'bg-secondary-background border-none text-base-foreground hover:bg-secondary-background-hover'
), ),
@@ -42,10 +41,8 @@ export const getButtonTypeClasses = (type: ButtonType = 'primary') => {
export const getBorderButtonTypeClasses = (type: ButtonType = 'primary') => { export const getBorderButtonTypeClasses = (type: ButtonType = 'primary') => {
const baseByType = { const baseByType = {
primary: primary: 'bg-base-background text-base-foreground',
'bg-neutral-900 text-white dark-theme:bg-white dark-theme:text-neutral-900', secondary: 'bg-secondary-background text-base-foreground',
secondary:
'bg-white text-neutral-950 dark-theme:bg-zinc-700 dark-theme:text-white',
transparent: cn( transparent: cn(
'bg-transparent text-base-foreground hover:bg-secondary-background-hover' 'bg-transparent text-base-foreground hover:bg-secondary-background-hover'
), ),
@@ -54,10 +51,9 @@ export const getBorderButtonTypeClasses = (type: ButtonType = 'primary') => {
} as const } as const
const borderByType = { const borderByType = {
primary: 'border border-solid border-white dark-theme:border-neutral-900', primary: 'border border-solid border-base-background',
secondary: 'border border-solid border-neutral-950 dark-theme:border-white', secondary: 'border border-solid border-base-foreground',
transparent: transparent: 'border border-solid border-base-foreground',
'border border-solid border-neutral-950 dark-theme:border-white',
accent: 'border border-solid border-primary-background' accent: 'border border-solid border-primary-background'
} as const } as const

View File

@@ -14,6 +14,8 @@ interface GridOptions {
} }
/** /**
* @deprecated Just use tailwind utilities directly.
* TODO: Create a common grid layout component if needed.
* Creates CSS grid styles for responsive grid layouts * Creates CSS grid styles for responsive grid layouts
* @param options Grid configuration options * @param options Grid configuration options
* @returns CSS properties object for grid styling * @returns CSS properties object for grid styling