refactor: Migrate the project functionality and optimize the code structure

This commit is contained in:
hayden
2024-10-12 17:31:11 +08:00
committed by hayden
parent d96aff80c2
commit c1747a79f3
71 changed files with 6741 additions and 1320 deletions

View File

@@ -0,0 +1,160 @@
<template>
<Dialog
v-model:visible="visible"
:header="$t('parseModelUrl')"
:modal="true"
:maximizable="!isMobile"
maximizeIcon="pi pi-arrow-up-right-and-arrow-down-left-from-center"
minimizeIcon="pi pi-arrow-down-left-and-arrow-up-right-to-center"
pt:mask:style="--p-mask-background: rgba(0, 0, 0, 0.3)"
pt:root:class="max-h-full"
pt:content:class="px-0"
@after-hide="clearContent"
>
<div class="flex h-full flex-col gap-4 px-5">
<ResponseInput
v-model="modelUrl"
:allow-clear="true"
:placeholder="$t('pleaseInputModelUrl')"
@keypress.enter="searchModelsByUrl"
>
<template #suffix>
<span
class="pi pi-search pi-inputicon"
@click="searchModelsByUrl"
></span>
</template>
</ResponseInput>
<div v-show="data.length > 0">
<ResponseSelect
v-model="current"
:items="data"
:type="isMobile ? 'drop' : 'button'"
>
<template #prefix>
<span>version:</span>
</template>
</ResponseSelect>
</div>
<ResponseScrollArea class="-mx-5 h-full">
<div class="px-5">
<ModelContent
v-for="{ item } in data"
v-show="current == item.id"
:key="item.id"
:model="item"
:editable="true"
@submit="createDownTask"
>
<template #action>
<Button
icon="pi pi-download"
:label="$t('download')"
type="submit"
></Button>
</template>
</ModelContent>
<div v-show="data.length === 0">
<div class="flex flex-col items-center gap-4 py-8">
<i class="pi pi-box text-3xl"></i>
<div>No Models Found</div>
</div>
</div>
</div>
</ResponseScrollArea>
</div>
<DialogResizer :min-width="390"></DialogResizer>
</Dialog>
</template>
<script setup lang="ts">
import DialogResizer from 'components/DialogResizer.vue'
import ResponseInput from 'components/ResponseInput.vue'
import ResponseSelect from 'components/ResponseSelect.vue'
import ResponseScrollArea from 'components/ResponseScrollArea.vue'
import ModelContent from 'components/ModelContent.vue'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import { useModelSearch } from 'hooks/download'
import { ref } from 'vue'
import { previewUrlToFile } from 'utils/common'
import { useLoading } from 'hooks/loading'
import { request } from 'hooks/request'
import { useToast } from 'hooks/toast'
import { useConfig } from 'hooks/config'
const visible = defineModel<boolean>('visible')
const { isMobile } = useConfig()
const { toast } = useToast()
const loading = useLoading()
const modelUrl = ref<string>()
const { current, data, search } = useModelSearch()
const searchModelsByUrl = async () => {
if (modelUrl.value) {
await search(modelUrl.value)
}
}
const clearContent = () => {
modelUrl.value = undefined
data.value = []
}
const createDownTask = async (data: VersionModel) => {
const formData = new FormData()
loading.show()
// set base info
formData.append('type', data.type)
formData.append('pathIndex', data.pathIndex.toString())
formData.append('fullname', data.fullname)
// set preview
const previewFile = await previewUrlToFile(data.preview as string).catch(
() => {
loading.hide()
toast.add({
severity: 'error',
summary: 'Error',
detail: 'Failed to download preview',
life: 15000,
})
throw new Error('Failed to download preview')
},
)
formData.append('previewFile', previewFile)
// set description
formData.append('description', data.description)
// set model download info
formData.append('downloadPlatform', data.downloadPlatform)
formData.append('downloadUrl', data.downloadUrl)
formData.append('sizeBytes', data.sizeBytes.toString())
formData.append('hashes', JSON.stringify(data.hashes))
await request('/model', {
method: 'POST',
body: formData,
})
.then(() => {
visible.value = false
})
.catch((e) => {
toast.add({
severity: 'error',
summary: 'Error',
detail: e.message ?? 'Failed to create download task',
life: 15000,
})
})
.finally(() => {
loading.hide()
})
}
</script>

View File

@@ -0,0 +1,167 @@
<template>
<Dialog
v-model:visible="visible"
:modal="true"
pt:mask:style="--p-mask-background: rgba(0, 0, 0, 0.3)"
pt:root:class="max-h-full"
pt:content:class="px-0"
>
<template #header>
<div class="flex flex-1 items-center justify-between pr-2">
<span class="p-dialog-title select-none">
{{ $t('downloadList') }}
</span>
<div class="p-dialog-header-actions">
<Button
icon="pi pi-refresh"
severity="secondary"
text
rounded
@click="refresh"
></Button>
</div>
</div>
</template>
<div class="flex h-full flex-col gap-4">
<div class="whitespace-nowrap px-4 @container">
<div class="flex gap-4 @sm:justify-end">
<Button
class="w-full @sm:w-auto"
:label="$t('createDownloadTask')"
@click="toggleCreateTask"
></Button>
</div>
</div>
<ResponseScrollArea>
<div class="w-full px-4">
<ul class="m-0 flex list-none flex-col gap-4 p-0">
<li
v-for="item in data"
:key="item.taskId"
class="rounded-lg border border-gray-500 p-4"
>
<div class="flex gap-4 overflow-hidden whitespace-nowrap">
<div class="h-18 preview-aspect">
<img :src="item.preview" />
</div>
<div class="flex flex-1 flex-col gap-3 overflow-hidden">
<div class="flex items-center gap-3 overflow-hidden">
<span class="flex-1 overflow-hidden text-ellipsis">
{{ item.fullname }}
</span>
<span v-show="item.status === 'waiting'" class="h-4">
<i class="pi pi-spinner pi-spin"></i>
</span>
<span
v-show="item.status === 'doing'"
class="h-4 cursor-pointer"
@click="item.pauseTask"
>
<i class="pi pi-pause-circle"></i>
</span>
<span
v-show="item.status === 'pause'"
class="h-4 cursor-pointer"
@click="item.resumeTask"
>
<i class="pi pi-play-circle"></i>
</span>
<span class="h-4 cursor-pointer" @click="item.deleteTask">
<i class="pi pi-trash text-red-400"></i>
</span>
</div>
<div class="h-2 overflow-hidden rounded bg-gray-200">
<div
class="h-full bg-blue-500 transition-[width]"
:style="{ width: `${item.progress}%` }"
></div>
</div>
<div class="flex justify-between">
<div>{{ item.downloadProgress }}</div>
<div v-show="item.status === 'doing'">
{{ item.downloadSpeed }}
</div>
</div>
</div>
</div>
</li>
</ul>
</div>
<!-- <ul class="m-0 flex list-none flex-col gap-4 p-0 px-4 pb-0">
<li
v-for="item in data"
:key="item.taskId"
class="flex flex-row gap-3 overflow-hidden rounded-lg border border-gray-500 p-4"
>
<div class="h-18 preview-aspect">
<img
:src="`/model-manager/preview/download/${item.preview}`"
alt=""
/>
</div>
<div class="flex flex-1 flex-col gap-3">
<div class="flex items-center justify-between gap-4">
<div class="overflow-hidden text-ellipsis whitespace-nowrap">
{{ item.fullname }}
</div>
<div class="flex items-center gap-4">
<i v-show="item.status === 'waiting'">
{{ $t('waiting') }}...
</i>
<i
v-show="item.status === 'doing'"
class="pi pi-pause-circle"
@click="item.pauseTask"
></i>
<i
v-show="item.status === 'pause'"
class="pi pi-play-circle"
@click="item.resumeTask"
></i>
<i
class="pi pi-trash text-red-400"
@click="item.deleteTask"
></i>
</div>
</div>
<div class="flex items-center gap-2">
<div class="h-2 flex-1 overflow-hidden rounded bg-gray-200">
<div class="h-full *:h-full *:bg-blue-500 *:transition-all">
<div :style="{ width: `${item.progress}%` }"></div>
</div>
</div>
</div>
<div class="flex items-center justify-between">
<div>{{ item.downloadProgress }}</div>
<div v-show="item.status === 'doing'">
{{ item.downloadSpeed }}
</div>
</div>
</div>
</li>
</ul> -->
</ResponseScrollArea>
</div>
<DialogResizer :min-width="390" :min-height="390"></DialogResizer>
</Dialog>
<DialogCreateTask v-model:visible="openCreateTask"></DialogCreateTask>
</template>
<script setup lang="ts">
import DialogCreateTask from 'components/DialogCreateTask.vue'
import DialogResizer from 'components/DialogResizer.vue'
import ResponseScrollArea from 'components/ResponseScrollArea.vue'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import { useDownload } from 'hooks/download'
import { useBoolean } from 'hooks/utils'
const { visible, data, refresh } = useDownload()
const [openCreateTask, toggleCreateTask] = useBoolean()
</script>

