feat(zitare): add loading states to list operations

Add spinner indicators and disable buttons during create, delete,
update, add quotes, and remove quote operations to prevent double
clicks and give visual feedback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-26 19:57:26 +01:00
parent f5a9edcfb6
commit 96ff16b7a7
2 changed files with 82 additions and 29 deletions

View file

@ -16,6 +16,8 @@
let lists = $state<QuoteList[]>([]);
let loading = $state(true);
let saving = $state(false);
let deletingId = $state<string | null>(null);
let showCreateModal = $state(false);
let newListName = $state('');
let newListDescription = $state('');
@ -58,11 +60,12 @@
}
async function createList() {
if (!newListName.trim()) return;
if (!newListName.trim() || saving) return;
const token = await authStore.getValidToken();
if (!token) return;
saving = true;
try {
const response = await fetch(`${getBackendUrl()}/api/lists`, {
method: 'POST',
@ -86,15 +89,18 @@
}
} catch (error) {
console.error('Failed to create list:', error);
} finally {
saving = false;
}
}
async function deleteList(listId: string) {
if (!confirm($_('lists.confirmDelete'))) return;
if (deletingId || !confirm($_('lists.confirmDelete'))) return;
const token = await authStore.getValidToken();
if (!token) return;
deletingId = listId;
try {
const response = await fetch(`${getBackendUrl()}/api/lists/${listId}`, {
method: 'DELETE',
@ -107,6 +113,8 @@
}
} catch (error) {
console.error('Failed to delete list:', error);
} finally {
deletingId = null;
}
}
@ -214,16 +222,23 @@
e.stopPropagation();
deleteList(list.id);
}}
class="p-2 text-foreground-muted hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
disabled={deletingId === list.id}
class="p-2 text-foreground-muted hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors disabled:opacity-50"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
{#if deletingId === list.id}
<div
class="w-5 h-5 border-2 border-red-400 border-t-transparent rounded-full animate-spin"
></div>
{:else}
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
{/if}
</button>
</div>
</a>
@ -285,9 +300,14 @@
</button>
<button
onclick={createList}
disabled={!newListName.trim()}
class="px-6 py-2 bg-primary text-white rounded-lg font-medium hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!newListName.trim() || saving}
class="px-6 py-2 bg-primary text-white rounded-lg font-medium hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{#if saving}
<div
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"
></div>
{/if}
{$_('lists.createModal.submit')}
</button>
</div>

View file

@ -13,6 +13,9 @@
let list = $state<QuoteList | null>(null);
let isLoading = $state(true);
let isSaving = $state(false);
let isAdding = $state(false);
let removingQuoteId = $state<string | null>(null);
let searchTerm = $state('');
let isSearchOpen = $state(false);
let showEditModal = $state(false);
@ -83,7 +86,9 @@
}
async function handleUpdateList() {
if (list && editName.trim()) {
if (!list || !editName.trim() || isSaving) return;
isSaving = true;
try {
const updated = await listsStore.updateList(list.id, {
name: editName.trim(),
description: editDescription.trim() || undefined,
@ -95,6 +100,8 @@
} else {
toast.error($_('lists.detail.toast.updateError'));
}
} finally {
isSaving = false;
}
}
@ -130,32 +137,37 @@
}
async function handleAddQuotes() {
if (list) {
const count = selectedQuoteIds.size;
if (!list || isAdding) return;
isAdding = true;
try {
let successCount = 0;
for (const quoteId of selectedQuoteIds) {
const success = await listsStore.addQuoteToList(list.id, quoteId);
if (success) successCount++;
}
if (successCount > 0) {
// Reload list to get updated quote IDs
list = await listsStore.getList(list.id);
toast.success($_('lists.detail.toast.quotesAdded', { values: { count: successCount } }));
}
closeAddQuotesModal();
} finally {
isAdding = false;
}
}
async function handleRemoveQuote(quoteId: string) {
if (list && confirm($_('lists.detail.removeConfirm'))) {
if (!list || removingQuoteId || !confirm($_('lists.detail.removeConfirm'))) return;
removingQuoteId = quoteId;
try {
const success = await listsStore.removeQuoteFromList(list.id, quoteId);
if (success) {
// Reload list to get updated quote IDs
list = await listsStore.getList(list.id);
toast.info($_('lists.detail.toast.quoteRemoved'));
} else {
toast.error($_('lists.detail.toast.removeError'));
}
} finally {
removingQuoteId = null;
}
}
@ -339,16 +351,23 @@
<button
class="remove-btn"
onclick={() => handleRemoveQuote(quote.id)}
disabled={removingQuoteId === quote.id}
aria-label={$_('lists.detail.remove')}
>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
{#if removingQuoteId === quote.id}
<div
class="w-4 h-4 border-2 border-red-400 border-t-transparent rounded-full animate-spin"
></div>
{:else}
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
{/if}
{$_('lists.detail.remove')}
</button>
</div>
@ -422,7 +441,16 @@
<div class="modal-footer">
<button class="btn btn-secondary" onclick={closeEditModal}>{$_('common.cancel')}</button>
<button class="btn btn-primary" onclick={handleUpdateList} disabled={!editName.trim()}>
<button
class="btn btn-primary"
onclick={handleUpdateList}
disabled={!editName.trim() || isSaving}
>
{#if isSaving}
<div
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin inline-block mr-1"
></div>
{/if}
{$_('common.save')}
</button>
</div>
@ -480,8 +508,13 @@
<button
class="btn btn-primary"
onclick={handleAddQuotes}
disabled={selectedQuoteIds.size === 0}
disabled={selectedQuoteIds.size === 0 || isAdding}
>
{#if isAdding}
<div
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin inline-block mr-1"
></div>
{/if}
{$_('lists.detail.addModal.submit', { values: { count: selectedQuoteIds.size } })}
</button>
</div>