You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
263 lines
5.6 KiB
263 lines
5.6 KiB
<script setup lang="ts">
|
|
export interface CategoryNode {
|
|
id: string
|
|
name: string
|
|
image?: string
|
|
count?: number
|
|
children?: CategoryNode[]
|
|
}
|
|
|
|
const props = defineProps<{
|
|
node: CategoryNode
|
|
activeId?: string
|
|
level?: number
|
|
expandedIds: Set<string>
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
select: [id: string]
|
|
contextmenu: [node: CategoryNode, event: MouseEvent]
|
|
toggleExpand: [id: string]
|
|
dropCard: [categoryId: string | null, categoryName: string]
|
|
}>()
|
|
|
|
defineOptions({ name: 'IndexCategoryTreeNode' })
|
|
|
|
const level = computed(() => props.level ?? 0)
|
|
const hasChildren = computed(() => !!props.node.children && props.node.children.length > 0)
|
|
const isExpanded = computed(() => props.expandedIds.has(props.node.id))
|
|
const isActive = computed(() => props.activeId === props.node.id)
|
|
const dragOver = ref(false)
|
|
|
|
const imgLoaded = ref(false)
|
|
const imgFailed = ref(false)
|
|
|
|
function onClick() {
|
|
emit('select', props.node.id)
|
|
}
|
|
|
|
function onDragOver(e: DragEvent) {
|
|
if (!e.dataTransfer?.types.includes('application/x-card-id')) return
|
|
e.preventDefault()
|
|
e.dataTransfer.dropEffect = 'move'
|
|
dragOver.value = true
|
|
}
|
|
|
|
function onDragLeave() {
|
|
dragOver.value = false
|
|
}
|
|
|
|
function onDrop(e: DragEvent) {
|
|
dragOver.value = false
|
|
if (!e.dataTransfer?.types.includes('application/x-card-id')) return
|
|
emit('dropCard', props.node.id === '__uncategorized__' ? null : props.node.id, props.node.name)
|
|
}
|
|
|
|
function onContextMenu(event: MouseEvent) {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
emit('contextmenu', props.node, event)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="tree-node">
|
|
<div
|
|
class="tree-row"
|
|
:class="{ active: isActive, 'drag-over': dragOver }"
|
|
:style="{ paddingLeft: `${10 + level * 14}px` }"
|
|
@click="onClick"
|
|
@contextmenu="onContextMenu"
|
|
@dragover="onDragOver"
|
|
@dragleave="onDragLeave"
|
|
@drop="onDrop"
|
|
>
|
|
<button
|
|
v-if="hasChildren"
|
|
class="tree-chevron"
|
|
:class="{ expanded: isExpanded }"
|
|
@click.stop="emit('toggleExpand', node.id)"
|
|
>
|
|
<Icon name="lucide:chevron-right" />
|
|
</button>
|
|
<span v-else class="tree-chevron-spacer" />
|
|
|
|
<div class="tree-thumb">
|
|
<img
|
|
v-if="node.image && !imgFailed"
|
|
:src="node.image"
|
|
:alt="node.name"
|
|
loading="lazy"
|
|
:class="{ loaded: imgLoaded }"
|
|
@load="imgLoaded = true"
|
|
@error="imgFailed = true"
|
|
>
|
|
<span v-else class="tree-thumb-fallback">
|
|
<Icon name="lucide:folder" />
|
|
</span>
|
|
</div>
|
|
|
|
<span class="tree-name">{{ node.name }}</span>
|
|
<span v-if="typeof node.count === 'number'" class="tree-count">{{ node.count }}</span>
|
|
</div>
|
|
|
|
<div v-if="hasChildren && isExpanded" class="tree-children">
|
|
<IndexCategoryTreeNode
|
|
v-for="child in node.children"
|
|
:key="child.id"
|
|
:node="child"
|
|
:active-id="activeId"
|
|
:level="level + 1"
|
|
:expanded-ids="expandedIds"
|
|
@select="(id) => emit('select', id)"
|
|
@contextmenu="(n, e) => emit('contextmenu', n, e)"
|
|
@toggle-expand="(id) => emit('toggleExpand', id)"
|
|
@drop-card="(catId, catName) => emit('dropCard', catId, catName)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.tree-node {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.tree-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 6px 8px 6px 10px;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
transition: background 0.12s ease;
|
|
margin-right: 6px;
|
|
}
|
|
|
|
.tree-row:hover {
|
|
background: var(--color-surface-soft);
|
|
}
|
|
|
|
.tree-row.active {
|
|
background: var(--color-surface-card);
|
|
}
|
|
|
|
.tree-row.drag-over {
|
|
background: rgba(204, 120, 92, 0.12);
|
|
outline: 2px dashed var(--color-primary);
|
|
outline-offset: -2px;
|
|
}
|
|
|
|
.tree-row.active .tree-name {
|
|
color: var(--color-ink);
|
|
}
|
|
|
|
.tree-chevron {
|
|
width: 22px;
|
|
height: 22px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
color: var(--color-muted-soft);
|
|
padding: 0;
|
|
flex-shrink: 0;
|
|
border-radius: 5px;
|
|
transition: transform 0.18s ease, color 0.12s ease, background 0.12s ease;
|
|
}
|
|
|
|
.tree-chevron :deep(svg) {
|
|
width: 14px;
|
|
height: 14px;
|
|
}
|
|
|
|
.tree-chevron:hover {
|
|
color: var(--color-ink);
|
|
background: var(--color-surface-card);
|
|
}
|
|
|
|
.tree-chevron.expanded {
|
|
transform: rotate(90deg);
|
|
}
|
|
|
|
.tree-chevron-spacer {
|
|
width: 22px;
|
|
height: 22px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.tree-thumb {
|
|
position: relative;
|
|
width: 22px;
|
|
height: 22px;
|
|
border-radius: 5px;
|
|
overflow: hidden;
|
|
background: var(--color-surface-card);
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.tree-thumb img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
opacity: 0;
|
|
transition: opacity 0.3s ease;
|
|
}
|
|
|
|
.tree-thumb img.loaded {
|
|
opacity: 1;
|
|
}
|
|
|
|
.tree-thumb-fallback {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--color-muted-soft);
|
|
}
|
|
|
|
.tree-thumb-fallback :deep(svg) {
|
|
width: 14px;
|
|
height: 14px;
|
|
}
|
|
|
|
.tree-name {
|
|
flex: 1;
|
|
font-family: var(--font-body);
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: var(--color-body);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.tree-row:hover .tree-name {
|
|
color: var(--color-ink);
|
|
}
|
|
|
|
.tree-count {
|
|
font-family: var(--font-body);
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
color: var(--color-muted-soft);
|
|
background: transparent;
|
|
padding: 0 4px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.tree-row.active .tree-count {
|
|
color: var(--color-primary);
|
|
}
|
|
|
|
.tree-children {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
</style>
|
|
|