View File

@@ -0,0 +1,223 @@
<template>
<Dialog
:visible="visible"
@update:visible="updateVisible"
:maximizable="!isMobile"
maximizeIcon="pi pi-arrow-up-right-and-arrow-down-left-from-center"
minimizeIcon="pi pi-arrow-down-left-and-arrow-up-right-to-center"
:pt:mask:class="['group', { open }]"
pt:root:class="max-h-full group-[:not(.open)]:!hidden"
pt:content:class="px-0"
>
<template #header>
<div class="flex flex-1 items-center justify-between pr-2">
<span class="p-dialog-title select-none">{{ $t('modelManager') }}</span>
<div class="p-dialog-header-actions">
<Button
icon="pi pi-refresh"
severity="secondary"
text
rounded
@click="refreshModels"
></Button>
<Button
icon="pi pi-download"
severity="secondary"
text
rounded
@click="download.toggle"
></Button>
</div>
</div>
</template>
<div
class="flex h-full flex-col gap-4 overflow-hidden @container/content"
:style="{
['--card-width']: `${cardWidth}px`,
['--gutter']: `${gutter}px`,
}"
>
<div
:class="[
'grid grid-cols-1 justify-center gap-4 px-8',
'@lg/content:grid-cols-[repeat(auto-fit,var(--card-width))]',
'@lg/content:gap-[var(--gutter)]',
'@lg/content:px-4',
]"
>
<div class="col-span-full @container/toolbar">
<div :class="['flex flex-col gap-4', '@2xl/toolbar:flex-row']">
<ResponseInput
v-model="searchContent"
:placeholder="$t('searchModels')"
:allow-clear="true"
suffix-icon="pi pi-search"
></ResponseInput>
<div
class="flex items-center justify-between gap-4 overflow-hidden"
>
<ResponseSelect
v-model="currentType"
:items="typeOptions"
:type="isMobile ? 'drop' : 'button'"
></ResponseSelect>
<ResponseSelect
v-model="sortOrder"
:items="sortOrderOptions"
></ResponseSelect>
</div>
</div>
</div>
</div>
<ResponseScrollArea class="h-full">
<div
:class="[
'-mt-8 grid grid-cols-1 justify-center gap-8 px-8',
'@lg/content:grid-cols-[repeat(auto-fit,var(--card-width))]',
'@lg/content:gap-[var(--gutter)]',
'@lg/content:-mt-[var(--gutter)]',
'@lg/content:px-4',
]"
>
<div class="col-span-full"></div>
<div v-for="model in list" v-show="model.visible" :key="model.id">
<DialogModelCard
:key="`${model.type}:${model.pathIndex}:${model.fullname}`"
:model="model"
></DialogModelCard>
</div>
</div>
<div v-show="noneDisplayModel" class="flex justify-center pt-20">
<div class="select-none text-lg font-bold">No models found</div>
</div>
</ResponseScrollArea>
</div>
<DialogResizer
:min-width="cardWidth * 2 + gutter + 42"
:min-height="cardWidth * aspect * 0.5 + 162"
></DialogResizer>
</Dialog>
</template>
<script setup lang="ts" name="manager-dialog">
import { useConfig } from 'hooks/config'
import { useDialogManager } from 'hooks/manager'
import { useModels } from 'hooks/model'
import DialogResizer from 'components/DialogResizer.vue'
import DialogModelCard from 'components/DialogModelCard.vue'
import ResponseInput from 'components/ResponseInput.vue'
import ResponseSelect from 'components/ResponseSelect.vue'
import ResponseScrollArea from 'components/ResponseScrollArea.vue'
import Dialog from 'primevue/dialog'
import Button from 'primevue/button'
import { computed, ref } from 'vue'
import { useToast } from 'hooks/toast'
import { useDownload } from 'hooks/download'
import { useI18n } from 'vue-i18n'
const { isMobile, cardWidth, gutter, aspect, refreshSetting } = useConfig()
const download = useDownload()
const { visible, updateVisible, open } = useDialogManager()
const { data, refresh } = useModels()
const { toast } = useToast()
const { t } = useI18n()
const searchContent = ref<string>()
const currentType = ref('all')
const typeOptions = ref(
[
{ label: 'ALL', value: 'all' },
{ label: 'Checkpoint', value: 'checkpoints' },
{ label: 'embedding', value: 'embeddings' },
{ label: 'Hypernetwork', value: 'hypernetworks' },
{ label: 'Lora', value: 'loras' },
{ label: 'VAE', value: 'vae' },
{ label: 'VAE approx', value: 'vae_approx' },
{ label: 'Controlnet', value: 'controlnet' },
{ label: 'Clip', value: 'clip' },
{ label: 'Clip Vision', value: 'clip_vision' },
{ label: 'Diffusers', value: 'diffusers' },
{ label: 'Gligen', value: 'gligen' },
{ label: 'Photomaker', value: 'photomaker' },
{ label: 'Style Models', value: 'style_models' },
{ label: 'Unet', value: 'unet' },
].map((item) => {
return {
...item,
command: () => {
currentType.value = item.value
},
}
}),
)
const sortOrder = ref('name')
const sortOrderOptions = ref(
['name', 'size', 'created', 'modified'].map((key) => {
return {
label: t(`sort.${key}`),
value: key,
icon: key === 'name' ? 'pi pi-sort-alpha-down' : 'pi pi-sort-amount-down',
command: () => {
sortOrder.value = key
},
}
}),
)
const list = computed(() => {
const filterList = data.value.map((model) => {
const showAllModel = currentType.value === 'all'
const matchType = showAllModel || model.type === currentType.value
const matchName = model.fullname
.toLowerCase()
.includes(searchContent.value?.toLowerCase() || '')
model.visible = matchType && matchName
return model
})
let sortStrategy = (a: Model, b: Model) => 0
switch (sortOrder.value) {
case 'name':
sortStrategy = (a, b) => a.fullname.localeCompare(b.fullname)
break
case 'size':
sortStrategy = (a, b) => b.sizeBytes - a.sizeBytes
break
case 'created':
sortStrategy = (a, b) => b.createdAt - a.createdAt
break
case 'modified':
sortStrategy = (a, b) => b.updatedAt - a.updatedAt
break
default:
break
}
return filterList.sort(sortStrategy)
})
const noneDisplayModel = computed(() => {
return !list.value.some((model) => model.visible)
})
const refreshModels = async () => {
await Promise.all([refresh(), refreshSetting()])
toast.add({
severity: 'success',
summary: 'Refreshed Models',
life: 2000,
})
}
</script>

