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

261 lines
5.0 KiB
Vue

<template>
<div class="panel">
<div class="panel-header">
<h2>All items</h2>
<div class="panel-controls">
<input
class="search-input"
type="text"
placeholder="Search by ID..."
:value="search"
@input="onSearchInput"
>
<button class="btn btn-primary" @click="showAddModal = true">
Add item
</button>
</div>
</div>
<div class="panel-body">
<ul class="item-list">
<li v-for="item in items" :key="item.id" class="item-row">
<span class="item-label">#{{ item.id }}</span>
<button class="btn btn-icon" title="Move to selected" @click="emit('select', item.id!)">
</button>
</li>
</ul>
<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 found
</div>
</div>
<div v-if="showAddModal" class="modal-overlay" @click.self="showAddModal = false">
<div class="modal">
<h3>Add new item</h3>
<input
v-model.number="addIdInput"
class="search-input"
type="number"
placeholder="Enter item ID"
@keydown.enter="submitAdd"
>
<div class="modal-actions">
<button class="btn" @click="showAddModal = false">
Cancel
</button>
<button class="btn btn-primary" @click="submitAdd">
Add
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Item } from '~/services/api'
import { onUnmounted, ref, watch } from 'vue'
const props = defineProps<{
items: Item[]
loading: boolean
hasMore: boolean
search: string
}>()
const emit = defineEmits<{
(e: 'update:search', val: string): void
(e: 'loadMore'): void
(e: 'select', id: number): void
(e: 'add', id: number): void
}>()
const sentinel = ref<HTMLElement | null>(null)
const showAddModal = ref(false)
const addIdInput = ref<number | 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)
}
function submitAdd() {
if (addIdInput.value !== null && Number.isInteger(addIdInput.value)) {
emit('add', addIdInput.value)
addIdInput.value = null
showAddModal.value = false
}
}
</script>
<style scoped lang="scss">
.panel {
display: flex;
flex-direction: column;
height: 100%;
border-right: 1px solid #e5e7eb;
}
.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-primary {
background: #6366f1;
color: #fff;
border-color: #6366f1;
&:hover {
background: #4f46e5;
}
}
&.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;
justify-content: space-between;
padding: 8px 16px;
border-bottom: 1px solid #f3f4f6;
&:hover {
background: #f9fafb;
}
}
.item-label {
font-size: 0.9rem;
color: #374151;
}
.loading-text,
.empty-text {
padding: 16px;
text-align: center;
color: #9ca3af;
font-size: 0.9rem;
}
.sentinel {
height: 1px;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
background: #fff;
border-radius: 10px;
padding: 24px;
min-width: 320px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
h3 {
margin: 0 0 16px;
font-size: 1rem;
font-weight: 600;
}
}
.modal-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 16px;
}
</style>