mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 10:59:53 +00:00
[backport cloud/1.37] feat(ui): add TagsInput component with click-to-edit behavior (#8236)
Backport of #8066 to `cloud/1.37` Automatically created by backport workflow. Co-authored-by: Alexander Brown <drjkl@comfy.org> Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -282,7 +282,7 @@
|
||||
--modal-card-border-highlighted: var(--secondary-background-selected);
|
||||
--modal-card-button-surface: var(--color-smoke-300);
|
||||
--modal-card-placeholder-background: var(--color-smoke-600);
|
||||
--modal-card-tag-background: var(--color-smoke-400);
|
||||
--modal-card-tag-background: var(--color-smoke-200);
|
||||
--modal-card-tag-foreground: var(--base-foreground);
|
||||
--modal-panel-background: var(--color-white);
|
||||
}
|
||||
|
||||
182
src/components/ui/tags-input/TagsInput.stories.ts
Normal file
182
src/components/ui/tags-input/TagsInput.stories.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import TagsInput from './TagsInput.vue'
|
||||
import TagsInputInput from './TagsInputInput.vue'
|
||||
import TagsInputItem from './TagsInputItem.vue'
|
||||
import TagsInputItemDelete from './TagsInputItemDelete.vue'
|
||||
import TagsInputItemText from './TagsInputItemText.vue'
|
||||
|
||||
interface GenericMeta<C> extends Omit<Meta<C>, 'component'> {
|
||||
component: ComponentExposed<C>
|
||||
}
|
||||
|
||||
const meta: GenericMeta<typeof TagsInput> = {
|
||||
title: 'Components/TagsInput',
|
||||
component: TagsInput,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
modelValue: {
|
||||
control: 'object',
|
||||
description: 'Array of tag values'
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description:
|
||||
'When true, completely disables the component. When false (default), shows read-only state with edit icon until clicked.'
|
||||
},
|
||||
'onUpdate:modelValue': { action: 'update:modelValue' }
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
TagsInput,
|
||||
TagsInputInput,
|
||||
TagsInputItem,
|
||||
TagsInputItemDelete,
|
||||
TagsInputItemText
|
||||
},
|
||||
setup() {
|
||||
const tags = ref(args.modelValue || ['tag1', 'tag2'])
|
||||
return { tags, args }
|
||||
},
|
||||
template: `
|
||||
<TagsInput v-model="tags" :disabled="args.disabled" class="w-80" v-slot="{ isEmpty }">
|
||||
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput :is-empty="isEmpty" placeholder="Add tag..." />
|
||||
</TagsInput>
|
||||
<div class="mt-4 text-sm text-muted-foreground">
|
||||
Tags: {{ tags.join(', ') }}
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
modelValue: ['Vue', 'TypeScript'],
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
disabled: false
|
||||
},
|
||||
render: (args) => ({
|
||||
components: {
|
||||
TagsInput,
|
||||
TagsInputInput,
|
||||
TagsInputItem,
|
||||
TagsInputItemDelete,
|
||||
TagsInputItemText
|
||||
},
|
||||
setup() {
|
||||
const tags = ref<string[]>([])
|
||||
return { tags, args }
|
||||
},
|
||||
template: `
|
||||
<TagsInput v-model="tags" :disabled="args.disabled" class="w-80" v-slot="{ isEmpty }">
|
||||
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput :is-empty="isEmpty" placeholder="Start typing to add tags..." />
|
||||
</TagsInput>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const ManyTags: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
TagsInput,
|
||||
TagsInputInput,
|
||||
TagsInputItem,
|
||||
TagsInputItemDelete,
|
||||
TagsInputItemText
|
||||
},
|
||||
setup() {
|
||||
const tags = ref([
|
||||
'JavaScript',
|
||||
'TypeScript',
|
||||
'Vue',
|
||||
'React',
|
||||
'Svelte',
|
||||
'Node.js',
|
||||
'Python',
|
||||
'Rust'
|
||||
])
|
||||
return { tags, args }
|
||||
},
|
||||
template: `
|
||||
<TagsInput v-model="tags" :disabled="args.disabled" class="w-96" v-slot="{ isEmpty }">
|
||||
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput :is-empty="isEmpty" placeholder="Add technology..." />
|
||||
</TagsInput>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
disabled: true
|
||||
},
|
||||
|
||||
render: (args) => ({
|
||||
components: {
|
||||
TagsInput,
|
||||
TagsInputInput,
|
||||
TagsInputItem,
|
||||
TagsInputItemDelete,
|
||||
TagsInputItemText
|
||||
},
|
||||
setup() {
|
||||
const tags = ref(['Read', 'Only', 'Tags'])
|
||||
return { tags, args }
|
||||
},
|
||||
template: `
|
||||
<TagsInput v-model="tags" :disabled="args.disabled" class="w-80" v-slot="{ isEmpty }">
|
||||
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput :is-empty="isEmpty" placeholder="Cannot add tags..." />
|
||||
</TagsInput>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const CustomWidth: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
TagsInput,
|
||||
TagsInputInput,
|
||||
TagsInputItem,
|
||||
TagsInputItemDelete,
|
||||
TagsInputItemText
|
||||
},
|
||||
setup() {
|
||||
const tags = ref(['Full', 'Width'])
|
||||
return { tags, args }
|
||||
},
|
||||
template: `
|
||||
<TagsInput v-model="tags" :disabled="args.disabled" class="w-full" v-slot="{ isEmpty }">
|
||||
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput :is-empty="isEmpty" placeholder="Add tag..." />
|
||||
</TagsInput>
|
||||
`
|
||||
})
|
||||
}
|
||||
161
src/components/ui/tags-input/TagsInput.test.ts
Normal file
161
src/components/ui/tags-input/TagsInput.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { h, nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import TagsInput from './TagsInput.vue'
|
||||
import TagsInputInput from './TagsInputInput.vue'
|
||||
import TagsInputItem from './TagsInputItem.vue'
|
||||
import TagsInputItemDelete from './TagsInputItemDelete.vue'
|
||||
import TagsInputItemText from './TagsInputItemText.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { g: { removeTag: 'Remove tag' } } }
|
||||
})
|
||||
|
||||
describe('TagsInput', () => {
|
||||
function mountTagsInput(props = {}, slots = {}) {
|
||||
return mount(TagsInput, {
|
||||
props: {
|
||||
modelValue: [],
|
||||
...props
|
||||
},
|
||||
slots
|
||||
})
|
||||
}
|
||||
|
||||
it('renders slot content', () => {
|
||||
const wrapper = mountTagsInput({}, { default: '<span>Slot Content</span>' })
|
||||
|
||||
expect(wrapper.text()).toContain('Slot Content')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TagsInput with child components', () => {
|
||||
function mountFullTagsInput(tags: string[] = ['tag1', 'tag2']) {
|
||||
return mount(TagsInput, {
|
||||
global: { plugins: [i18n] },
|
||||
props: {
|
||||
modelValue: tags
|
||||
},
|
||||
slots: {
|
||||
default: () => [
|
||||
...tags.map((tag) =>
|
||||
h(TagsInputItem, { key: tag, value: tag }, () => [
|
||||
h(TagsInputItemText),
|
||||
h(TagsInputItemDelete)
|
||||
])
|
||||
),
|
||||
h(TagsInputInput, { placeholder: 'Add tag...' })
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders tags structure and content', () => {
|
||||
const tags = ['tag1', 'tag2']
|
||||
const wrapper = mountFullTagsInput(tags)
|
||||
|
||||
const items = wrapper.findAllComponents(TagsInputItem)
|
||||
const textElements = wrapper.findAllComponents(TagsInputItemText)
|
||||
const deleteButtons = wrapper.findAllComponents(TagsInputItemDelete)
|
||||
|
||||
expect(items).toHaveLength(tags.length)
|
||||
expect(textElements).toHaveLength(tags.length)
|
||||
expect(deleteButtons).toHaveLength(tags.length)
|
||||
|
||||
textElements.forEach((el, i) => {
|
||||
expect(el.text()).toBe(tags[i])
|
||||
})
|
||||
|
||||
expect(wrapper.findComponent(TagsInputInput).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('updates model value when adding a tag', async () => {
|
||||
let currentTags = ['existing']
|
||||
|
||||
const wrapper = mount<typeof TagsInput<string>>(TagsInput, {
|
||||
props: {
|
||||
modelValue: currentTags,
|
||||
'onUpdate:modelValue': (payload) => {
|
||||
currentTags = payload
|
||||
}
|
||||
},
|
||||
slots: {
|
||||
default: () => h(TagsInputInput, { placeholder: 'Add tag...' })
|
||||
}
|
||||
})
|
||||
|
||||
await wrapper.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const input = wrapper.find('input')
|
||||
await input.setValue('newTag')
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
await nextTick()
|
||||
|
||||
expect(currentTags).toContain('newTag')
|
||||
})
|
||||
|
||||
it('does not enter edit mode when disabled', async () => {
|
||||
const wrapper = mount<typeof TagsInput<string>>(TagsInput, {
|
||||
props: {
|
||||
modelValue: ['tag1'],
|
||||
disabled: true
|
||||
},
|
||||
slots: {
|
||||
default: () => h(TagsInputInput, { placeholder: 'Add tag...' })
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.find('input').exists()).toBe(false)
|
||||
|
||||
await wrapper.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('input').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('exits edit mode when clicking outside', async () => {
|
||||
const wrapper = mount<typeof TagsInput<string>>(TagsInput, {
|
||||
props: {
|
||||
modelValue: ['tag1']
|
||||
},
|
||||
slots: {
|
||||
default: () => h(TagsInputInput, { placeholder: 'Add tag...' })
|
||||
},
|
||||
attachTo: document.body
|
||||
})
|
||||
|
||||
await wrapper.trigger('click')
|
||||
await nextTick()
|
||||
expect(wrapper.find('input').exists()).toBe(true)
|
||||
|
||||
document.body.click()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('input').exists()).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows placeholder when modelValue is empty', async () => {
|
||||
const wrapper = mount<typeof TagsInput<string>>(TagsInput, {
|
||||
props: {
|
||||
modelValue: []
|
||||
},
|
||||
slots: {
|
||||
default: ({ isEmpty }: { isEmpty: boolean }) =>
|
||||
h(TagsInputInput, { placeholder: 'Add tag...', isEmpty })
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const input = wrapper.find('input')
|
||||
expect(input.exists()).toBe(true)
|
||||
expect(input.attributes('placeholder')).toBe('Add tag...')
|
||||
})
|
||||
})
|
||||
77
src/components/ui/tags-input/TagsInput.vue
Normal file
77
src/components/ui/tags-input/TagsInput.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts" generic="T extends AcceptableInputValue = string">
|
||||
import { onClickOutside, useCurrentElement } from '@vueuse/core'
|
||||
import type {
|
||||
AcceptableInputValue,
|
||||
TagsInputRootEmits,
|
||||
TagsInputRootProps
|
||||
} from 'reka-ui'
|
||||
import { TagsInputRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import { computed, nextTick, provide, ref } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { tagsInputFocusKey, tagsInputIsEditingKey } from './tagsInputContext'
|
||||
import type { FocusCallback } from './tagsInputContext'
|
||||
|
||||
const {
|
||||
disabled = false,
|
||||
class: className,
|
||||
...restProps
|
||||
} = defineProps<TagsInputRootProps<T> & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<TagsInputRootEmits<T>>()
|
||||
|
||||
const isEditing = ref(false)
|
||||
const rootEl = useCurrentElement<HTMLElement>()
|
||||
const focusInput = ref<FocusCallback>()
|
||||
|
||||
provide(tagsInputFocusKey, (callback: FocusCallback) => {
|
||||
focusInput.value = callback
|
||||
})
|
||||
provide(tagsInputIsEditingKey, isEditing)
|
||||
|
||||
const internalDisabled = computed(() => disabled || !isEditing.value)
|
||||
|
||||
const delegatedProps = computed(() => ({
|
||||
...restProps,
|
||||
disabled: internalDisabled.value
|
||||
}))
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
|
||||
async function enableEditing() {
|
||||
if (!disabled && !isEditing.value) {
|
||||
isEditing.value = true
|
||||
await nextTick()
|
||||
focusInput.value?.()
|
||||
}
|
||||
}
|
||||
|
||||
onClickOutside(rootEl, () => {
|
||||
isEditing.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagsInputRoot
|
||||
v-slot="{ modelValue }"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'group relative flex flex-wrap items-center gap-2 rounded-lg bg-transparent p-2 text-xs text-base-foreground',
|
||||
!internalDisabled &&
|
||||
'hover:bg-modal-card-background-hovered focus-within:bg-modal-card-background-hovered',
|
||||
!disabled && !isEditing && 'cursor-pointer',
|
||||
className
|
||||
)
|
||||
"
|
||||
@click="enableEditing"
|
||||
>
|
||||
<slot :is-empty="modelValue.length === 0" />
|
||||
<i
|
||||
v-if="!disabled && !isEditing"
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--square-pen] absolute bottom-2 right-2 size-4 text-muted-foreground"
|
||||
/>
|
||||
</TagsInputRoot>
|
||||
</template>
|
||||
48
src/components/ui/tags-input/TagsInputInput.vue
Normal file
48
src/components/ui/tags-input/TagsInputInput.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import type { TagsInputInputProps } from 'reka-ui'
|
||||
import { TagsInputInput, useForwardExpose, useForwardProps } from 'reka-ui'
|
||||
import { computed, inject, onMounted, onUnmounted, ref } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { tagsInputFocusKey, tagsInputIsEditingKey } from './tagsInputContext'
|
||||
|
||||
const {
|
||||
isEmpty = false,
|
||||
class: className,
|
||||
...restProps
|
||||
} = defineProps<
|
||||
TagsInputInputProps & { class?: HTMLAttributes['class']; isEmpty?: boolean }
|
||||
>()
|
||||
|
||||
const forwardedProps = useForwardProps(restProps)
|
||||
const isEditing = inject(tagsInputIsEditingKey, ref(true))
|
||||
const showInput = computed(() => isEditing.value || isEmpty)
|
||||
|
||||
const { forwardRef, currentElement } = useForwardExpose()
|
||||
const registerFocus = inject(tagsInputFocusKey, undefined)
|
||||
|
||||
onMounted(() => {
|
||||
registerFocus?.(() => currentElement.value?.focus())
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
registerFocus?.(undefined)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagsInputInput
|
||||
v-if="showInput"
|
||||
:ref="forwardRef"
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'min-h-6 flex-1 bg-transparent text-xs text-muted-foreground placeholder:text-muted-foreground focus:outline-none appearance-none border-none',
|
||||
!isEditing && 'pointer-events-none',
|
||||
className
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
27
src/components/ui/tags-input/TagsInputItem.vue
Normal file
27
src/components/ui/tags-input/TagsInputItem.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { TagsInputItemProps } from 'reka-ui'
|
||||
import { TagsInputItem, useForwardProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
TagsInputItemProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const forwardedProps = useForwardProps(restProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagsInputItem
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-6 items-center gap-1 rounded-sm bg-modal-card-tag-background py-1 pl-2 pr-1 text-modal-card-tag-foreground backdrop-blur-sm ring-offset-base-background data-[state=active]:ring-2 data-[state=active]:ring-base-foreground data-[state=active]:ring-offset-1',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</TagsInputItem>
|
||||
</template>
|
||||
36
src/components/ui/tags-input/TagsInputItemDelete.vue
Normal file
36
src/components/ui/tags-input/TagsInputItemDelete.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import type { TagsInputItemDeleteProps } from 'reka-ui'
|
||||
import { TagsInputItemDelete, useForwardProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
TagsInputItemDeleteProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const forwardedProps = useForwardProps(restProps)
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagsInputItemDelete
|
||||
v-bind="forwardedProps"
|
||||
:as="Button"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('g.removeTag')"
|
||||
:class="
|
||||
cn(
|
||||
'opacity-60 hover:bg-transparent hover:opacity-100 transition-[opacity,width] duration-150 w-4 data-[disabled]:w-0 data-[disabled]:opacity-0 data-[disabled]:pointer-events-none overflow-hidden',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</slot>
|
||||
</TagsInputItemDelete>
|
||||
</template>
|
||||
20
src/components/ui/tags-input/TagsInputItemText.vue
Normal file
20
src/components/ui/tags-input/TagsInputItemText.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { TagsInputItemTextProps } from 'reka-ui'
|
||||
import { TagsInputItemText, useForwardProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
TagsInputItemTextProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const forwardedProps = useForwardProps(restProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagsInputItemText
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('bg-transparent text-xs', className)"
|
||||
/>
|
||||
</template>
|
||||
10
src/components/ui/tags-input/tagsInputContext.ts
Normal file
10
src/components/ui/tags-input/tagsInputContext.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
|
||||
export type FocusCallback = (() => void) | undefined
|
||||
|
||||
export const tagsInputFocusKey: InjectionKey<
|
||||
(callback: FocusCallback) => void
|
||||
> = Symbol('tagsInputFocus')
|
||||
|
||||
export const tagsInputIsEditingKey: InjectionKey<Ref<boolean>> =
|
||||
Symbol('tagsInputIsEditing')
|
||||
@@ -16,6 +16,7 @@
|
||||
"increment": "Increment",
|
||||
"removeImage": "Remove image",
|
||||
"removeVideo": "Remove video",
|
||||
"removeTag": "Remove tag",
|
||||
"chart": "Chart",
|
||||
"chartLowercase": "chart",
|
||||
"file": "file",
|
||||
|
||||
Reference in New Issue
Block a user