View File

@@ -0,0 +1,97 @@
<template>
<div
class="group/card relative w-full cursor-pointer select-none preview-aspect"
@click.stop.prevent="toggle"
>
<div class="h-full overflow-hidden rounded-lg">
<div class="h-full bg-gray-500 duration-300 group-hover/card:scale-110">
<img class="h-full w-full object-cover" :src="preview" />
</div>
</div>
<div
data-draggable-overlay
class="absolute left-0 top-0 h-full w-full"
draggable="true"
@dragend.stop="dragToAddModelNode"
></div>
<div class="pointer-events-none absolute left-0 top-0 h-full w-full p-4">
<div class="relative h-full w-full text-white">
<div class="absolute bottom-0 left-0">
<div class="drop-shadow-[0px_2px_2px_rgba(0,0,0,0.75)]">
<div class="line-clamp-3 break-all text-2xl font-bold @lg:text-lg">
{{ model.basename }}
</div>
</div>
</div>
<div class="absolute left-0 top-0 w-full">
<div class="flex flex-row items-start justify-between">
<div class="flex items-center rounded-full bg-black/30 px-3 py-2">
<div class="font-bold @lg:text-xs">
{{ displayType }}
</div>
</div>
<div class="duration-300 group-hover/card:opacity-100">
<div class="flex flex-col gap-4 *:pointer-events-auto">
<Button
icon="pi pi-plus"
severity="secondary"
rounded
@click.stop="addModelNode"
></Button>
<Button
icon="pi pi-copy"
severity="secondary"
rounded
@click.stop="copyModelNode"
></Button>
<Button
v-show="model.preview"
icon="pi pi-file-import"
severity="secondary"
rounded
@click.stop="loadPreviewWorkflow"
></Button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<DialogModelDetail
v-model:visible="visible"
:model="model"
></DialogModelDetail>
</template>
<script setup lang="ts">
import { useBoolean } from 'hooks/utils'
import DialogModelDetail from 'components/DialogModelDetail.vue'
import Button from 'primevue/button'
import { resolveModelType } from 'utils/model'
import { computed } from 'vue'
import { useModelNodeAction } from 'hooks/model'
interface Props {
model: Model
}
const props = defineProps<Props>()
const [visible, toggle] = useBoolean()
const displayType = computed(() => resolveModelType(props.model.type).display)
const preview = computed(() =>
Array.isArray(props.model.preview)
? props.model.preview[0]
: props.model.preview,
)
const { addModelNode, dragToAddModelNode, copyModelNode, loadPreviewWorkflow } =
useModelNodeAction(props.model)
</script>

View File

@@ -0,0 +1,103 @@
<template>
<Dialog
v-model:visible="visible"
:header="filename"
:maximizable="!isMobile"
maximizeIcon="pi pi-arrow-up-right-and-arrow-down-left-from-center"
minimizeIcon="pi pi-arrow-down-left-and-arrow-up-right-to-center"
pt:title:class="whitespace-nowrap text-ellipsis overflow-hidden"
pt:root:class="max-h-full"
pt:content:class="px-0"
@after-hide="handleCancel"
>
<ResponseScrollArea class="h-full">
<div class="px-8">
<ModelContent
v-model:editable="editable"
:model="model"
@submit="handleSave"
@reset="handleCancel"
>
<template #action="{ metadata }">
<template v-if="editable">
<Button :label="$t('cancel')" type="reset"></Button>
<Button :label="$t('save')" type="submit"></Button>
</template>
<template v-else>
<Button
v-show="metadata.modelPage"
icon="pi pi-eye"
@click="openModelPage(metadata.modelPage)"
></Button>
<Button icon="pi pi-plus" @click.stop="addModelNode"></Button>
<Button icon="pi pi-copy" @click.stop="copyModelNode"></Button>
<Button
v-show="model.preview"
icon="pi pi-file-import"
@click.stop="loadPreviewWorkflow"
></Button>
<Button
icon="pi pi-pen-to-square"
@click="editable = true"
></Button>
<Button
severity="danger"
icon="pi pi-trash"
@click="handleDelete"
></Button>
</template>
</template>
</ModelContent>
</div>
</ResponseScrollArea>
<DialogResizer :min-width="390"></DialogResizer>
</Dialog>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import ModelContent from 'components/ModelContent.vue'
import DialogResizer from 'components/DialogResizer.vue'
import ResponseScrollArea from 'components/ResponseScrollArea.vue'
import { useConfig } from 'hooks/config'
import { computed, ref } from 'vue'
import { useModelNodeAction, useModels } from 'hooks/model'
const visible = defineModel<boolean>('visible')
interface Props {
model: Model
}
const props = defineProps<Props>()
const { isMobile } = useConfig()
const { remove, update } = useModels()
const editable = ref(false)
const filename = computed(() => {
const basename = props.model.fullname.split('/').pop()!
return basename.replace(props.model.extension, '')
})
const handleCancel = () => {
editable.value = false
}
const handleSave = async (data: BaseModel) => {
editable.value = false
await update(props.model, data)
}
const handleDelete = async () => {
await remove(props.model)
}
const openModelPage = (url: string) => {
window.open(url, '_blank')
}
const { addModelNode, copyModelNode, loadPreviewWorkflow } = useModelNodeAction(
props.model,
)
</script>

View File

