235 lines
5.4 KiB
Svelte
235 lines
5.4 KiB
Svelte
<script>
|
|
import { getEntries, searchEntries } from '../lib/storage/db.js'
|
|
import { search as searchStore } from '../lib/stores/search.svelte.js'
|
|
|
|
let entries = $state([])
|
|
let loading = $state(true)
|
|
let error = $state('')
|
|
let resultCount = $state(0)
|
|
let dragging = $state(false)
|
|
|
|
let { onSelect, onAdd } = $props()
|
|
|
|
async function loadEntries() {
|
|
loading = true
|
|
error = ''
|
|
try {
|
|
const query = searchStore.debouncedQuery.trim()
|
|
const groupId = searchStore.activeGroupId
|
|
|
|
if (query) {
|
|
// Search with optional group filter
|
|
const options = groupId !== 'all' ? { groupId } : {}
|
|
entries = await searchEntries(query, options)
|
|
} else if (groupId !== 'all') {
|
|
// Filter by group only
|
|
entries = await getEntries({ groupId })
|
|
} else {
|
|
// Show all
|
|
entries = await getEntries()
|
|
}
|
|
|
|
resultCount = entries.length
|
|
} catch (e) {
|
|
error = 'Failed to load entries: ' + e.message
|
|
}
|
|
loading = false
|
|
}
|
|
|
|
// Reload when debounced search query, active group filter, or refresh trigger changes
|
|
$effect(() => {
|
|
searchStore.debouncedQuery
|
|
searchStore.activeGroupId
|
|
searchStore.refreshTrigger
|
|
loadEntries()
|
|
})
|
|
</script>
|
|
|
|
<div class="entry-list">
|
|
{#if loading}
|
|
<div class="loading">Loading entries...</div>
|
|
{:else if error}
|
|
<div class="error-banner">{error}</div>
|
|
{:else if entries.length === 0}
|
|
<div class="empty-state">
|
|
<p class="empty-icon">{searchStore.query ? '🔍' : '🔑'}</p>
|
|
<p class="empty-text">{searchStore.query ? 'No results found' : 'No entries yet'}</p>
|
|
<p class="empty-hint">
|
|
{searchStore.query
|
|
? 'Try a different search term'
|
|
: 'Add your first login credential to get started'}
|
|
</p>
|
|
{#if !searchStore.query}
|
|
<button class="btn btn-primary mt-3" onclick={onAdd}>+ New Entry</button>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<div class="results-info">
|
|
<span class="text-sm text-muted">
|
|
{resultCount} entr{resultCount === 1 ? 'y' : 'ies'}
|
|
{#if searchStore.query}
|
|
matching "<strong>{searchStore.query}</strong>"
|
|
{/if}
|
|
</span>
|
|
</div>
|
|
|
|
<table class="entries-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Title</th>
|
|
<th>Username</th>
|
|
<th>URL</th>
|
|
<th>Notes</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each entries as entry (entry.id)}
|
|
<tr
|
|
draggable={true}
|
|
onclick={() => onSelect(entry.id)}
|
|
ondragstart={(e) => { dragging = true; e.dataTransfer.setData('text/plain', entry.id); e.dataTransfer.effectAllowed = 'move'; }}
|
|
ondragend={() => { dragging = false; }}
|
|
class="entry-row {dragging ? 'dragging' : ''}"
|
|
>
|
|
<td>
|
|
<span class="drag-handle" aria-hidden="true">⠿</span>
|
|
<span class="entry-title">{entry.title}</span>
|
|
</td>
|
|
<td>
|
|
<span class="entry-username">{entry.username || '—'}</span>
|
|
</td>
|
|
<td>
|
|
<span class="entry-url truncate">{entry.url || '—'}</span>
|
|
</td>
|
|
<td>
|
|
<span class="entry-notes truncate">{entry.notes || '—'}</span>
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.loading, .empty-state {
|
|
text-align: center;
|
|
padding: 3rem 1rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.error-banner {
|
|
padding: 12px 16px;
|
|
background: rgba(229, 72, 77, 0.15);
|
|
border: 1px solid rgba(229, 72, 77, 0.4);
|
|
border-radius: var(--radius-md);
|
|
color: var(--color-danger);
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.empty-icon {
|
|
font-size: 3rem;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.empty-text {
|
|
font-size: 1.1rem;
|
|
font-weight: 500;
|
|
color: var(--color-text);
|
|
}
|
|
|
|
.empty-hint {
|
|
font-size: 0.85rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.results-info {
|
|
padding: 8px 0;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.entries-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
.entries-table th {
|
|
text-align: left;
|
|
padding: 8px 12px;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--color-text-muted);
|
|
border-bottom: 1px solid var(--color-border);
|
|
}
|
|
|
|
.entry-row {
|
|
cursor: grab;
|
|
transition: background-color 150ms, opacity 150ms;
|
|
}
|
|
|
|
.entry-row:active {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
.entry-row:hover {
|
|
background: var(--color-surface-hover);
|
|
}
|
|
|
|
.entry-row.dragging {
|
|
opacity: 0.35;
|
|
}
|
|
|
|
.entry-row td {
|
|
padding: 10px 12px;
|
|
font-size: 0.875rem;
|
|
border-bottom: 1px solid var(--color-border);
|
|
}
|
|
|
|
.drag-handle {
|
|
color: var(--color-text-muted);
|
|
opacity: 0.3;
|
|
margin-right: 6px;
|
|
font-size: 0.9rem;
|
|
user-select: none;
|
|
transition: opacity 150ms;
|
|
}
|
|
|
|
.entry-row:hover .drag-handle {
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.entry-title {
|
|
font-weight: 500;
|
|
}
|
|
|
|
.entry-username {
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.entry-url {
|
|
color: var(--color-text-muted);
|
|
max-width: 200px;
|
|
}
|
|
|
|
.entry-notes {
|
|
color: var(--color-text-muted);
|
|
max-width: 200px;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.entries-table th:nth-child(4),
|
|
.entry-row td:nth-child(4) {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 600px) {
|
|
.entries-table th:nth-child(3),
|
|
.entry-row td:nth-child(3) {
|
|
display: none;
|
|
}
|
|
}
|
|
</style>
|