password_manager/src/components/EntryDetail.svelte
2026-05-16 23:21:53 +00:00

365 lines
10 KiB
Svelte

<script>
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()
let entry = $state(null)
let passwordVisible = $state(false)
let decryptedPassword = $state('')
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() {
loading = true
error = ''
try {
entry = await getEntryById(entryId)
if (entry && app.encryptionKey) {
decryptedPassword = await decrypt(entry.encryptedPassword, app.encryptionKey)
}
} catch (e) {
error = 'Failed to load entry: ' + e.message
}
loading = false
}
loadEntry()
function showToast(message) {
toast = message
if (toastTimer) clearTimeout(toastTimer)
toastTimer = setTimeout(() => { toast = '' }, 3000)
}
async function copyToClipboard(text, label) {
try {
await navigator.clipboard.writeText(text)
showToast(`✓ ${label} copied (auto-clear in 15s)`)
// Auto-clear after 15 seconds
setTimeout(async () => {
try {
// Try to clear by writing spaces
await navigator.clipboard.writeText('')
} catch {
// Some browsers don't allow clearing clipboard
}
}, 15000)
} catch (e) {
// Fallback for browsers without clipboard API
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
showToast(`✓ ${label} copied`)
}
}
async function handleMoveToTrash() {
deleting = true
try {
await moveToTrash(entryId)
onBack()
} catch (e) {
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">
<!-- Toast notification -->
{#if toast}
<div class="toast">{toast}</div>
{/if}
{#if loading}
<div class="loading">Loading...</div>
{:else if error}
<div class="error-banner">{error}</div>
{:else if !entry}
<div class="empty-state">Entry not found</div>
{:else}
<div class="detail-card">
<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}>🗑 Move to Trash</button>
{/if}
</div>
</div>
<div class="detail-fields">
{#if entry.username}
<div class="detail-field">
<span class="field-label">Username</span>
<div class="field-value">
<span>{entry.username}</span>
<button class="btn btn-ghost btn-sm copy-btn" onclick={() => copyToClipboard(entry.username, 'Username')} title="Copy username">📋</button>
</div>
</div>
{/if}
<div class="detail-field">
<span class="field-label">Password</span>
<div class="field-value">
<span>{passwordVisible ? decryptedPassword : '••••••••••••'}</span>
<button class="btn btn-ghost btn-sm" onclick={() => passwordVisible = !passwordVisible} title="Toggle visibility">
{passwordVisible ? '🙈' : '👁'}
</button>
<button class="btn btn-ghost btn-sm copy-btn" onclick={() => copyToClipboard(decryptedPassword, 'Password')} title="Copy password">📋</button>
</div>
</div>
{#if entry.url}
<div class="detail-field">
<span class="field-label">URL</span>
<div class="field-value">
<a href={entry.url} target="_blank" rel="noopener noreferrer">{entry.url}</a>
<button class="btn btn-ghost btn-sm copy-btn" onclick={() => copyToClipboard(entry.url, 'URL')} title="Copy URL">📋</button>
</div>
</div>
{/if}
{#if entry.notes}
<div class="detail-field">
<span class="field-label">Notes</span>
<div class="field-value notes">{entry.notes}</div>
</div>
{/if}
</div>
<div class="detail-meta">
<span class="text-xs text-muted">Created: {new Date(entry.createdAt).toLocaleString()}</span>
<span class="text-xs text-muted">Updated: {new Date(entry.updatedAt).toLocaleString()}</span>
</div>
</div>
<!-- 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="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={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>
<style>
.loading, .empty-state {
text-align: center;
padding: 3rem;
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;
}
.toast {
position: fixed;
bottom: 20px;
right: 20px;
padding: 10px 16px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: 0.85rem;
color: var(--color-success);
box-shadow: var(--shadow);
z-index: 1000;
animation: slideIn 200ms ease;
}
@keyframes slideIn {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.detail-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 24px;
max-width: 600px;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--color-border);
gap: 12px;
}
.detail-header h2 {
font-size: 1.25rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.detail-fields {
display: flex;
flex-direction: column;
gap: 16px;
}
.field-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
margin-bottom: 4px;
display: block;
}
.field-value {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.95rem;
word-break: break-all;
}
.field-value.notes {
white-space: pre-wrap;
}
.field-value a {
color: var(--color-primary);
text-decoration: none;
}
.field-value a:hover {
text-decoration: underline;
}
.copy-btn {
flex-shrink: 0;
}
.detail-meta {
display: flex;
gap: 16px;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--color-border);
}
/* 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: 400px;
width: 100%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.modal h3 {
margin-bottom: 12px;
}
.modal p {
color: var(--color-text-muted);
font-size: 0.9rem;
margin-bottom: 20px;
}
.modal-actions {
display: flex;
gap: 8px;
}
@media (max-width: 600px) {
.header-actions {
display: none;
}
}
</style>