@@ -0,0 +1,303 @@
<template>
<div v-if="allowResize" data-dialog-resizer>
<div
v-if="allow?.x"
data-resize-pos="left"
class="absolute -left-1 top-0 h-full w-2 cursor-ew-resize"
@mousedown="startResize"
></div>
<div
v-if="allow?.x"
data-resize-pos="right"
class="absolute -right-1 top-0 h-full w-2 cursor-ew-resize"
@mousedown="startResize"
></div>
<div
v-if="allow?.y"
data-resize-pos="top"
class="absolute -top-1 left-0 h-2 w-full cursor-ns-resize"
@mousedown="startResize"
></div>
<div
v-if="allow?.y"
data-resize-pos="bottom"
class="absolute -bottom-1 left-0 h-2 w-full cursor-ns-resize"
@mousedown="startResize"
></div>
<div
v-if="allow?.x && allow?.y"
data-resize-pos="top-left"
class="absolute -left-1 -top-1 h-2 w-2 cursor-se-resize"
@mousedown="startResize"
></div>
<div
v-if="allow?.x && allow?.y"
data-resize-pos="top-right"
class="absolute -right-1 -top-1 h-2 w-2 cursor-sw-resize"
@mousedown="startResize"
></div>
<div
v-if="allow?.x && allow?.y"
data-resize-pos="bottom-left"
class="absolute -bottom-1 -left-1 h-2 w-2 cursor-sw-resize"
@mousedown="startResize"
></div>
<div
v-if="allow?.x && allow?.y"
data-resize-pos="bottom-right"
class="absolute -bottom-1 -right-1 h-2 w-2 cursor-se-resize"
@mousedown="startResize"
></div>
</div>
</template>
<script setup lang="ts">
import { clamp } from 'lodash'
import { useConfig } from 'hooks/config'
import {
computed,
getCurrentInstance,
onBeforeUnmount,
onMounted,
ref,
watch,
} from 'vue'
type ContainerSize = { width: number; height: number }
type ContainerPosition = { left: number; top: number }
interface ResizableProps {
defaultSize?: Partial<ContainerSize>
defaultMobileSize?: Partial<ContainerSize>
allow?: { x?: boolean; y?: boolean }
minWidth?: number
maxWidth?: number
minHeight?: number
maxHeight?: number
}
const props = withDefaults(defineProps<ResizableProps>(), {
allow: () => ({ x: true, y: true }),
})
const config = useConfig()
const allowResize = computed(() => {
return !config.isMobile.value
})
const instance = getCurrentInstance()
const resizeDirection = ref<string[]>([])
const getContainer = () => {
return instance!.parent!.vnode.el as HTMLDivElement
}
const minWidth = computed(() => {
const defaultMinWidth = 100
return props.minWidth ?? defaultMinWidth
})
const maxWidth = computed(() => {
const defaultMaxWidth = window.innerWidth
return props.maxWidth ?? defaultMaxWidth
})
const minHeight = computed(() => {
const defaultMinHeight = 100
return props.minHeight ?? defaultMinHeight
})
const maxHeight = computed(() => {
const defaultMaxHeight = window.innerHeight
return props.maxHeight ?? defaultMaxHeight
})
const isResizing = ref(false)
const defaultWidth = window.innerWidth * 0.6
const defaultHeight = window.innerHeight * 0.8
const containerSize = ref({
width:
props.defaultSize?.width ??
clamp(defaultWidth, minWidth.value, maxWidth.value),
height:
props.defaultSize?.height ??
clamp(defaultHeight, minHeight.value, maxHeight.value),
})
const containerPosition = ref<ContainerPosition>({ left: 0, top: 0 })
const updateContainerSize = (size: ContainerSize) => {
const container = getContainer()
container.style.width = `${size.width}px`
container.style.height = `${size.height}px`
}
const updateContainerPosition = (position: ContainerPosition) => {
const container = getContainer()
container.style.left = `${position.left}px`
container.style.top = `${position.top}px`
}
const recordContainerPosition = () => {
const container = getContainer()
containerPosition.value = {
left: container.offsetLeft,
top: container.offsetTop,
}
}
const updateGlobalStyle = (direction?: string) => {
let cursor = ''
let select = ''
switch (direction) {
case 'left':
case 'right':
cursor = 'ew-resize'
select = 'none'
break
case 'top':
case 'bottom':
cursor = 'ns-resize'
select = 'none'
break
case 'top-left':
case 'bottom-right':
cursor = 'se-resize'
select = 'none'
break
case 'top-right':
case 'bottom-left':
cursor = 'sw-resize'
select = 'none'
break
default:
break
}
document.body.style.cursor = cursor
document.body.style.userSelect = select
}
const resize = (event: MouseEvent) => {
if (isResizing.value) {
const container = getContainer()
for (const direction of resizeDirection.value) {
if (direction === 'left') {
if (event.clientX > 0) {
containerSize.value.width = clamp(
container.offsetLeft + container.offsetWidth - event.clientX,
minWidth.value,
maxWidth.value,
)
}
if (
containerSize.value.width > minWidth.value &&
containerSize.value.width < maxWidth.value
) {
containerPosition.value.left = clamp(
event.clientX,
0,
window.innerWidth - containerSize.value.width,
)
}
}
if (direction === 'right') {
containerSize.value.width = clamp(
event.clientX - container.offsetLeft,
minWidth.value,
maxWidth.value,
)
}
if (direction === 'top') {
if (event.clientY > 0) {
containerSize.value.height = clamp(
container.offsetTop + container.offsetHeight - event.clientY,
minHeight.value,
maxHeight.value,
)
}
if (
containerSize.value.height > minHeight.value &&
containerSize.value.height < maxHeight.value
) {
containerPosition.value.top = clamp(
event.clientY,
0,
window.innerHeight - containerSize.value.height,
)
}
}
if (direction === 'bottom') {
containerSize.value.height = clamp(
event.clientY - container.offsetTop,
minHeight.value,
maxHeight.value,
)
}
}
updateContainerSize(containerSize.value)
updateContainerPosition(containerPosition.value)
}
}
const stopResize = () => {
isResizing.value = false
resizeDirection.value = []
document.removeEventListener('mousemove', resize)
document.removeEventListener('mouseup', stopResize)
updateGlobalStyle()
}
const startResize = (event: MouseEvent) => {
isResizing.value = true
const direction =
(event.target as HTMLElement).getAttribute('data-resize-pos') ?? ''
resizeDirection.value = direction.split('-')
recordContainerPosition()
updateGlobalStyle(direction)
document.addEventListener('mousemove', resize)
document.addEventListener('mouseup', stopResize)
}
onMounted(() => {
if (allowResize.value) {
updateContainerSize(containerSize.value)
} else {
updateContainerSize({
width: props.defaultMobileSize?.width ?? window.innerWidth,
height: props.defaultMobileSize?.height ?? window.innerHeight,
})
}
recordContainerPosition()
updateContainerPosition(containerPosition.value)
getContainer().style.position = 'fixed'
})
onBeforeUnmount(() => {
stopResize()
})
watch(allowResize, (allowResize) => {
if (allowResize) {
updateContainerSize(containerSize.value)
updateContainerPosition(containerPosition.value)
} else {
updateContainerSize({
width: props.defaultMobileSize?.width ?? window.innerWidth,
height: props.defaultMobileSize?.height ?? window.innerHeight,
})
updateContainerPosition({ left: 0, top: 0 })
}
})
defineExpose({
updateContainerSize,
updateContainerPosition,
})
</script>

