Add "Trash"
This commit is contained in:
parent
47609c9e7c
commit
46c49655a7
724
dist/index.html
vendored
724
dist/index.html
vendored
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,8 @@
|
|||||||
<script>
|
<script>
|
||||||
import { getEntryById, deleteEntry } from '../lib/storage/db.js'
|
import { getEntryById, moveToTrash, emptyTrash } from '../lib/storage/db.js'
|
||||||
import { decrypt } from '../lib/crypto/crypto.js'
|
import { decrypt } from '../lib/crypto/crypto.js'
|
||||||
import { app } from '../lib/stores/app.svelte.js'
|
import { app } from '../lib/stores/app.svelte.js'
|
||||||
|
import { isTrashGroup } from '../lib/models/schema.js'
|
||||||
|
|
||||||
let { entryId, onEdit, onBack } = $props()
|
let { entryId, onEdit, onBack } = $props()
|
||||||
|
|
||||||
@ -11,8 +12,10 @@
|
|||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
let error = $state('')
|
let error = $state('')
|
||||||
let showDeleteConfirm = $state(false)
|
let showDeleteConfirm = $state(false)
|
||||||
|
let showPermanentDeleteConfirm = $state(false)
|
||||||
let deleting = $state(false)
|
let deleting = $state(false)
|
||||||
let toast = $state('')
|
let toast = $state('')
|
||||||
|
const isInTrash = $derived(entry && isTrashGroup(entry.groupId))
|
||||||
let toastTimer = null
|
let toastTimer = null
|
||||||
|
|
||||||
async function loadEntry() {
|
async function loadEntry() {
|
||||||
@ -64,17 +67,31 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleMoveToTrash() {
|
||||||
deleting = true
|
deleting = true
|
||||||
try {
|
try {
|
||||||
await deleteEntry(entryId)
|
await moveToTrash(entryId)
|
||||||
onBack()
|
onBack()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = 'Failed to delete: ' + e.message
|
error = 'Failed to move to trash: ' + e.message
|
||||||
}
|
}
|
||||||
deleting = false
|
deleting = false
|
||||||
showDeleteConfirm = false
|
showDeleteConfirm = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handlePermanentDelete() {
|
||||||
|
// Move to trash first (if not already), then empty trash
|
||||||
|
deleting = true
|
||||||
|
try {
|
||||||
|
await moveToTrash(entryId)
|
||||||
|
await emptyTrash()
|
||||||
|
onBack()
|
||||||
|
} catch (e) {
|
||||||
|
error = 'Failed to permanently delete: ' + e.message
|
||||||
|
}
|
||||||
|
deleting = false
|
||||||
|
showPermanentDeleteConfirm = false
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="entry-detail">
|
<div class="entry-detail">
|
||||||
@ -94,8 +111,13 @@
|
|||||||
<div class="detail-header">
|
<div class="detail-header">
|
||||||
<h2>{entry.title}</h2>
|
<h2>{entry.title}</h2>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
{#if isInTrash}
|
||||||
|
<button class="btn btn-primary btn-sm" onclick={() => onEdit(entry.id)}>↩️ Restore</button>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick={() => showPermanentDeleteConfirm = true}>🗑 Delete Forever</button>
|
||||||
|
{:else}
|
||||||
<button class="btn btn-ghost btn-sm" onclick={() => onEdit(entry.id)}>✏️ Edit</button>
|
<button class="btn btn-ghost btn-sm" onclick={() => onEdit(entry.id)}>✏️ Edit</button>
|
||||||
<button class="btn btn-danger btn-sm" onclick={() => showDeleteConfirm = true}>🗑 Delete</button>
|
<button class="btn btn-danger btn-sm" onclick={() => showDeleteConfirm = true}>🗑 Move to Trash</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -145,22 +167,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete confirmation modal -->
|
<!-- Move to Trash confirmation modal -->
|
||||||
{#if showDeleteConfirm}
|
{#if showDeleteConfirm}
|
||||||
<div class="modal-overlay" role="presentation" onclick={() => showDeleteConfirm = false}>
|
<div class="modal-overlay" role="presentation" onclick={() => showDeleteConfirm = false}>
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
<div class="modal" role="dialog" aria-modal="true" aria-label="Delete confirmation" tabindex="-1" onclick={(e) => e.stopPropagation()}>
|
<div class="modal" role="dialog" aria-modal="true" aria-label="Move to trash confirmation" tabindex="-1" onclick={(e) => e.stopPropagation()}>
|
||||||
<h3>Delete Entry</h3>
|
<h3>Move to Trash</h3>
|
||||||
<p>Are you sure you want to delete "<strong>{entry.title}</strong>"? This cannot be undone.</p>
|
<p>Move "<strong>{entry.title}</strong>" to the trash? You can restore it later.</p>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-danger" onclick={handleDelete} disabled={deleting}>
|
<button class="btn btn-danger" onclick={handleMoveToTrash} disabled={deleting}>
|
||||||
{deleting ? 'Deleting...' : 'Yes, delete'}
|
{deleting ? 'Moving...' : 'Move to Trash'}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-ghost" onclick={() => showDeleteConfirm = false}>Cancel</button>
|
<button class="btn btn-ghost" onclick={() => showDeleteConfirm = false}>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Permanent delete confirmation modal -->
|
||||||
|
{#if showPermanentDeleteConfirm}
|
||||||
|
<div class="modal-overlay" role="presentation" onclick={() => showPermanentDeleteConfirm = false}>
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<div class="modal" role="dialog" aria-modal="true" aria-label="Permanent delete confirmation" tabindex="-1" onclick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>Delete Forever</h3>
|
||||||
|
<p>Permanently delete "<strong>{entry.title}</strong>"? This cannot be undone.</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-danger" onclick={handlePermanentDelete} disabled={deleting}>
|
||||||
|
{deleting ? 'Deleting...' : 'Delete Forever'}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost" onclick={() => showPermanentDeleteConfirm = false}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { addEntry, updateEntry, getEntryById, getGroups } from '../lib/storage/db.js'
|
import { addEntry, updateEntry, getEntryById, getGroups } from '../lib/storage/db.js'
|
||||||
import { encrypt, decrypt } from '../lib/crypto/crypto.js'
|
import { encrypt, decrypt } from '../lib/crypto/crypto.js'
|
||||||
import { createEntry, updateEntry as updateEntryModel, validateEntry } from '../lib/models/schema.js'
|
import { createEntry, updateEntry as updateEntryModel, validateEntry, isTrashGroup } from '../lib/models/schema.js'
|
||||||
import { generatePassword } from '../lib/crypto/crypto.js'
|
import { generatePassword } from '../lib/crypto/crypto.js'
|
||||||
import { app } from '../lib/stores/app.svelte.js'
|
import { app } from '../lib/stores/app.svelte.js'
|
||||||
import PasswordGenerator from './PasswordGenerator.svelte'
|
import PasswordGenerator from './PasswordGenerator.svelte'
|
||||||
@ -151,7 +151,9 @@ import { autofocus } from '../lib/autofocus.js'
|
|||||||
<select id="group" bind:value={groupId}>
|
<select id="group" bind:value={groupId}>
|
||||||
<option value="">No group</option>
|
<option value="">No group</option>
|
||||||
{#each groups as group}
|
{#each groups as group}
|
||||||
|
{#if !isTrashGroup(group.id)}
|
||||||
<option value={group.id}>{group.name}</option>
|
<option value={group.id}>{group.name}</option>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { getEntries, searchEntries } from '../lib/storage/db.js'
|
import { getEntries, searchEntries, restoreEntry, TRASH_GROUP_ID } from '../lib/storage/db.js'
|
||||||
import { search as searchStore } from '../lib/stores/search.svelte.js'
|
import { search as searchStore } from '../lib/stores/search.svelte.js'
|
||||||
|
|
||||||
let entries = $state([])
|
let entries = $state([])
|
||||||
@ -10,6 +10,8 @@
|
|||||||
|
|
||||||
let { onSelect, onAdd } = $props()
|
let { onSelect, onAdd } = $props()
|
||||||
|
|
||||||
|
const isTrashView = $derived(searchStore.activeGroupId === 'trash')
|
||||||
|
|
||||||
async function loadEntries() {
|
async function loadEntries() {
|
||||||
loading = true
|
loading = true
|
||||||
error = ''
|
error = ''
|
||||||
@ -17,16 +19,19 @@
|
|||||||
const query = searchStore.debouncedQuery.trim()
|
const query = searchStore.debouncedQuery.trim()
|
||||||
const groupId = searchStore.activeGroupId
|
const groupId = searchStore.activeGroupId
|
||||||
|
|
||||||
|
// Resolve 'trash' alias to the real Trash group ID for DB queries
|
||||||
|
const resolvedGroupId = groupId === 'trash' ? TRASH_GROUP_ID : groupId
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
// Search with optional group filter
|
// Search with optional group filter
|
||||||
const options = groupId !== 'all' ? { groupId } : {}
|
const options = resolvedGroupId !== 'all' ? { groupId: resolvedGroupId } : {}
|
||||||
entries = await searchEntries(query, options)
|
entries = await searchEntries(query, options)
|
||||||
} else if (groupId !== 'all') {
|
} else if (resolvedGroupId !== 'all') {
|
||||||
// Filter by group only
|
// Filter by group only
|
||||||
entries = await getEntries({ groupId })
|
entries = await getEntries({ groupId: resolvedGroupId })
|
||||||
} else {
|
} else {
|
||||||
// Show all
|
// Show all (excluding trashed entries)
|
||||||
entries = await getEntries()
|
entries = (await getEntries()).filter(e => e.groupId !== TRASH_GROUP_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
resultCount = entries.length
|
resultCount = entries.length
|
||||||
@ -36,6 +41,15 @@
|
|||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleRestore(entryId) {
|
||||||
|
try {
|
||||||
|
await restoreEntry(entryId)
|
||||||
|
searchStore.refresh()
|
||||||
|
} catch (e) {
|
||||||
|
error = 'Failed to restore: ' + e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reload when debounced search query, active group filter, or refresh trigger changes
|
// Reload when debounced search query, active group filter, or refresh trigger changes
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
searchStore.debouncedQuery
|
searchStore.debouncedQuery
|
||||||
@ -52,14 +66,14 @@
|
|||||||
<div class="error-banner">{error}</div>
|
<div class="error-banner">{error}</div>
|
||||||
{:else if entries.length === 0}
|
{:else if entries.length === 0}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<p class="empty-icon">{searchStore.query ? '🔍' : '🔑'}</p>
|
<p class="empty-icon">{searchStore.query ? '🔍' : (isTrashView ? '🗑' : '🔑')}</p>
|
||||||
<p class="empty-text">{searchStore.query ? 'No results found' : 'No entries yet'}</p>
|
<p class="empty-text">{searchStore.query ? 'No results found' : (isTrashView ? 'Trash is empty' : 'No entries yet')}</p>
|
||||||
<p class="empty-hint">
|
<p class="empty-hint">
|
||||||
{searchStore.query
|
{searchStore.query
|
||||||
? 'Try a different search term'
|
? 'Try a different search term'
|
||||||
: 'Add your first login credential to get started'}
|
: (isTrashView ? 'Deleted entries will appear here' : 'Add your first login credential to get started')}
|
||||||
</p>
|
</p>
|
||||||
{#if !searchStore.query}
|
{#if !searchStore.query && !isTrashView}
|
||||||
<button class="btn btn-primary mt-3" onclick={onAdd}>+ New Entry</button>
|
<button class="btn btn-primary mt-3" onclick={onAdd}>+ New Entry</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@ -80,19 +94,24 @@
|
|||||||
<th>Username</th>
|
<th>Username</th>
|
||||||
<th>URL</th>
|
<th>URL</th>
|
||||||
<th>Notes</th>
|
<th>Notes</th>
|
||||||
|
{#if isTrashView}
|
||||||
|
<th style="width: 60px"></th>
|
||||||
|
{/if}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each entries as entry (entry.id)}
|
{#each entries as entry (entry.id)}
|
||||||
<tr
|
<tr
|
||||||
draggable={true}
|
draggable={!isTrashView}
|
||||||
onclick={() => onSelect(entry.id)}
|
onclick={() => onSelect(entry.id)}
|
||||||
ondragstart={(e) => { dragging = true; e.dataTransfer.setData('text/plain', entry.id); e.dataTransfer.effectAllowed = 'move'; }}
|
ondragstart={(e) => { if (!isTrashView) { dragging = true; e.dataTransfer.setData('text/plain', entry.id); e.dataTransfer.effectAllowed = 'move'; } }}
|
||||||
ondragend={() => { dragging = false; }}
|
ondragend={() => { dragging = false; }}
|
||||||
class="entry-row {dragging ? 'dragging' : ''}"
|
class="entry-row {dragging ? 'dragging' : ''}"
|
||||||
>
|
>
|
||||||
<td>
|
<td>
|
||||||
|
{#if !isTrashView}
|
||||||
<span class="drag-handle" aria-hidden="true">⠿</span>
|
<span class="drag-handle" aria-hidden="true">⠿</span>
|
||||||
|
{/if}
|
||||||
<span class="entry-title">{entry.title}</span>
|
<span class="entry-title">{entry.title}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@ -104,6 +123,11 @@
|
|||||||
<td>
|
<td>
|
||||||
<span class="entry-notes truncate">{entry.notes || '—'}</span>
|
<span class="entry-notes truncate">{entry.notes || '—'}</span>
|
||||||
</td>
|
</td>
|
||||||
|
{#if isTrashView}
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-ghost btn-sm restore-btn" onclick={(e) => { e.stopPropagation(); handleRestore(entry.id); }} title="Restore entry">↩️</button>
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -200,6 +224,11 @@
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.restore-btn {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.entry-title {
|
.entry-title {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { app } from '../lib/stores/app.svelte.js'
|
import { app } from '../lib/stores/app.svelte.js'
|
||||||
import { deriveKey, createTestPayload, verifyPassword } from '../lib/crypto/crypto.js'
|
import { deriveKey, createTestPayload, verifyPassword } from '../lib/crypto/crypto.js'
|
||||||
import { saveVaultMeta, loadVaultMeta, isVaultInitialized } from '../lib/storage/db.js'
|
import { saveVaultMeta, loadVaultMeta, isVaultInitialized, ensureTrashGroup } from '../lib/storage/db.js'
|
||||||
import { startAutoLock } from '../lib/stores/security.svelte.js'
|
import { startAutoLock } from '../lib/stores/security.svelte.js'
|
||||||
import { autofocus } from '../lib/autofocus.js'
|
import { autofocus } from '../lib/autofocus.js'
|
||||||
|
|
||||||
@ -41,6 +41,7 @@
|
|||||||
app.encryptionKey = key
|
app.encryptionKey = key
|
||||||
|
|
||||||
await saveVaultMeta(salt, testEncrypted, testPlaintext)
|
await saveVaultMeta(salt, testEncrypted, testPlaintext)
|
||||||
|
await ensureTrashGroup()
|
||||||
app.isUnlocked = true
|
app.isUnlocked = true
|
||||||
startAutoLock()
|
startAutoLock()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
<script>
|
<script>
|
||||||
import { app } from '../lib/stores/app.svelte.js'
|
import { app } from '../lib/stores/app.svelte.js'
|
||||||
|
import { search as searchStore } from '../lib/stores/search.svelte.js'
|
||||||
|
import { TRASH_GROUP_NAME } from '../lib/models/schema.js'
|
||||||
|
import { emptyTrash } from '../lib/storage/db.js'
|
||||||
import Sidebar from './Sidebar.svelte'
|
import Sidebar from './Sidebar.svelte'
|
||||||
import EntryList from './EntryList.svelte'
|
import EntryList from './EntryList.svelte'
|
||||||
import EntryDetail from './EntryDetail.svelte'
|
import EntryDetail from './EntryDetail.svelte'
|
||||||
@ -9,6 +12,10 @@
|
|||||||
let sidebarOpen = $state(false)
|
let sidebarOpen = $state(false)
|
||||||
let viewMode = $state('list') // 'list' | 'detail' | 'form'
|
let viewMode = $state('list') // 'list' | 'detail' | 'form'
|
||||||
let selectedEntryId = $state(null)
|
let selectedEntryId = $state(null)
|
||||||
|
let showEmptyTrashConfirm = $state(false)
|
||||||
|
let emptyingTrash = $state(false)
|
||||||
|
|
||||||
|
const isTrashView = $derived(searchStore.activeGroupId === 'trash')
|
||||||
|
|
||||||
function goList() {
|
function goList() {
|
||||||
viewMode = 'list'
|
viewMode = 'list'
|
||||||
@ -36,6 +43,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleEmptyTrash() {
|
||||||
|
emptyingTrash = true
|
||||||
|
try {
|
||||||
|
await emptyTrash()
|
||||||
|
searchStore.activeGroupId = 'all'
|
||||||
|
showEmptyTrashConfirm = false
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to empty trash:', e)
|
||||||
|
}
|
||||||
|
emptyingTrash = false
|
||||||
|
}
|
||||||
|
|
||||||
function handleLock() {
|
function handleLock() {
|
||||||
app.lockVault()
|
app.lockVault()
|
||||||
}
|
}
|
||||||
@ -70,7 +89,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<div class="top-bar-title">
|
<div class="top-bar-title">
|
||||||
{#if viewMode === 'list'}
|
{#if viewMode === 'list'}
|
||||||
<h1>All Entries</h1>
|
<h1>{searchStore.activeGroupId === 'trash' ? TRASH_GROUP_NAME : 'All Entries'}</h1>
|
||||||
{:else if viewMode === 'detail'}
|
{:else if viewMode === 'detail'}
|
||||||
<h1>Entry Details</h1>
|
<h1>Entry Details</h1>
|
||||||
{:else if viewMode === 'form'}
|
{:else if viewMode === 'form'}
|
||||||
@ -78,7 +97,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="top-bar-actions">
|
<div class="top-bar-actions">
|
||||||
{#if viewMode === 'list'}
|
{#if viewMode === 'list' && isTrashView}
|
||||||
|
<button class="btn btn-danger btn-sm" onclick={() => showEmptyTrashConfirm = true} disabled={emptyingTrash}>
|
||||||
|
{emptyingTrash ? 'Emptying...' : '🗑 Empty Trash'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if viewMode === 'list' && !isTrashView}
|
||||||
<button class="btn btn-primary btn-sm" onclick={() => goForm(null)}>+ New Entry</button>
|
<button class="btn btn-primary btn-sm" onclick={() => goForm(null)}>+ New Entry</button>
|
||||||
{/if}
|
{/if}
|
||||||
<ImportExport />
|
<ImportExport />
|
||||||
@ -105,6 +129,23 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Empty trash confirmation -->
|
||||||
|
{#if showEmptyTrashConfirm}
|
||||||
|
<div class="modal-overlay" role="presentation" onclick={() => showEmptyTrashConfirm = false}>
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<div class="modal" role="dialog" aria-modal="true" aria-label="Empty trash confirmation" tabindex="-1" onclick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>Empty Trash</h3>
|
||||||
|
<p>Permanently delete all entries from the trash? This cannot be undone.</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-danger" onclick={handleEmptyTrash} disabled={emptyingTrash}>
|
||||||
|
{emptyingTrash ? 'Emptying...' : 'Yes, empty trash'}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost" onclick={() => showEmptyTrashConfirm = false}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@ -204,6 +245,43 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 380px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h3 {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal p {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.mobile-header {
|
.mobile-header {
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
<script>
|
<script>
|
||||||
import { getGroups, addGroup, updateGroup, deleteGroup, getEntryCountsByGroup, moveEntryToGroup } from '../lib/storage/db.js'
|
import { getGroups, addGroup, updateGroup, deleteGroup, moveEntryToGroup, ensureTrashGroup } from '../lib/storage/db.js'
|
||||||
import { createGroup, validateGroup } from '../lib/models/schema.js'
|
import { createGroup, validateGroup, isTrashGroup, TRASH_GROUP_NAME, TRASH_GROUP_COLOR } from '../lib/models/schema.js'
|
||||||
import { search as searchStore } from '../lib/stores/search.svelte.js'
|
import { search as searchStore } from '../lib/stores/search.svelte.js'
|
||||||
import { autofocus } from '../lib/autofocus.js'
|
import { autofocus } from '../lib/autofocus.js'
|
||||||
|
|
||||||
let groups = $state([])
|
let groups = $state([])
|
||||||
let entryCounts = $state(new Map())
|
|
||||||
|
|
||||||
// Group management state
|
// Group management state
|
||||||
let showGroupForm = $state(false)
|
let showGroupForm = $state(false)
|
||||||
@ -33,7 +32,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function canDrop(groupId) {
|
function canDrop(groupId) {
|
||||||
return groupId !== searchStore.activeGroupId
|
return groupId !== searchStore.activeGroupId && !isTrashGroup(groupId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const GROUP_COLORS = [
|
const GROUP_COLORS = [
|
||||||
@ -43,9 +42,8 @@
|
|||||||
]
|
]
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
const [g, counts] = await Promise.all([getGroups(), getEntryCountsByGroup()])
|
await ensureTrashGroup()
|
||||||
groups = g
|
groups = await getGroups()
|
||||||
entryCounts = counts
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial load + refresh after import/export
|
// Initial load + refresh after import/export
|
||||||
@ -130,6 +128,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#each groups as group}
|
{#each groups as group}
|
||||||
|
{#if !isTrashGroup(group.id)}
|
||||||
<div class="group-row">
|
<div class="group-row">
|
||||||
<button
|
<button
|
||||||
class="group-item {searchStore.activeGroupId === group.id ? 'active' : ''} {dragOverGroupId === group.id ? 'drag-over' : ''} {droppedGroupId === group.id ? 'dropped' : ''}"
|
class="group-item {searchStore.activeGroupId === group.id ? 'active' : ''} {dragOverGroupId === group.id ? 'drag-over' : ''} {droppedGroupId === group.id ? 'dropped' : ''}"
|
||||||
@ -147,9 +146,21 @@
|
|||||||
<button class="group-action-btn" onclick={() => showDeleteGroupConfirm = group.id} title="Delete group">🗑</button>
|
<button class="group-action-btn" onclick={() => showDeleteGroupConfirm = group.id} title="Delete group">🗑</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<!-- Trash pinned to bottom -->
|
||||||
|
<div class="trash-section">
|
||||||
|
<button
|
||||||
|
class="group-item {searchStore.activeGroupId === 'trash' ? 'active' : ''}"
|
||||||
|
onclick={() => searchStore.activeGroupId = 'trash'}
|
||||||
|
>
|
||||||
|
<span class="group-color" style="background-color: {TRASH_GROUP_COLOR}"></span>
|
||||||
|
<span class="group-name">{TRASH_GROUP_NAME}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<button class="btn btn-ghost btn-sm w-full" onclick={() => openGroupForm(null)}>+ New Group</button>
|
<button class="btn btn-ghost btn-sm w-full" onclick={() => openGroupForm(null)}>+ New Group</button>
|
||||||
</div>
|
</div>
|
||||||
@ -333,6 +344,11 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trash-section {
|
||||||
|
padding: 8px;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
.group-action-btn {
|
.group-action-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@ -5,6 +5,14 @@
|
|||||||
* to keep things sortable and collision-resistant without external deps.
|
* to keep things sortable and collision-resistant without external deps.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Well-known ID for the permanent Trash group.
|
||||||
|
* This is a fixed constant so it survives re-creates.
|
||||||
|
*/
|
||||||
|
export const TRASH_GROUP_ID = '__trash__'
|
||||||
|
export const TRASH_GROUP_NAME = 'Trash'
|
||||||
|
export const TRASH_GROUP_COLOR = '#e5484d'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a unique ID (timestamp-based, sortable).
|
* Generate a unique ID (timestamp-based, sortable).
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
@ -136,3 +144,25 @@ export function validateGroup(name) {
|
|||||||
else if (name.trim().length > 50) errors.push('Group name must be 50 characters or less')
|
else if (name.trim().length > 50) errors.push('Group name must be 50 characters or less')
|
||||||
return { valid: errors.length === 0, errors }
|
return { valid: errors.length === 0, errors }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the permanent Trash group.
|
||||||
|
* @returns {Group}
|
||||||
|
*/
|
||||||
|
export function createTrashGroup() {
|
||||||
|
return {
|
||||||
|
id: TRASH_GROUP_ID,
|
||||||
|
name: TRASH_GROUP_NAME,
|
||||||
|
color: TRASH_GROUP_COLOR,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a group ID refers to the Trash group.
|
||||||
|
* @param {string} groupId
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isTrashGroup(groupId) {
|
||||||
|
return groupId === TRASH_GROUP_ID
|
||||||
|
}
|
||||||
|
|||||||
@ -13,6 +13,10 @@
|
|||||||
|
|
||||||
import { openDB } from 'idb'
|
import { openDB } from 'idb'
|
||||||
import { deriveKey, decrypt, encrypt } from '../crypto/crypto.js'
|
import { deriveKey, decrypt, encrypt } from '../crypto/crypto.js'
|
||||||
|
import { TRASH_GROUP_ID, createTrashGroup, isTrashGroup } from '../models/schema.js'
|
||||||
|
|
||||||
|
// Re-export for convenience
|
||||||
|
export { TRASH_GROUP_ID }
|
||||||
|
|
||||||
const DB_NAME = 'password-vault'
|
const DB_NAME = 'password-vault'
|
||||||
const DB_VERSION = 1
|
const DB_VERSION = 1
|
||||||
@ -147,10 +151,26 @@ export async function updateGroup(group) {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
export async function deleteGroup(groupId) {
|
export async function deleteGroup(groupId) {
|
||||||
|
// Prevent deleting the Trash group
|
||||||
|
if (isTrashGroup(groupId)) {
|
||||||
|
throw new Error('Cannot delete the Trash group')
|
||||||
|
}
|
||||||
const db = await getDb()
|
const db = await getDb()
|
||||||
await db.delete('groups', groupId)
|
await db.delete('groups', groupId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the Trash group exists in the database.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function ensureTrashGroup() {
|
||||||
|
const db = await getDb()
|
||||||
|
const existing = await db.get('groups', TRASH_GROUP_ID)
|
||||||
|
if (!existing) {
|
||||||
|
await db.put('groups', createTrashGroup())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all groups, sorted by creation date.
|
* Get all groups, sorted by creation date.
|
||||||
* @returns {Promise<Group[]>}
|
* @returns {Promise<Group[]>}
|
||||||
@ -162,6 +182,56 @@ export async function getGroups() {
|
|||||||
return all.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
return all.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Trash operations
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move an entry to the Trash group.
|
||||||
|
* @param {string} entryId
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function moveToTrash(entryId) {
|
||||||
|
await ensureTrashGroup()
|
||||||
|
const db = await getDb()
|
||||||
|
const entry = await db.get('entries', entryId)
|
||||||
|
if (!entry) throw new Error('Entry not found')
|
||||||
|
entry.groupId = TRASH_GROUP_ID
|
||||||
|
entry.updatedAt = new Date().toISOString()
|
||||||
|
await db.put('entries', entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanently delete all entries in the Trash group.
|
||||||
|
* @returns {Promise<number>} Number of entries deleted
|
||||||
|
*/
|
||||||
|
export async function emptyTrash() {
|
||||||
|
const db = await getDb()
|
||||||
|
const index = db.transaction('entries').store.index('groupId')
|
||||||
|
const trashed = await index.getAll(TRASH_GROUP_ID)
|
||||||
|
const tx = db.transaction('entries', 'readwrite')
|
||||||
|
for (const entry of trashed) {
|
||||||
|
await tx.store.delete(entry.id)
|
||||||
|
}
|
||||||
|
await tx.done
|
||||||
|
return trashed.length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore a trashed entry to its original group (or ungrouped if unknown).
|
||||||
|
* @param {string} entryId
|
||||||
|
* @param {string} [restoreGroupId] - Group to restore to (default: empty/ungrouped)
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function restoreEntry(entryId, restoreGroupId = '') {
|
||||||
|
const db = await getDb()
|
||||||
|
const entry = await db.get('entries', entryId)
|
||||||
|
if (!entry) throw new Error('Entry not found')
|
||||||
|
entry.groupId = restoreGroupId
|
||||||
|
entry.updatedAt = new Date().toISOString()
|
||||||
|
await db.put('entries', entry)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a single group by ID.
|
* Get a single group by ID.
|
||||||
* @param {string} groupId
|
* @param {string} groupId
|
||||||
|
|||||||
@ -3,12 +3,14 @@
|
|||||||
* Shared between Sidebar and EntryList for coordinated filtering.
|
* Shared between Sidebar and EntryList for coordinated filtering.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { TRASH_GROUP_ID } from '../models/schema.js'
|
||||||
|
|
||||||
const DEBOUNCE_MS = 300
|
const DEBOUNCE_MS = 300
|
||||||
|
|
||||||
export class SearchStore {
|
export class SearchStore {
|
||||||
query = $state('') // raw input value — bound to the search input
|
query = $state('') // raw input value — bound to the search input
|
||||||
debouncedQuery = $state('') // debounced value — used for actual search
|
debouncedQuery = $state('') // debounced value — used for actual search
|
||||||
activeGroupId = $state('all') // 'all' or a group id
|
activeGroupId = $state('all') // 'all', 'trash', or a group id
|
||||||
refreshTrigger = $state(0) // incremented to force a re-fetch
|
refreshTrigger = $state(0) // incremented to force a re-fetch
|
||||||
#debounceTimer = null
|
#debounceTimer = null
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user