Add "Trash"

This commit is contained in:
Timothy Farrell 2026-05-16 23:21:53 +00:00
parent 47609c9e7c
commit 46c49655a7
10 changed files with 884 additions and 313 deletions

724
dist/index.html vendored

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,8 @@
<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 { app } from '../lib/stores/app.svelte.js'
import { isTrashGroup } from '../lib/models/schema.js'
let { entryId, onEdit, onBack } = $props()
@ -11,8 +12,10 @@
let loading = $state(true)
let error = $state('')
let showDeleteConfirm = $state(false)
let showPermanentDeleteConfirm = $state(false)
let deleting = $state(false)
let toast = $state('')
const isInTrash = $derived(entry && isTrashGroup(entry.groupId))
let toastTimer = null
async function loadEntry() {
@ -64,17 +67,31 @@
}
}
async function handleDelete() {
async function handleMoveToTrash() {
deleting = true
try {
await deleteEntry(entryId)
await moveToTrash(entryId)
onBack()
} catch (e) {
error = 'Failed to delete: ' + e.message
error = 'Failed to move to trash: ' + e.message
}
deleting = 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>
<div class="entry-detail">
@ -94,8 +111,13 @@
<div class="detail-header">
<h2>{entry.title}</h2>
<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-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>
@ -145,22 +167,39 @@
</div>
</div>
<!-- Delete confirmation modal -->
<!-- Move to Trash confirmation modal -->
{#if showDeleteConfirm}
<div class="modal-overlay" role="presentation" onclick={() => showDeleteConfirm = false}>
<!-- 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()}>
<h3>Delete Entry</h3>
<p>Are you sure you want to delete "<strong>{entry.title}</strong>"? This cannot be undone.</p>
<div class="modal" role="dialog" aria-modal="true" aria-label="Move to trash confirmation" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<h3>Move to Trash</h3>
<p>Move "<strong>{entry.title}</strong>" to the trash? You can restore it later.</p>
<div class="modal-actions">
<button class="btn btn-danger" onclick={handleDelete} disabled={deleting}>
{deleting ? 'Deleting...' : 'Yes, delete'}
<button class="btn btn-danger" onclick={handleMoveToTrash} disabled={deleting}>
{deleting ? 'Moving...' : 'Move to Trash'}
</button>
<button class="btn btn-ghost" onclick={() => showDeleteConfirm = false}>Cancel</button>
</div>
</div>
</div>
{/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}
</div>

View File

@ -1,7 +1,7 @@
<script>
import { addEntry, updateEntry, getEntryById, getGroups } from '../lib/storage/db.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 { app } from '../lib/stores/app.svelte.js'
import PasswordGenerator from './PasswordGenerator.svelte'
@ -151,7 +151,9 @@ import { autofocus } from '../lib/autofocus.js'
<select id="group" bind:value={groupId}>
<option value="">No group</option>
{#each groups as group}
{#if !isTrashGroup(group.id)}
<option value={group.id}>{group.name}</option>
{/if}
{/each}
</select>
</div>

View File

@ -1,5 +1,5 @@
<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'
let entries = $state([])
@ -10,6 +10,8 @@
let { onSelect, onAdd } = $props()
const isTrashView = $derived(searchStore.activeGroupId === 'trash')
async function loadEntries() {
loading = true
error = ''
@ -17,16 +19,19 @@
const query = searchStore.debouncedQuery.trim()
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) {
// Search with optional group filter
const options = groupId !== 'all' ? { groupId } : {}
const options = resolvedGroupId !== 'all' ? { groupId: resolvedGroupId } : {}
entries = await searchEntries(query, options)
} else if (groupId !== 'all') {
} else if (resolvedGroupId !== 'all') {
// Filter by group only
entries = await getEntries({ groupId })
entries = await getEntries({ groupId: resolvedGroupId })
} else {
// Show all
entries = await getEntries()
// Show all (excluding trashed entries)
entries = (await getEntries()).filter(e => e.groupId !== TRASH_GROUP_ID)
}
resultCount = entries.length
@ -36,6 +41,15 @@
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
$effect(() => {
searchStore.debouncedQuery
@ -52,14 +66,14 @@
<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-icon">{searchStore.query ? '🔍' : (isTrashView ? '🗑' : '🔑')}</p>
<p class="empty-text">{searchStore.query ? 'No results found' : (isTrashView ? 'Trash is empty' : 'No entries yet')}</p>
<p class="empty-hint">
{searchStore.query
? '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>
{#if !searchStore.query}
{#if !searchStore.query && !isTrashView}
<button class="btn btn-primary mt-3" onclick={onAdd}>+ New Entry</button>
{/if}
</div>
@ -80,19 +94,24 @@
<th>Username</th>
<th>URL</th>
<th>Notes</th>
{#if isTrashView}
<th style="width: 60px"></th>
{/if}
</tr>
</thead>
<tbody>
{#each entries as entry (entry.id)}
<tr
draggable={true}
draggable={!isTrashView}
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; }}
class="entry-row {dragging ? 'dragging' : ''}"
>
<td>
{#if !isTrashView}
<span class="drag-handle" aria-hidden="true"></span>
{/if}
<span class="entry-title">{entry.title}</span>
</td>
<td>
@ -104,6 +123,11 @@
<td>
<span class="entry-notes truncate">{entry.notes || '—'}</span>
</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>
{/each}
</tbody>
@ -200,6 +224,11 @@
opacity: 0.7;
}
.restore-btn {
font-size: 0.85rem;
padding: 4px 6px;
}
.entry-title {
font-weight: 500;
}

View File

@ -1,7 +1,7 @@
<script>
import { app } from '../lib/stores/app.svelte.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 { autofocus } from '../lib/autofocus.js'
@ -41,6 +41,7 @@
app.encryptionKey = key
await saveVaultMeta(salt, testEncrypted, testPlaintext)
await ensureTrashGroup()
app.isUnlocked = true
startAutoLock()
} else {

View File

@ -1,5 +1,8 @@
<script>
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 EntryList from './EntryList.svelte'
import EntryDetail from './EntryDetail.svelte'
@ -9,6 +12,10 @@
let sidebarOpen = $state(false)
let viewMode = $state('list') // 'list' | 'detail' | 'form'
let selectedEntryId = $state(null)
let showEmptyTrashConfirm = $state(false)
let emptyingTrash = $state(false)
const isTrashView = $derived(searchStore.activeGroupId === 'trash')
function goList() {
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() {
app.lockVault()
}
@ -70,7 +89,7 @@
{/if}
<div class="top-bar-title">
{#if viewMode === 'list'}
<h1>All Entries</h1>
<h1>{searchStore.activeGroupId === 'trash' ? TRASH_GROUP_NAME : 'All Entries'}</h1>
{:else if viewMode === 'detail'}
<h1>Entry Details</h1>
{:else if viewMode === 'form'}
@ -78,7 +97,12 @@
{/if}
</div>
<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>
{/if}
<ImportExport />
@ -105,6 +129,23 @@
{/if}
</div>
</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>
<style>
@ -204,6 +245,43 @@
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 */
@media (max-width: 768px) {
.mobile-header {

View File

@ -1,11 +1,10 @@
<script>
import { getGroups, addGroup, updateGroup, deleteGroup, getEntryCountsByGroup, moveEntryToGroup } from '../lib/storage/db.js'
import { createGroup, validateGroup } from '../lib/models/schema.js'
import { getGroups, addGroup, updateGroup, deleteGroup, moveEntryToGroup, ensureTrashGroup } from '../lib/storage/db.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 { autofocus } from '../lib/autofocus.js'
let groups = $state([])
let entryCounts = $state(new Map())
// Group management state
let showGroupForm = $state(false)
@ -33,7 +32,7 @@
}
function canDrop(groupId) {
return groupId !== searchStore.activeGroupId
return groupId !== searchStore.activeGroupId && !isTrashGroup(groupId)
}
const GROUP_COLORS = [
@ -43,9 +42,8 @@
]
async function loadData() {
const [g, counts] = await Promise.all([getGroups(), getEntryCountsByGroup()])
groups = g
entryCounts = counts
await ensureTrashGroup()
groups = await getGroups()
}
// Initial load + refresh after import/export
@ -130,6 +128,7 @@
</button>
{#each groups as group}
{#if !isTrashGroup(group.id)}
<div class="group-row">
<button
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>
</div>
</div>
{/if}
{/each}
</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">
<button class="btn btn-ghost btn-sm w-full" onclick={() => openGroupForm(null)}>+ New Group</button>
</div>
@ -333,6 +344,11 @@
opacity: 1;
}
.trash-section {
padding: 8px;
border-top: 1px solid var(--color-border);
}
.group-action-btn {
background: none;
border: none;

View File

@ -5,6 +5,14 @@
* 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).
* @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')
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
}

View File

@ -13,6 +13,10 @@
import { openDB } from 'idb'
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_VERSION = 1
@ -147,10 +151,26 @@ export async function updateGroup(group) {
* @returns {Promise<void>}
*/
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()
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.
* @returns {Promise<Group[]>}
@ -162,6 +182,56 @@ export async function getGroups() {
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.
* @param {string} groupId

View File

@ -3,12 +3,14 @@
* Shared between Sidebar and EntryList for coordinated filtering.
*/
import { TRASH_GROUP_ID } from '../models/schema.js'
const DEBOUNCE_MS = 300
export class SearchStore {
query = $state('') // raw input value — bound to the search input
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
#debounceTimer = null