View File

@@ -0,0 +1,17 @@
<template>
<form @submit="handleSubmit" @reset="handleReset">
<slot name="default"></slot>
</form>
</template>
<script setup lang="ts">
const emits = defineEmits(['submit', 'reset'])
const handleReset = () => {
emits('reset')
}
const handleSubmit = async () => {
emits('submit')
}
</script>

View File

@@ -0,0 +1,15 @@
<template>
<div v-show="loading">
<div class="fixed left-0 top-0 h-full w-full" style="z-index: 9999">
<div class="flex h-full w-full items-center justify-center bg-black/30">
<i class="pi pi-spinner pi-spin text-3xl opacity-30"></i>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useGlobalLoading } from 'hooks/loading'
const { loading } = useGlobalLoading()
</script>

View File

@@ -0,0 +1,22 @@
<template>
<Toast :position="position" :style="style"></Toast>
</template>
<script setup lang="ts">
import { useConfig } from 'hooks/config'
import Toast from 'primevue/toast'
import { computed } from 'vue'
const config = useConfig()
const position = computed(() => {
return config.isMobile.value ? 'top-center' : 'top-right'
})
const style = computed(() => {
if (config.isMobile.value) {
return { width: '80vw' }
}
return {}
})
</script>

View File

@@ -0,0 +1,90 @@
<template>
<div class="flex flex-col gap-4">
<div v-if="editable" class="flex flex-col gap-4">
<ResponseSelect v-if="!baseInfo.type" v-model="type" :items="typeOptions">
<template #prefix>
<span>{{ $t('modelType') }}</span>
</template>
</ResponseSelect>
<ResponseSelect class="w-full" v-model="pathIndex" :items="pathOptions">
</ResponseSelect>
<ResponseInput
v-model.trim="basename"
class="-mr-2 text-right"
update-trigger="blur"
>
<template #suffix>
<span class="pi-inputicon">
{{ extension }}
</span>
</template>
</ResponseInput>
</div>
<table class="w-full table-fixed border-collapse border">
<colgroup>
<col class="w-32" />
<col />
</colgroup>
<tbody>
<tr v-for="item in information" class="h-8 border-b">
<td class="border-r bg-gray-300 px-4 dark:bg-gray-800">
{{ $t(`info.${item.key}`) }}
</td>
<td class="break-all px-4">{{ item.display }}</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import ResponseInput from 'components/ResponseInput.vue'
import ResponseSelect from 'components/ResponseSelect.vue'
import { useConfig } from 'hooks/config'
import { useModelBaseInfo } from 'hooks/model'
import { resolveModelType } from 'utils/model'
import { computed } from 'vue'
const editable = defineModel<boolean>('editable')
const { modelFolders } = useConfig()
const { baseInfo, pathIndex, basename, extension, type } = useModelBaseInfo()
const typeOptions = computed(() => {
return Object.keys(modelFolders.value).map((curr) => {
return {
value: curr,
label: resolveModelType(curr).display,
command: () => {
type.value = curr
pathIndex.value = 0
},
}
})
})
const pathOptions = computed(() => {
return (modelFolders.value[type.value] ?? []).map((folder, index) => {
return {
value: index,
label: folder,
command: () => {
pathIndex.value = index
},
}
})
})
const information = computed(() => {
return Object.values(baseInfo.value).filter((row) => {
if (editable.value) {
return row.key !== 'fullname'
}
return true
})
})
</script>

View File

@@ -0,0 +1,96 @@
<template>
<form
class="@container"
@submit.prevent="handleSubmit"
@reset.prevent="handleReset"
>
<div class="mx-auto w-full max-w-[50rem]">
<div class="relative flex flex-col gap-4 overflow-hidden @xl:flex-row">
<ModelPreview
class="shrink-0"
v-model:editable="editable"
></ModelPreview>
<div class="flex flex-col gap-4 overflow-hidden">
<div class="flex items-center justify-end gap-4">
<slot name="action" :metadata="formInstance.metadata.value"></slot>
</div>
<ModelBaseInfo v-model:editable="editable"></ModelBaseInfo>
</div>
</div>
<Tabs value="0" class="mt-4">
<TabList>
<Tab value="0">Description</Tab>
<Tab value="1">Metadata</Tab>
</TabList>
<TabPanels pt:root:class="p-0 py-4">
<TabPanel value="0">
<ModelDescription v-model:editable="editable"></ModelDescription>
</TabPanel>
<TabPanel value="1">
<ModelMetadata></ModelMetadata>
</TabPanel>
</TabPanels>
</Tabs>
</div>
</form>
</template>
<script setup lang="ts">
import ModelPreview from 'components/ModelPreview.vue'
import ModelBaseInfo from 'components/ModelBaseInfo.vue'
import ModelDescription from 'components/ModelDescription.vue'
import ModelMetadata from 'components/ModelMetadata.vue'
import Tab from 'primevue/tab'
import Tabs from 'primevue/tabs'
import TabList from 'primevue/tablist'
import TabPanel from 'primevue/tabpanel'
import TabPanels from 'primevue/tabpanels'
import {
useModelBaseInfoEditor,
useModelDescriptionEditor,
useModelFormData,
useModelMetadataEditor,
useModelPreviewEditor,
} from 'hooks/model'
import { toRaw, watch } from 'vue'
import { cloneDeep } from 'lodash'
interface Props {
model: BaseModel
}
const props = defineProps<Props>()
const editable = defineModel<boolean>('editable')
const emits = defineEmits<{
submit: [formData: BaseModel]
reset: []
}>()
const formInstance = useModelFormData(() => cloneDeep(toRaw(props.model)))
useModelBaseInfoEditor(formInstance)
useModelPreviewEditor(formInstance)
useModelDescriptionEditor(formInstance)
useModelMetadataEditor(formInstance)
const handleReset = () => {
formInstance.reset()
emits('reset')
}
const handleSubmit = async () => {
const data = formInstance.submit()
emits('submit', data)
}
watch(
() => props.model,
() => {
handleReset()
},
)
</script>

View File

