generated from nhcarrigan/template
feat: add saved drafts feature
Adds a drafts system allowing users to save input content for later use. - Rust backend: list, save, delete, delete-all commands backed by hikari-drafts.json - Svelte store wrapping all four Tauri commands with timestamp formatting - DraftPanel overlay with insert, per-item delete confirmation, and delete-all - InputBar: Drafts button in control row, floppy-disk Save as Draft icon button
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { draftsStore, type Draft } from "$lib/stores/drafts";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onInsert: (content: string) => void;
|
||||
}
|
||||
|
||||
const { onClose, onInsert }: Props = $props();
|
||||
|
||||
let confirmingDeleteId = $state<string | null>(null);
|
||||
let confirmingAll = $state(false);
|
||||
|
||||
const drafts = $derived(draftsStore.drafts);
|
||||
const isLoading = $derived(draftsStore.isLoading);
|
||||
|
||||
onMount(() => {
|
||||
draftsStore.loadDrafts();
|
||||
});
|
||||
|
||||
function handleInsert(draft: Draft): void {
|
||||
onInsert(draft.content);
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function handleDelete(draftId: string): Promise<void> {
|
||||
await draftsStore.deleteDraft(draftId);
|
||||
confirmingDeleteId = null;
|
||||
}
|
||||
|
||||
async function handleDeleteAll(): Promise<void> {
|
||||
await draftsStore.deleteAllDrafts();
|
||||
confirmingAll = false;
|
||||
}
|
||||
|
||||
function truncateContent(content: string): string {
|
||||
return content.length > 120 ? content.slice(0, 120) + "…" : content;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onclick={onClose}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === "Escape" && onClose()}
|
||||
>
|
||||
<div
|
||||
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="draft-panel-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
|
||||
<h2 id="draft-panel-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||
Saved Drafts
|
||||
</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if $drafts.length > 0}
|
||||
{#if confirmingAll}
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={handleDeleteAll}
|
||||
class="px-3 py-1.5 text-sm font-medium bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Confirm Delete All
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (confirmingAll = false)}
|
||||
class="px-3 py-1.5 text-sm font-medium bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (confirmingAll = true)}
|
||||
class="px-3 py-1.5 text-sm font-medium text-red-400 hover:text-red-300 transition-colors border border-red-400/30 rounded-lg hover:border-red-300/50 hover:bg-red-400/10"
|
||||
>
|
||||
Delete All
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if $isLoading}
|
||||
<div class="flex items-center justify-center p-8">
|
||||
<div class="text-[var(--text-tertiary)]">Loading drafts...</div>
|
||||
</div>
|
||||
{:else if $drafts.length === 0}
|
||||
<div class="flex flex-col items-center justify-center p-8 text-center">
|
||||
<svg
|
||||
class="w-16 h-16 text-[var(--text-tertiary)] mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-[var(--text-secondary)]">No saved drafts yet</p>
|
||||
<p class="text-sm text-[var(--text-tertiary)] mt-1">
|
||||
Use "Save as Draft" to store messages for later
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divide-y divide-[var(--border-color)]">
|
||||
{#each $drafts as draft (draft.id)}
|
||||
<div class="p-4 hover:bg-[var(--bg-secondary)] transition-colors group">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs text-[var(--text-tertiary)] mb-1">
|
||||
{draftsStore.formatTimestamp(draft.saved_at)}
|
||||
</p>
|
||||
<p
|
||||
class="text-sm text-[var(--text-secondary)] font-mono whitespace-pre-wrap break-words"
|
||||
>
|
||||
{truncateContent(draft.content)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||
>
|
||||
<button
|
||||
onclick={() => handleInsert(draft)}
|
||||
class="btn-trans-gradient px-3 py-1.5 text-xs font-medium rounded"
|
||||
title="Insert this draft"
|
||||
>
|
||||
Insert
|
||||
</button>
|
||||
{#if confirmingDeleteId === draft.id}
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={() => handleDelete(draft.id)}
|
||||
class="px-2 py-1 text-xs font-medium bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (confirmingDeleteId = null)}
|
||||
class="px-2 py-1 text-xs font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (confirmingDeleteId = draft.id)}
|
||||
class="p-1.5 text-[var(--text-tertiary)] hover:text-red-400 transition-colors"
|
||||
title="Delete draft"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[role="dialog"] {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.overflow-y-auto {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--accent-primary);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user