feat: add upload dropzone, library select, and 2-step confirm to missing media

- Upload dropzone with drag & drop, file type validation, event propagation guard
- Use from Library dropdown with image/video thumbnail previews
- 2-step confirm flow: select/upload → status card → checkmark to apply
- Single node shows node display name; multiple nodes show filename with count
- Extract MIME type constants to shared mediaUploadUtil.ts (single source)
- Locate button visible only for single-node items
This commit is contained in:
jaeone94
2026-03-19 22:16:27 +09:00
parent 39352c857b
commit 5ed88ebd39
7 changed files with 663 additions and 47 deletions

View File

@@ -3528,11 +3528,18 @@
"downloadAll": "Download all"
},
"missingMedia": {
"missingMediaTitle": "Missing Media",
"missingMediaTitle": "Missing Inputs",
"image": "Images",
"video": "Videos",
"audio": "Audio",
"locateNode": "Locate node"
"locateNode": "Locate node",
"expandNodes": "Show referencing nodes",
"collapseNodes": "Hide referencing nodes",
"uploadFile": "Upload {type}",
"uploading": "Uploading...",
"uploaded": "Uploaded",
"selectedFromLibrary": "Selected from library",
"useFromLibrary": "Use from Library"
}
},
"errorOverlay": {

View File

@@ -0,0 +1,145 @@
<template>
<div class="flex flex-col gap-2">
<div v-if="showDivider" class="flex items-center justify-center py-0.5">
<span class="text-xs font-bold text-muted-foreground">
{{ t('rightSidePanel.missingModels.or') }}
</span>
</div>
<Select
:model-value="modelValue"
:disabled="options.length === 0"
@update:model-value="handleSelect"
>
<SelectTrigger
size="md"
class="border-transparent bg-secondary-background text-xs hover:border-interface-stroke"
>
<SelectValue
:placeholder="t('rightSidePanel.missingMedia.useFromLibrary')"
/>
</SelectTrigger>
<SelectContent class="max-h-72">
<template v-if="options.length > 4" #prepend>
<div class="px-1 pb-1.5">
<div
class="flex items-center gap-1.5 rounded-md border border-border-default px-2"
>
<i
aria-hidden="true"
class="icon-[lucide--search] size-3.5 shrink-0 text-muted-foreground"
/>
<input
v-model="filterQuery"
type="text"
:aria-label="t('g.searchPlaceholder', { subject: '' })"
class="h-7 w-full border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground"
:placeholder="t('g.searchPlaceholder', { subject: '' })"
@keydown.stop
/>
</div>
</div>
</template>
<SelectItem
v-for="option in filteredOptions"
:key="option.value"
:value="option.value"
class="text-xs"
>
<div class="flex items-center gap-2">
<img
v-if="mediaType === 'image'"
:src="getPreviewUrl(option.value)"
:alt="option.name"
class="size-8 shrink-0 rounded-sm object-cover"
loading="lazy"
/>
<video
v-else-if="mediaType === 'video'"
:src="getPreviewUrl(option.value)"
class="size-8 shrink-0 rounded-sm object-cover"
preload="metadata"
muted
/>
<i
v-else
aria-hidden="true"
class="icon-[lucide--music] size-5 shrink-0 text-muted-foreground"
/>
<span class="min-w-0 truncate">{{ option.name }}</span>
</div>
</SelectItem>
<div
v-if="filteredOptions.length === 0"
role="status"
class="px-3 py-2 text-xs text-muted-foreground"
>
{{ t('g.noResultsFound') }}
</div>
</SelectContent>
</Select>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFuse } from '@vueuse/integrations/useFuse'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import type { MediaType } from '@/platform/missingMedia/types'
import { api } from '@/scripts/api'
const {
options,
showDivider = false,
mediaType
} = defineProps<{
modelValue: string | undefined
options: { name: string; value: string }[]
showDivider?: boolean
mediaType: MediaType
}>()
const emit = defineEmits<{
select: [value: string]
}>()
const { t } = useI18n()
const filterQuery = ref('')
watch(
() => options.length,
(len) => {
if (len <= 4) filterQuery.value = ''
}
)
const { results: fuseResults } = useFuse(filterQuery, () => options, {
fuseOptions: {
keys: ['name'],
threshold: 0.4,
ignoreLocation: true
},
matchAllWhenSearchEmpty: true
})
const filteredOptions = computed(() => fuseResults.value.map((r) => r.item))
function getPreviewUrl(filename: string): string {
return api.apiURL(`/view?filename=${encodeURIComponent(filename)}&type=input`)
}
function handleSelect(value: unknown) {
if (typeof value === 'string') {
filterQuery.value = ''
emit('select', value)
}
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex w-full flex-col pb-2">
<div class="flex w-full flex-col pb-3">
<!-- File header -->
<div class="flex h-8 w-full items-center gap-2">
<i
@@ -7,7 +7,25 @@
class="text-foreground icon-[lucide--file] size-4 shrink-0"
/>
<!-- Single node: show node display name instead of filename -->
<template v-if="isSingleNode">
<span
v-if="showNodeIdBadge"
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
>
#{{ item.referencingNodes[0].nodeId }}
</span>
<p
class="text-foreground min-w-0 flex-1 truncate text-sm font-medium"
:title="singleNodeLabel"
>
{{ singleNodeLabel }}
</p>
</template>
<!-- Multiple nodes: show filename with count -->
<p
v-else
class="text-foreground min-w-0 flex-1 truncate text-sm font-medium"
:title="item.name"
>
@@ -15,62 +33,210 @@
({{ item.referencingNodes.length }})
</p>
<!-- Confirm button (visible when pending selection exists) -->
<Button
v-if="item.referencingNodes.length > 0"
variant="textonly"
size="icon-sm"
:disabled="!isPending"
:class="
cn(
'size-8 shrink-0 rounded-lg transition-colors',
isPending ? 'bg-primary/10 hover:bg-primary/15' : 'opacity-20'
)
"
@click="confirmSelection(item.name)"
>
<i
aria-hidden="true"
class="icon-[lucide--check] size-4"
:class="isPending ? 'text-primary' : 'text-foreground'"
/>
</Button>
<!-- Locate button (single node only) -->
<Button
v-if="isSingleNode"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingMedia.locateNode')"
class="mr-1 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('locateNode', String(item.referencingNodes[0].nodeId))"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
</Button>
<!-- Expand button (multiple nodes only) -->
<Button
v-if="!isSingleNode"
variant="textonly"
size="icon-sm"
:aria-label="
expanded
? t('rightSidePanel.missingMedia.collapseNodes')
: t('rightSidePanel.missingMedia.expandNodes')
"
:aria-expanded="expanded"
:class="
cn(
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-180'
)
"
@click="toggleExpand(item.name)"
>
<i
aria-hidden="true"
class="icon-[lucide--chevron-down] size-4 text-muted-foreground"
/>
</Button>
</div>
<!-- Referencing nodes (when multiple) -->
<div
v-if="item.referencingNodes.length > 1"
class="flex flex-col gap-0.5 overflow-hidden pl-6"
>
<!-- Referencing nodes (expandable) -->
<TransitionCollapse>
<div
v-for="ref in item.referencingNodes"
:key="`${String(ref.nodeId)}::${ref.widgetName}`"
class="flex h-7 items-center"
v-if="expanded && item.referencingNodes.length > 1"
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-6"
>
<span
v-if="showNodeIdBadge"
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
<div
v-for="ref in item.referencingNodes"
:key="`${String(ref.nodeId)}::${ref.widgetName}`"
class="flex h-7 items-center"
>
#{{ ref.nodeId }}
</span>
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{{ getNodeDisplayLabel(ref.nodeId) }}
</p>
<Button
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingMedia.locateNode')"
class="mr-1 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('locateNode', String(ref.nodeId))"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
</Button>
<span
v-if="showNodeIdBadge"
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
>
#{{ ref.nodeId }}
</span>
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{{ getNodeDisplayLabel(String(ref.nodeId), item.name) }}
</p>
<Button
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingMedia.locateNode')"
class="mr-1 size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('locateNode', String(ref.nodeId))"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
</Button>
</div>
</div>
</div>
</TransitionCollapse>
<!-- Status card (pending selection: upload complete or library select) -->
<TransitionCollapse>
<div
v-if="isPending"
class="bg-foreground/5 relative mt-1 overflow-hidden rounded-lg border border-interface-stroke p-2"
>
<div class="relative z-10 flex items-center gap-2">
<div class="flex size-8 shrink-0 items-center justify-center">
<i
v-if="currentUpload?.status === 'uploading'"
aria-hidden="true"
class="icon-[lucide--loader-circle] size-5 animate-spin text-muted-foreground"
/>
<i
v-else
aria-hidden="true"
class="icon-[lucide--file-check] size-5 text-muted-foreground"
/>
</div>
<div class="flex min-w-0 flex-1 flex-col justify-center">
<span class="text-foreground truncate text-xs/tight font-medium">
{{ pendingDisplayName }}
</span>
<span class="mt-0.5 text-xs/tight text-muted-foreground">
<template v-if="currentUpload?.status === 'uploading'">
{{ t('rightSidePanel.missingMedia.uploading') }}
</template>
<template v-else-if="currentUpload?.status === 'uploaded'">
{{ t('rightSidePanel.missingMedia.uploaded') }}
</template>
<template v-else>
{{ t('rightSidePanel.missingMedia.selectedFromLibrary') }}
</template>
</span>
</div>
<Button
variant="textonly"
size="icon-sm"
class="relative z-10 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="cancelSelection(item.name)"
>
<i aria-hidden="true" class="icon-[lucide--circle-x] size-4" />
</Button>
</div>
</div>
</TransitionCollapse>
<!-- Upload + Library (when no pending selection) -->
<TransitionCollapse>
<div v-if="!isPending && !isUploading" class="mt-1 flex flex-col gap-1">
<!-- Upload dropzone -->
<div class="flex w-full flex-col py-1">
<button
type="button"
:class="
cn(
'flex w-full cursor-pointer items-center justify-center rounded-lg border border-dashed border-component-node-border bg-transparent px-3 py-2 text-xs text-muted-foreground transition-colors hover:border-base-foreground hover:text-base-foreground',
isDragOver && 'border-primary text-primary'
)
"
@click="openFilePicker"
@dragover.prevent.stop="isDragOver = true"
@dragleave.prevent.stop="isDragOver = false"
@drop.prevent.stop="handleDrop"
>
{{
t('rightSidePanel.missingMedia.uploadFile', {
type: extensionHint
})
}}
</button>
</div>
<!-- OR separator + Use from Library -->
<MissingMediaLibrarySelect
:model-value="undefined"
:options="libraryOptions"
:show-divider="true"
:media-type="item.mediaType"
@select="handleLibrarySelect(item.name, $event)"
/>
</div>
</TransitionCollapse>
<!-- Hidden file input -->
<input
ref="fileInputRef"
type="file"
class="hidden"
:accept="acceptType"
@change="handleFileInputChange"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { cn } from '@/utils/tailwindUtil'
import Button from '@/components/ui/button/Button.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import MissingMediaLibrarySelect from '@/platform/missingMedia/components/MissingMediaLibrarySelect.vue'
import type { MissingMediaViewModel } from '@/platform/missingMedia/types'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { app } from '@/scripts/app'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { st } from '@/i18n'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import {
useMissingMediaInteractions,
getNodeDisplayLabel
} from '@/platform/missingMedia/composables/useMissingMediaInteractions'
defineProps<{
const { item, showNodeIdBadge } = defineProps<{
item: MissingMediaViewModel
showNodeIdBadge: boolean
}>()
@@ -81,12 +247,72 @@ const emit = defineEmits<{
const { t } = useI18n()
function getNodeDisplayLabel(nodeId: NodeId): string {
const graphNode = getNodeByExecutionId(app.rootGraph, String(nodeId))
return resolveNodeDisplayName(graphNode, {
emptyLabel: '',
untitledLabel: '',
st
})
const store = useMissingMediaStore()
const { uploadState, pendingSelection } = storeToRefs(store)
const {
isExpanded,
toggleExpand,
getAcceptType,
getExtensionHint,
getLibraryOptions,
handleLibrarySelect,
handleUpload,
confirmSelection,
cancelSelection,
hasPendingSelection
} = useMissingMediaInteractions()
const isSingleNode = computed(() => item.referencingNodes.length === 1)
const singleNodeLabel = computed(() => {
if (!isSingleNode.value) return ''
const ref = item.referencingNodes[0]
return getNodeDisplayLabel(String(ref.nodeId), item.name)
})
const expanded = computed(() => isExpanded(item.name))
const acceptType = computed(() => getAcceptType(item.mediaType))
const libraryOptions = computed(() => {
const candidates = store.missingMediaCandidates
if (!candidates?.length) return []
const candidate = candidates.find((c) => c.name === item.name)
if (!candidate) return []
return getLibraryOptions(candidate)
})
const isPending = computed(() => hasPendingSelection(item.name))
const isUploading = computed(
() => uploadState.value[item.name]?.status === 'uploading'
)
const currentUpload = computed(() => uploadState.value[item.name])
const pendingDisplayName = computed(() => {
if (currentUpload.value) return currentUpload.value.fileName
return pendingSelection.value[item.name] ?? ''
})
const extensionHint = computed(() => getExtensionHint(item.mediaType))
const isDragOver = ref(false)
const fileInputRef = ref<HTMLInputElement | null>(null)
function openFilePicker() {
fileInputRef.value?.click()
}
function handleFileInputChange(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (file) {
handleUpload(file, item.name, item.mediaType)
}
input.value = ''
}
function handleDrop(e: DragEvent) {
isDragOver.value = false
const file = e.dataTransfer?.files[0]
if (file) {
handleUpload(file, item.name, item.mediaType)
}
}
</script>

View File

@@ -0,0 +1,206 @@
import { app } from '@/scripts/app'
import { api } from '@/scripts/api'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useAssetsStore } from '@/stores/assetsStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import type {
MissingMediaCandidate,
MediaType
} from '@/platform/missingMedia/types'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { addToComboValues } from '@/utils/litegraphUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
import {
ACCEPTED_IMAGE_TYPES,
ACCEPTED_VIDEO_TYPES
} from '@/utils/mediaUploadUtil'
const MEDIA_ACCEPT_MAP: Record<MediaType, string> = {
image: ACCEPTED_IMAGE_TYPES,
video: ACCEPTED_VIDEO_TYPES,
audio: 'audio/*'
}
function getMediaComboWidget(
candidate: MissingMediaCandidate
): { node: LGraphNode; widget: IComboWidget } | null {
const graph = app.rootGraph
if (!graph || candidate.nodeId == null) return null
const node = getNodeByExecutionId(graph, String(candidate.nodeId))
if (!node) return null
const widget = node.widgets?.find(
(w) => w.name === candidate.widgetName && w.type === 'combo'
) as IComboWidget | undefined
if (!widget) return null
return { node, widget }
}
function resolveComboOptions(
candidate: MissingMediaCandidate
): { name: string; value: string }[] {
const result = getMediaComboWidget(candidate)
if (!result) return []
const values = result.widget.options.values
if (!values) return []
const list: string[] =
typeof values === 'function'
? values(result.widget)
: Array.isArray(values)
? values
: Object.keys(values)
return list
.filter((v) => v !== candidate.name)
.map((v) => ({ name: v, value: v }))
}
function applyValueToNodes(
candidates: MissingMediaCandidate[],
name: string,
newValue: string
) {
const matching = candidates.filter((c) => c.name === name)
for (const c of matching) {
const result = getMediaComboWidget(c)
if (!result) continue
addToComboValues(result.widget, newValue)
result.widget.value = newValue
result.widget.callback?.(newValue)
result.node.graph?.setDirtyCanvas(true, true)
}
}
export function getNodeDisplayLabel(
nodeId: string | number,
fallback: string
): string {
const graph = app.rootGraph
if (!graph) return fallback
const node = getNodeByExecutionId(graph, String(nodeId))
return resolveNodeDisplayName(node, {
emptyLabel: fallback,
untitledLabel: fallback,
st
})
}
export function useMissingMediaInteractions() {
const store = useMissingMediaStore()
function isExpanded(key: string): boolean {
return store.expandState[key] ?? false
}
function toggleExpand(key: string) {
store.expandState[key] = !isExpanded(key)
}
function getAcceptType(mediaType: MediaType): string {
return MEDIA_ACCEPT_MAP[mediaType]
}
function getExtensionHint(mediaType: MediaType): string {
if (mediaType === 'audio') return 'audio'
const exts = MEDIA_ACCEPT_MAP[mediaType]
.split(',')
.map((mime) => mime.split('/')[1])
.join(', ')
return `${exts}, ...`
}
function getLibraryOptions(
candidate: MissingMediaCandidate
): { name: string; value: string }[] {
return resolveComboOptions(candidate)
}
/** Step 1: Store selection from library (does not apply yet). */
function handleLibrarySelect(name: string, value: string) {
store.pendingSelection[name] = value
}
/** Step 1: Upload file and store result as pending (does not apply yet). */
async function handleUpload(file: File, name: string, mediaType: MediaType) {
const expectedPrefix =
mediaType === 'audio'
? 'audio/'
: mediaType === 'video'
? 'video/'
: 'image/'
if (file.type && !file.type.startsWith(expectedPrefix)) return
store.uploadState[name] = { fileName: file.name, status: 'uploading' }
try {
const body = new FormData()
body.append('image', file)
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status !== 200) {
useToastStore().addAlert(`${resp.status} - ${resp.statusText}`)
delete store.uploadState[name]
return
}
const data = await resp.json()
const uploadedPath: string = data.subfolder
? `${data.subfolder}/${data.name}`
: data.name
const assetsStore = useAssetsStore()
await assetsStore.updateInputs()
store.uploadState[name] = { fileName: file.name, status: 'uploaded' }
store.pendingSelection[name] = uploadedPath
} catch (err) {
useToastStore().addAlert(String(err))
delete store.uploadState[name]
}
}
/** Step 2: Apply pending selection to widgets and remove from missing list. */
function confirmSelection(name: string) {
const value = store.pendingSelection[name]
if (!value || !store.missingMediaCandidates) return
applyValueToNodes(store.missingMediaCandidates, name, value)
store.removeMissingMediaByName(name)
delete store.pendingSelection[name]
delete store.uploadState[name]
}
function cancelSelection(name: string) {
delete store.pendingSelection[name]
delete store.uploadState[name]
}
function hasPendingSelection(name: string): boolean {
return name in store.pendingSelection
}
return {
isExpanded,
toggleExpand,
getAcceptType,
getExtensionHint,
getLibraryOptions,
handleLibrarySelect,
handleUpload,
confirmSelection,
cancelSelection,
hasPendingSelection
}
}

View File

@@ -60,6 +60,14 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
)
})
// Interaction state — persists across component re-mounts
const expandState = ref<Record<string, boolean>>({})
const uploadState = ref<
Record<string, { fileName: string; status: 'uploading' | 'uploaded' }>
>({})
/** Pending selection: value to apply on confirm. */
const pendingSelection = ref<Record<string, string>>({})
let _verificationAbortController: AbortController | null = null
function createVerificationAbortController(): AbortController {
@@ -80,10 +88,22 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
return activeMissingMediaGraphIds.value.has(String(node.id))
}
function removeMissingMediaByName(name: string) {
if (!missingMediaCandidates.value) return
missingMediaCandidates.value = missingMediaCandidates.value.filter(
(m) => m.name !== name
)
if (!missingMediaCandidates.value.length)
missingMediaCandidates.value = null
}
function clearMissingMedia() {
_verificationAbortController?.abort()
_verificationAbortController = null
missingMediaCandidates.value = null
expandState.value = {}
uploadState.value = {}
pendingSelection.value = {}
}
return {
@@ -95,10 +115,15 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
activeMissingMediaGraphIds,
setMissingMedia,
removeMissingMediaByName,
clearMissingMedia,
createVerificationAbortController,
hasMissingMediaOnNode,
isContainerWithMissingMedia
isContainerWithMissingMedia,
expandState,
uploadState,
pendingSelection
}
})

View File

@@ -11,8 +11,10 @@ import { isImageUploadInput } from '@/types/nodeDefAugmentation'
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
import { addToComboValues } from '@/utils/litegraphUtil'
const ACCEPTED_IMAGE_TYPES = 'image/png,image/jpeg,image/webp'
const ACCEPTED_VIDEO_TYPES = 'video/webm,video/mp4'
import {
ACCEPTED_IMAGE_TYPES,
ACCEPTED_VIDEO_TYPES
} from '@/utils/mediaUploadUtil'
const isImageFile = (file: File) => file.type.startsWith('image/')
const isVideoFile = (file: File) => file.type.startsWith('video/')

View File

@@ -0,0 +1,5 @@
/** Accepted MIME types for the image upload file picker. */
export const ACCEPTED_IMAGE_TYPES = 'image/png,image/jpeg,image/webp'
/** Accepted MIME types for the video upload file picker. */
export const ACCEPTED_VIDEO_TYPES = 'video/webm,video/mp4'