@@ -0,0 +1,91 @@
<template>
<div class="relative">
<textarea
ref="textareaRef"
v-show="active"
:class="[
'w-full resize-none overflow-hidden px-3 py-2 outline-none',
'rounded-lg border',
'border-[var(--p-form-field-border-color)]',
'focus:border-[var(--p-form-field-focus-border-color)]',
'relative z-10',
]"
v-model="innerValue"
@input="resizeTextarea"
@blur="exitEditMode"
></textarea>
<div v-show="!active">
<div v-show="editable" class="flex items-center gap-2 text-gray-600">
<i class="pi pi-info-circle"></i>
<span>
{{ $t('tapToChange') }}
</span>
</div>
<div class="relative">
<div
v-if="renderedDescription"
class="markdown-it"
v-html="renderedDescription"
></div>
<div v-else class="flex flex-col items-center gap-2 py-5">
<i class="pi pi-info-circle text-lg"></i>
<div>no description</div>
</div>
<div
v-show="editable"
class="absolute left-0 top-0 h-full w-full cursor-pointer"
@click="entryEditMode"
></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useModelDescription } from 'hooks/model'
import { nextTick, ref, watch } from 'vue'
const editable = defineModel<boolean>('editable')
const active = ref(false)
const { description, renderedDescription } = useModelDescription()
const textareaRef = ref<HTMLTextAreaElement>()
const innerValue = ref<string>()
watch(
description,
(value) => {
innerValue.value = value
},
{ immediate: true },
)
const resizeTextarea = () => {
const textarea = textareaRef.value!
textarea.style.height = 'auto'
const scrollHeight = textarea.scrollHeight
textarea.style.height = scrollHeight + 'px'
textarea.scrollIntoView({
block: 'nearest',
inline: 'nearest',
})
}
const entryEditMode = async () => {
active.value = true
await nextTick()
resizeTextarea()
textareaRef.value!.focus()
}
const exitEditMode = () => {
description.value = innerValue.value!
active.value = false
}
</script>

View File

@@ -0,0 +1,37 @@
<template>
<table v-if="dataSource.length" class="w-full border-collapse border">
<tbody>
<tr v-for="item in dataSource" class="h-8 border-b">
<td class="border-r bg-gray-300 px-4 dark:bg-gray-800">
{{ item.key }}
</td>
<td class="break-all px-4">{{ item.value }}</td>
</tr>
</tbody>
</table>
<div v-else class="flex flex-col items-center gap-2 py-5">
<i class="pi pi-info-circle text-lg"></i>
<div>no metadata</div>
</div>
</template>
<script setup lang="ts">
import { useModelMetadata } from 'hooks/model'
import { computed } from 'vue'
const { metadata } = useModelMetadata()
const dataSource = computed(() => {
const dataSource: { key: string; value: any }[] = []
for (const key in metadata.value) {
if (Object.prototype.hasOwnProperty.call(metadata.value, key)) {
const value = metadata.value[key]
dataSource.push({ key, value })
}
}
return dataSource
})
</script>

View File

@@ -0,0 +1,112 @@
<template>
<div
class="flex flex-col gap-4"
:style="{ ['--preview-width']: `${cardWidth}px` }"
>
<div>
<div
:class="[
'relative mx-auto w-full',
'@sm:w-[var(--preview-width)]',
'overflow-hidden rounded-lg preview-aspect',
]"
>
<ResponseImage :src="preview" :error="noPreviewContent"></ResponseImage>
<Carousel
v-if="defaultContent.length > 1"
v-show="currentType === 'default'"
class="absolute top-0 h-full w-full"
:value="defaultContent"
v-model:page="defaultContentPage"
:circular="true"
:show-navigators="true"
:show-indicators="false"
pt:contentcontainer:class="h-full"
pt:content:class="h-full"
pt:itemlist:class="h-full"
:prev-button-props="{
class: 'absolute left-4 z-10',
rounded: true,
severity: 'secondary',
}"
:next-button-props="{
class: 'absolute right-4 z-10',
rounded: true,
severity: 'secondary',
}"
>
<template #item="slotProps">
<ResponseImage
:src="slotProps.data"
:error="noPreviewContent"
></ResponseImage>
</template>
</Carousel>
</div>
</div>
<div v-if="editable" class="flex flex-col gap-4 whitespace-nowrap">
<div class="h-10"></div>
<div
:class="[
'flex h-10 items-center gap-4',
'absolute left-1/2 -translate-x-1/2',
'@xl:left-0 @xl:translate-x-0',
]"
>
<Button
v-for="type in typeOptions"
:key="type"
:severity="currentType === type ? undefined : 'secondary'"
:label="$t(type)"
@click="currentType = type"
></Button>
</div>
<div v-show="currentType === 'network'">
<div class="absolute left-0 w-full">
<ResponseInput
v-model="networkContent"
prefix-icon="pi pi-globe"
:allow-clear="true"
></ResponseInput>
</div>
<div class="h-10"></div>
</div>
<div v-show="currentType === 'local'">
<ResponseFileUpload
class="absolute left-0 h-24 w-full"
@select="updateLocalContent"
>
</ResponseFileUpload>
<div class="h-24"></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import ResponseImage from 'components/ResponseImage.vue'
import ResponseInput from 'components/ResponseInput.vue'
import ResponseFileUpload from 'components/ResponseFileUpload.vue'
import Button from 'primevue/button'
import Carousel from 'primevue/carousel'
import { useModelPreview } from 'hooks/model'
import { useConfig } from 'hooks/config'
const editable = defineModel<boolean>('editable')
const { cardWidth } = useConfig()
const {
preview,
typeOptions,
currentType,
defaultContent,
defaultContentPage,
networkContent,
updateLocalContent,
noPreviewContent,
} = useModelPreview()
</script>

View File

@@ -0,0 +1,56 @@
<template>
<div
class="rounded-lg border border-gray-500 p-4 text-gray-500"
@dragenter.stop.prevent
@dragover.stop.prevent
@dragleave.stop.prevent
@drop.stop.prevent="handleDropFile"
@click="handleClick"
>
<slot name="default">
<div class="flex h-full flex-col items-center justify-center gap-2">
<i class="pi pi-cloud-upload text-2xl"></i>
<p class="m-0 select-none overflow-hidden text-ellipsis">
{{ $t('uploadFile') }}
</p>
</div>
</slot>
</div>
</template>
<script setup lang="ts">
const emits = defineEmits<{
select: [event: SelectEvent]
}>()
const covertFileList = (fileList: FileList) => {
const files: SelectFile[] = []
for (const file of fileList) {
const selectFile = file as SelectFile
selectFile.objectURL = URL.createObjectURL(file)
files.push(selectFile)
}
return files
}
const handleDropFile = (event: DragEvent) => {
const files = event.dataTransfer?.files
if (files) {
emits('select', { originalEvent: event, files: covertFileList(files) })
}
}
const handleClick = (event: MouseEvent) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.onchange = () => {
const files = input.files
if (files) {
emits('select', { originalEvent: event, files: covertFileList(files) })
}
}
input.click()
}
</script>

View File

@@ -0,0 +1,36 @@
<template>
<span class="relative">
<img :src="src" :alt="alt" v-bind="$attrs" @error="onError" />
<img v-if="error" v-show="loadError" :src="error" class="absolute top-0" />
</span>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
interface Props {
src?: string
alt?: string
error?: string
}
const props = defineProps<Props>()
defineOptions({
inheritAttrs: false,
})
const loadError = ref(false)
watch(
() => props.src,
() => {
loadError.value = !props.src
},
{ immediate: true },
)
const onError = () => {
loadError.value = true
}
</script>

View File

