227 lines
6.0 KiB
Svelte
227 lines
6.0 KiB
Svelte
<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 { generatePassword } from '../lib/crypto/crypto.js'
|
||
import { app } from '../lib/stores/app.svelte.js'
|
||
import PasswordGenerator from './PasswordGenerator.svelte'
|
||
import { autofocus } from '../lib/autofocus.js'
|
||
|
||
let { entryId, onSave, onCancel } = $props()
|
||
|
||
let title = $state('')
|
||
let username = $state('')
|
||
let password = $state('')
|
||
let url = $state('')
|
||
let notes = $state('')
|
||
let groupId = $state('')
|
||
let passwordVisible = $state(false)
|
||
let groups = $state([])
|
||
let loading = $state(true)
|
||
let error = $state('')
|
||
let saving = $state(false)
|
||
let isEdit = $state(false)
|
||
let formErrors = $state([])
|
||
|
||
async function loadForm() {
|
||
loading = true
|
||
try {
|
||
groups = await getGroups()
|
||
if (entryId) {
|
||
isEdit = true
|
||
const entry = await getEntryById(entryId)
|
||
if (entry) {
|
||
title = entry.title
|
||
username = entry.username
|
||
password = await decrypt(entry.encryptedPassword, app.encryptionKey)
|
||
url = entry.url || ''
|
||
notes = entry.notes || ''
|
||
groupId = entry.groupId || ''
|
||
} else {
|
||
error = 'Entry not found'
|
||
}
|
||
}
|
||
} catch (e) {
|
||
error = 'Failed to load form: ' + e.message
|
||
}
|
||
loading = false
|
||
}
|
||
|
||
loadForm()
|
||
|
||
async function handleSubmit() {
|
||
formErrors = []
|
||
error = ''
|
||
saving = true
|
||
|
||
try {
|
||
const validation = validateEntry({ title, username, encryptedPassword: password })
|
||
if (!validation.valid) {
|
||
formErrors = validation.errors
|
||
saving = false
|
||
return
|
||
}
|
||
|
||
const encryptedPassword = await encrypt(password, app.encryptionKey)
|
||
|
||
if (isEdit) {
|
||
const existing = await getEntryById(entryId)
|
||
const updated = updateEntryModel(existing, {
|
||
title,
|
||
username,
|
||
encryptedPassword,
|
||
url,
|
||
notes,
|
||
groupId,
|
||
})
|
||
await updateEntry(updated)
|
||
} else {
|
||
const entry = createEntry({
|
||
title,
|
||
username,
|
||
encryptedPassword,
|
||
url,
|
||
notes,
|
||
groupId,
|
||
})
|
||
await addEntry(entry)
|
||
}
|
||
|
||
onSave()
|
||
} catch (e) {
|
||
error = 'Failed to save: ' + e.message
|
||
}
|
||
|
||
saving = false
|
||
}
|
||
</script>
|
||
|
||
<div class="entry-form">
|
||
{#if loading}
|
||
<div class="loading">Loading...</div>
|
||
{:else}
|
||
{#if error}
|
||
<div class="error-banner">{error}</div>
|
||
{/if}
|
||
|
||
<form class="form-card" onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
|
||
{#if formErrors.length > 0}
|
||
<div class="validation-errors">
|
||
{#each formErrors as err}
|
||
<div class="validation-error">⚠ {err}</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
|
||
<div class="form-group">
|
||
<label for="title">Title *</label>
|
||
<input id="title" type="text" bind:value={title} placeholder="e.g. GitHub, Gmail" use:autofocus={!isEdit} />
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="username">Username / Email</label>
|
||
<input id="username" type="text" bind:value={username} placeholder="username or email" />
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="password">Password *</label>
|
||
<div class="password-input-group">
|
||
<input
|
||
id="password"
|
||
type={passwordVisible ? 'text' : 'password'}
|
||
bind:value={password}
|
||
placeholder="Password"
|
||
/>
|
||
<button type="button" class="btn btn-ghost btn-sm" onclick={() => passwordVisible = !passwordVisible} title="Toggle visibility">
|
||
{passwordVisible ? '🙈' : '👁'}
|
||
</button>
|
||
<button type="button" class="btn btn-ghost btn-sm" onclick={() => password = generatePassword({ length: 16 })} title="Generate password">
|
||
🎲
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="url">URL</label>
|
||
<input id="url" type="url" bind:value={url} placeholder="https://example.com" />
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="group">Group</label>
|
||
<select id="group" bind:value={groupId}>
|
||
<option value="">No group</option>
|
||
{#each groups as group}
|
||
<option value={group.id}>{group.name}</option>
|
||
{/each}
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="notes">Notes</label>
|
||
<textarea id="notes" bind:value={notes} placeholder="Any additional notes..."></textarea>
|
||
</div>
|
||
|
||
<div class="form-actions">
|
||
<button type="submit" class="btn btn-primary" disabled={saving}>
|
||
{saving ? 'Saving...' : (isEdit ? '💾 Update' : '➕ Create')}
|
||
</button>
|
||
<button type="button" class="btn btn-ghost" onclick={onCancel}>Cancel</button>
|
||
</div>
|
||
</form>
|
||
{/if}
|
||
</div>
|
||
|
||
<style>
|
||
.loading {
|
||
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;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.form-card {
|
||
background: var(--color-surface);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-lg);
|
||
padding: 24px;
|
||
max-width: 500px;
|
||
}
|
||
|
||
.validation-errors {
|
||
margin-bottom: 16px;
|
||
padding: 12px;
|
||
background: rgba(251, 191, 36, 0.1);
|
||
border: 1px solid rgba(251, 191, 36, 0.3);
|
||
border-radius: var(--radius-md);
|
||
}
|
||
|
||
.validation-error {
|
||
font-size: 0.85rem;
|
||
color: var(--color-warning);
|
||
}
|
||
|
||
.password-input-group {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.password-input-group input {
|
||
flex: 1;
|
||
}
|
||
|
||
.form-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-top: 20px;
|
||
}
|
||
</style>
|