Files
tmc-test-task/frontend/app/components/RightPanel.vue
2026-06-03 20:21:43 +03:00

217 lines
4.0 KiB
Vue

<template>
<div class="panel">
<div class="panel-header">
<h2>Selected items</h2>
<div class="panel-controls">
<input
class="search-input"
type="text"
placeholder="Search by ID..."
:value="search"
@input="onSearchInput"
>
</div>
</div>
<div class="panel-body">
<VueDraggableNext
v-model="localItems"
tag="ul"
class="item-list"
handle=".drag-handle"
@end="onDragEnd"
>
<li v-for="item in localItems" :key="item.id" class="item-row">
<span class="drag-handle" title="Drag to reorder"></span>
<span class="item-label">#{{ item.id }}</span>
<button class="btn btn-icon" title="Remove from selected" @click="emit('deselect', item.id!)">
</button>
</li>
</VueDraggableNext>
<div v-if="loading" class="loading-text">
Loading...
</div>
<div v-if="hasMore && !loading" ref="sentinel" class="sentinel" />
<div v-if="!loading && !hasMore && items.length === 0" class="empty-text">
No items selected
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Item } from '~/services/api'
import { onUnmounted, ref, watch } from 'vue'
import { VueDraggableNext } from 'vue-draggable-next'
const props = defineProps<{
items: Item[]
loading: boolean
hasMore: boolean
search: string
}>()
const emit = defineEmits<{
(e: 'update:search', val: string): void
(e: 'loadMore'): void
(e: 'deselect', id: number): void
(e: 'reorder', ids: number[]): void
}>()
const sentinel = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | null = null
watch(sentinel, (el) => {
if (observer) {
observer.disconnect()
observer = null
}
if (el) {
observer = new IntersectionObserver((entries) => {
if (entries[0]?.isIntersecting && props.hasMore && !props.loading) {
emit('loadMore')
}
}, { threshold: 0.1 })
observer.observe(el)
}
})
onUnmounted(() => {
observer?.disconnect()
})
let debounceTimer: ReturnType<typeof setTimeout> | null = null
function onSearchInput(e: Event) {
const val = (e.target as HTMLInputElement).value
if (debounceTimer)
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
emit('update:search', val)
}, 300)
}
const localItems = ref([...props.items])
watch(() => props.items, (val) => {
localItems.value = [...val]
}, { deep: true })
function onDragEnd(): void {
const ids = localItems.value.map(i => i.id!)
emit('reorder', ids)
}
</script>
<style scoped lang="scss">
.panel {
display: flex;
flex-direction: column;
height: 100%;
}
.panel-header {
padding: 16px;
border-bottom: 1px solid #e5e7eb;
background: #f9fafb;
h2 {
margin: 0 0 12px;
font-size: 1.1rem;
font-weight: 600;
}
}
.panel-controls {
display: flex;
gap: 8px;
}
.search-input {
flex: 1;
padding: 6px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.9rem;
outline: none;
&:focus {
border-color: #6366f1;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.15);
}
}
.btn {
padding: 6px 14px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
cursor: pointer;
font-size: 0.9rem;
white-space: nowrap;
&:hover {
background: #f3f4f6;
}
&.btn-icon {
padding: 4px 10px;
font-size: 1rem;
}
}
.panel-body {
flex: 1;
overflow-y: auto;
}
.item-list {
list-style: none;
margin: 0;
padding: 0;
}
.item-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-bottom: 1px solid #f3f4f6;
cursor: default;
&:hover {
background: #f9fafb;
}
}
.drag-handle {
cursor: grab;
color: #9ca3af;
font-size: 1.1rem;
user-select: none;
&:active {
cursor: grabbing;
}
}
.item-label {
flex: 1;
font-size: 0.9rem;
color: #374151;
}
.loading-text,
.empty-text {
padding: 16px;
text-align: center;
color: #9ca3af;
font-size: 0.9rem;
}
.sentinel {
height: 1px;
}
</style>