@@ -0,0 +1,82 @@
<template>
<div class="p-component p-inputtext flex items-center gap-2">
<slot name="prefix">
<span v-if="prefixIcon" :class="[prefixIcon, 'pi-inputicon']"></span>
</slot>
<input
ref="inputRef"
v-model="innerValue"
class="flex-1 border-none bg-transparent text-base outline-none"
type="text"
:placeholder="placeholder"
@paste.stop
v-bind="$attrs"
@[trigger]="updateContent"
/>
<span
v-if="allowClear"
v-show="content"
class="pi pi-times pi-inputicon"
@click="clearContent"
></span>
<slot name="suffix">
<span v-if="suffixIcon" :class="[suffixIcon, 'pi-inputicon']"></span>
</slot>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
interface Props {
prefixIcon?: string
suffixIcon?: string
placeholder?: string
allowClear?: boolean
updateTrigger?: string
}
const props = defineProps<Props>()
const [content, modifiers] = defineModel<string, 'trim'>()
const inputRef = ref()
const innerValue = ref(content)
const trigger = computed(() => props.updateTrigger ?? 'input')
const updateContent = () => {
let value = innerValue.value
if (modifiers.trim) {
value = innerValue.value?.trim()
}
content.value = value
inputRef.value.value = value
}
defineOptions({
inheritAttrs: false,
})
const clearContent = () => {
content.value = undefined
inputRef.value?.focus()
}
</script>
<style>
.p-inputtext:focus-within {
border-color: var(--p-inputtext-focus-border-color);
box-shadow: var(--p-inputtext-focus-ring-shadow);
outline: var(--p-inputtext-focus-ring-width)
var(--p-inputtext-focus-ring-style) var(--p-inputtext-focus-ring-color);
outline-offset: var(--p-inputtext-focus-ring-offset);
}
.p-inputtext .pi-inputicon {
font-size: 1rem;
opacity: 0.6;
}
</style>

View File

@@ -0,0 +1,214 @@
<template>
<div data-scroll-area class="group/scroll relative overflow-hidden">
<div
ref="viewport"
data-scroll-viewport
class="h-full w-full overflow-auto scrollbar-none"
@scroll="onContentScroll"
v-resize="onContainerResize"
>
<div data-scroll-content style="min-width: 100%">
<slot name="default"></slot>
</div>
</div>
<div
v-for="scroll in scrollbars"
:key="scroll.direction"
v-show="scroll.visible"
v-bind="{ [`data-scroll-bar-${scroll.direction}`]: '' }"
:class="[
'pointer-events-none absolute z-auto h-full w-full rounded-full',
'data-[scroll-bar-horizontal]:bottom-0 data-[scroll-bar-horizontal]:left-0 data-[scroll-bar-horizontal]:h-2',
'data-[scroll-bar-vertical]:right-0 data-[scroll-bar-vertical]:top-0 data-[scroll-bar-vertical]:w-2',
]"
>
<div
v-bind="{ ['data-scroll-thumb']: scroll.direction }"
:class="[
'pointer-events-auto absolute h-full w-full rounded-full',
'cursor-pointer bg-black dark:bg-white',
'opacity-0 transition-opacity duration-300 group-hover/scroll:opacity-10',
]"
:style="{
[scrollbarAttrs[scroll.direction].size]: `${scroll.size}px`,
[scrollbarAttrs[scroll.direction].offset]: `${scroll.offset}px`,
opacity: isDragging ? 0.1 : '',
}"
@mousedown="startDragThumb"
></div>
</div>
</div>
</template>
<script setup lang="ts">
import { nextTick, onUnmounted, ref } from 'vue'
import { clamp, throttle } from 'lodash'
interface ScrollAreaProps {
scrollbar?: boolean
}
const props = withDefaults(defineProps<ScrollAreaProps>(), {
scrollbar: true,
})
const emit = defineEmits(['scroll', 'resize'])
type ScrollbarDirection = 'horizontal' | 'vertical'
interface Scrollbar {
direction: ScrollbarDirection
visible: boolean
size: number
offset: number
}
interface ScrollbarAttribute {
clientSize: string
scrollOffset: string
pagePosition: string
offset: string
size: string
}
const scrollbarAttrs: Record<ScrollbarDirection, ScrollbarAttribute> = {
horizontal: {
clientSize: 'clientWidth',
scrollOffset: 'scrollLeft',
pagePosition: 'pageX',
offset: 'left',
size: 'width',
},
vertical: {
clientSize: 'clientHeight',
scrollOffset: 'scrollTop',
pagePosition: 'pageY',
offset: 'top',
size: 'height',
},
}
const scrollbars = ref<Record<ScrollbarDirection, Scrollbar>>({
horizontal: {
direction: 'horizontal',
visible: props.scrollbar,
size: 0,
offset: 0,
},
vertical: {
direction: 'vertical',
visible: props.scrollbar,
size: 0,
offset: 0,
},
})
const isDragging = ref(false)
const onContainerResize: ResizeObserverCallback = throttle((entries) => {
emit('resize', entries)
if (isDragging.value) return
const entry = entries[0]
const container = entry.target as HTMLElement
const content = container.querySelector('[data-scroll-content]')!
const resolveScrollbarSize = (item: Scrollbar, attr: ScrollbarAttribute) => {
const containerSize: number = container[attr.clientSize]
const contentSize: number = content[attr.clientSize]
item.visible = props.scrollbar && contentSize > containerSize
item.size = Math.pow(containerSize, 2) / contentSize
}
nextTick(() => {
resolveScrollbarSize(scrollbars.value.horizontal, scrollbarAttrs.horizontal)
resolveScrollbarSize(scrollbars.value.vertical, scrollbarAttrs.vertical)
})
})
const onContentScroll = throttle((event: Event) => {
emit('scroll', event)
if (isDragging.value) return
const container = event.target as HTMLDivElement
const content = container.querySelector('[data-scroll-content]')!
const resolveOffset = (item: Scrollbar, attr: ScrollbarAttribute) => {
const containerSize = container[attr.clientSize]
const contentSize = content[attr.clientSize]
const scrollOffset = container[attr.scrollOffset]
item.offset =
(scrollOffset / (contentSize - containerSize)) *
(containerSize - item.size)
}
resolveOffset(scrollbars.value.horizontal, scrollbarAttrs.horizontal)
resolveOffset(scrollbars.value.vertical, scrollbarAttrs.vertical)
})
const viewport = ref<HTMLElement>()
const draggingDirection = ref<ScrollbarDirection>()
const prevDraggingEvent = ref<MouseEvent>()
const moveThumb = throttle((event: MouseEvent) => {
if (isDragging.value) {
const container = viewport.value!
const content = container.querySelector('[data-scroll-content]')!
const resolveOffset = (item: Scrollbar, attr: ScrollbarAttribute) => {
const containerSize = container[attr.clientSize]
const contentSize = content[attr.clientSize]
// Resolve thumb position
const prevPagePos = prevDraggingEvent.value![attr.pagePosition]
const currPagePos = event[attr.pagePosition]
const offset = currPagePos - prevPagePos
item.offset = clamp(item.offset + offset, 0, containerSize - item.size)
// Resolve scroll position
const scrollOffset = containerSize - item.size
const offsetSize = contentSize - containerSize
container[attr.scrollOffset] = (item.offset / scrollOffset) * offsetSize
}
const scrollDirection = draggingDirection.value!
resolveOffset(
scrollbars.value[scrollDirection],
scrollbarAttrs[scrollDirection],
)
prevDraggingEvent.value = event
}
})
const stopMoveThumb = () => {
isDragging.value = false
draggingDirection.value = undefined
prevDraggingEvent.value = undefined
document.removeEventListener('mousemove', moveThumb)
document.removeEventListener('mouseup', stopMoveThumb)
document.body.style.userSelect = ''
document.body.style.cursor = ''
}
const startDragThumb = (event: MouseEvent) => {
isDragging.value = true
const target = event.target as HTMLElement
draggingDirection.value = <any>target.getAttribute('data-scroll-thumb')
prevDraggingEvent.value = event
document.addEventListener('mousemove', moveThumb)
document.addEventListener('mouseup', stopMoveThumb)
document.body.style.userSelect = 'none'
document.body.style.cursor = 'default'
}
onUnmounted(() => {
stopMoveThumb()
})
defineExpose({
viewport,
})
</script>

