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

<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>