365 lines
10 KiB
Svelte
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>
|