View File

@@ -0,0 +1,234 @@
<template>
<slot
v-if="type === 'drop'"
name="target"
v-bind="{ toggle, prefixIcon, suffixIcon, currentLabel, current }"
>
<div :class="['-my-1 py-1', $attrs.class]" @click="toggle">
<Button
v-bind="{ rounded, text, severity, size }"
class="w-full whitespace-nowrap"
>
<slot name="prefix">
<span v-if="prefixIcon" class="p-button-icon p-button-icon-left">
<i :class="prefixIcon"></i>
</span>
</slot>
<span class="flex-1 overflow-scroll text-right scrollbar-none">
<slot name="label">{{ currentLabel }}</slot>
</span>
<slot name="suffix">
<span v-if="suffixIcon" class="p-button-icon p-button-icon-right">
<i :class="suffixIcon"></i>
</span>
</slot>
</Button>
</div>
</slot>
<div v-else class="relative flex-1 overflow-hidden">
<div
ref="scrollArea"
class="h-full w-full overflow-auto scrollbar-none"
v-resize="checkScrollPosition"
@scroll="checkScrollPosition"
>
<div ref="contentArea" class="table max-w-full">
<div
v-show="showControlButton && scrollPosition !== 'left'"
:class="[
'pointer-events-none absolute left-0 top-1/2 z-10',
'-translate-y-1/2 bg-gradient-to-r from-current to-transparent pr-16',
]"
style="color: var(--p-dialog-background)"
>
<Button
icon="pi pi-angle-left"
class="pointer-events-auto border-none bg-transparent"
severity="secondary"
@click="scrollTo('prev')"
:size="size"
></Button>
</div>
<div class="flex h-10 items-center gap-2">
<Button
v-for="item in items"
severity="secondary"
:key="item.value"
:data-active="current === item.value"
:active="current === item.value"
class="data-[active=true]:bg-blue-500 data-[active=true]:text-white"
:size="size"
@click="item.command"
>
<span class="whitespace-nowrap">{{ item.label }}</span>
</Button>
</div>
<div
v-show="showControlButton && scrollPosition !== 'right'"
:class="[
'pointer-events-none absolute right-0 top-1/2 z-10',
'-translate-y-1/2 bg-gradient-to-l from-current to-transparent pl-16',
]"
style="color: var(--p-dialog-background)"
>
<Button
:size="size"
icon="pi pi-angle-right"
class="pointer-events-auto border-none bg-transparent"
severity="secondary"
@click="scrollTo('next')"
></Button>
</div>
</div>
</div>
</div>
<slot v-if="isMobile" name="mobile">
<Drawer
v-model:visible="visible"
position="bottom"
style="height: auto; max-height: 80%"
>
<template #container>
<slot name="container">
<slot name="mobile:container">
<div class="h-full overflow-scroll scrollbar-none">
<Menu
:model="items"
pt:root:class="border-0 px-4 py-5"
:pt:list:onClick="toggle"
>
<template #item="{ item }">
<slot name="item" :item="item">
<slot name="mobile:container:item" :item="item">
<a class="p-menu-item-link justify-between">
<span
class="p-menu-item-label overflow-hidden break-words"
>
{{ item.label }}
</span>
<span v-show="current === item.value">
<i class="pi pi-check text-blue-400"></i>
</span>
</a>
</slot>
</slot>
</template>
</Menu>
</div>
</slot>
</slot>
</template>
</Drawer>
</slot>
<slot v-else name="desktop">
<slot name="container">
<slot name="desktop:container">
<Menu ref="menu" :model="items" :popup="true">
<template #item="{ item }">
<slot name="item" :item="item">
<slot name="desktop:container:item" :item="item">
<a class="p-menu-item-link justify-between">
<span class="p-menu-item-label">{{ item.label }}</span>
<span v-show="current === item.value">
<i class="pi pi-check text-blue-400"></i>
</span>
</a>
</slot>
</slot>
</template>
</Menu>
</slot>
</slot>
</slot>
</template>
<script setup lang="ts">
import { useConfig } from 'hooks/config'
import Button, { ButtonProps } from 'primevue/button'
import Drawer from 'primevue/drawer'
import Menu from 'primevue/menu'
import { computed, ref } from 'vue'
const current = defineModel()
interface Props {
items?: SelectOptions[]
rounded?: boolean
text?: boolean
severity?: ButtonProps['severity']
size?: ButtonProps['size']
type?: 'button' | 'drop'
}
const props = withDefaults(defineProps<Props>(), {
severity: 'secondary',
type: 'drop',
})
const suffixIcon = ref('pi pi-angle-down')
const prefixIcon = computed(() => {
return props.items?.find((item) => item.value === current.value)?.icon
})
const currentLabel = computed(() => {
return props.items?.find((item) => item.value === current.value)?.label
})
const menu = ref()
const visible = ref(false)
const { isMobile } = useConfig()
const toggle = (event: MouseEvent) => {
if (isMobile.value) {
visible.value = !visible.value
} else {
menu.value.toggle(event)
}
}
// Select Button Type
const scrollArea = ref()
const contentArea = ref()
type ScrollPosition = 'left' | 'right'
const scrollPosition = ref<ScrollPosition | undefined>('left')
const showControlButton = ref<boolean>(true)
const scrollTo = (type: 'prev' | 'next') => {
const container = scrollArea.value as HTMLDivElement
const scrollLeft = container.scrollLeft
const direction = type === 'prev' ? -1 : 1
const distance = (container.clientWidth / 3) * 2
container.scrollTo({
left: scrollLeft + direction * distance,
behavior: 'smooth',
})
}
const checkScrollPosition = () => {
const container = scrollArea.value as HTMLDivElement
const content = contentArea.value as HTMLDivElement
const scrollLeft = container.scrollLeft
const containerWidth = container.clientWidth
const contentWidth = content.clientWidth
let position: ScrollPosition | undefined = undefined
if (scrollLeft === 0) {
position = 'left'
}
if (Math.ceil(scrollLeft) >= contentWidth - containerWidth) {
position = 'right'
}
scrollPosition.value = position
showControlButton.value = contentWidth > containerWidth
}
</script>