Refactor input elements for consistency and readability; update styles for better alignment and spacing in SignupPage and ListDetailPage.

This commit is contained in:
mohamad 2025-05-13 22:46:40 +02:00
parent 18f759aa7c
commit 29682b7e9c
2 changed files with 230 additions and 203 deletions

View File

@ -1,13 +1,15 @@
<template> <template>
<main class="container page-padding"> <main class="container page-padding">
<div v-if="loading" class="text-center"> <div v-if="loading" class="text-center">
<div class="spinner-dots" role="status"><span/><span/><span/></div> <div class="spinner-dots" role="status"><span /><span /><span /></div>
<p>Loading list details...</p> <p>Loading list details...</p>
</div> </div>
<div v-else-if="error" class="alert alert-error mb-3" role="alert"> <div v-else-if="error" class="alert alert-error mb-3" role="alert">
<div class="alert-content"> <div class="alert-content">
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-alert-triangle" /></svg> <svg class="icon" aria-hidden="true">
<use xlink:href="#icon-alert-triangle" />
</svg>
{{ error }} {{ error }}
</div> </div>
<button type="button" class="btn btn-sm btn-danger" @click="fetchListDetails">Retry</button> <button type="button" class="btn btn-sm btn-danger" @click="fetchListDetails">Retry</button>
@ -18,11 +20,15 @@
<h1>{{ list.name }}</h1> <h1>{{ list.name }}</h1>
<div class="flex items-center flex-wrap" style="gap: 0.5rem;"> <div class="flex items-center flex-wrap" style="gap: 0.5rem;">
<button class="btn btn-neutral btn-sm" @click="showCostSummaryDialog = true"> <button class="btn btn-neutral btn-sm" @click="showCostSummaryDialog = true">
<svg class="icon icon-sm"><use xlink:href="#icon-clipboard"/></svg> <!-- Placeholder icon --> <svg class="icon icon-sm">
<use xlink:href="#icon-clipboard" />
</svg> <!-- Placeholder icon -->
Cost Summary Cost Summary
</button> </button>
<button class="btn btn-secondary btn-sm" @click="openOcrDialog"> <button class="btn btn-secondary btn-sm" @click="openOcrDialog">
<svg class="icon icon-sm"><use xlink:href="#icon-plus"/></svg> <!-- Placeholder, camera_alt not in Valerie --> <svg class="icon icon-sm">
<use xlink:href="#icon-plus" />
</svg> <!-- Placeholder, camera_alt not in Valerie -->
Add via OCR Add via OCR
</button> </button>
<span class="item-badge ml-1" :class="list.is_complete ? 'badge-settled' : 'badge-pending'"> <span class="item-badge ml-1" :class="list.is_complete ? 'badge-settled' : 'badge-pending'">
@ -37,27 +43,15 @@
<div class="flex items-end flex-wrap" style="gap: 1rem;"> <div class="flex items-end flex-wrap" style="gap: 1rem;">
<div class="form-group flex-grow" style="margin-bottom: 0;"> <div class="form-group flex-grow" style="margin-bottom: 0;">
<label for="newItemName" class="form-label">Item Name</label> <label for="newItemName" class="form-label">Item Name</label>
<input <input type="text" id="newItemName" v-model="newItem.name" class="form-input" required
type="text" ref="itemNameInputRef" />
id="newItemName"
v-model="newItem.name"
class="form-input"
required
ref="itemNameInputRef"
/>
</div> </div>
<div class="form-group" style="margin-bottom: 0; min-width: 120px;"> <div class="form-group" style="margin-bottom: 0; min-width: 120px;">
<label for="newItemQuantity" class="form-label">Quantity</label> <label for="newItemQuantity" class="form-label">Quantity</label>
<input <input type="number" id="newItemQuantity" v-model="newItem.quantity" class="form-input" min="1" />
type="number"
id="newItemQuantity"
v-model="newItem.quantity"
class="form-input"
min="1"
/>
</div> </div>
<button type="submit" class="btn btn-primary" :disabled="addingItem"> <button type="submit" class="btn btn-primary" :disabled="addingItem">
<span v-if="addingItem" class="spinner-dots-sm" role="status"><span/><span/><span/></span> <span v-if="addingItem" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
Add Item Add Item
</button> </button>
</div> </div>
@ -66,61 +60,44 @@
<!-- Items List --> <!-- Items List -->
<div v-if="list.items.length === 0" class="card empty-state-card"> <div v-if="list.items.length === 0" class="card empty-state-card">
<svg class="icon icon-lg" aria-hidden="true"><use xlink:href="#icon-clipboard" /></svg> <svg class="icon icon-lg" aria-hidden="true">
<use xlink:href="#icon-clipboard" />
</svg>
<h3>No Items Yet!</h3> <h3>No Items Yet!</h3>
<p>This list is empty. Add some items using the form above.</p> <p>This list is empty. Add some items using the form above.</p>
</div> </div>
<ul v-else class="item-list"> <ul v-else class="item-list">
<li <li v-for="item in list.items" :key="item.id" class="list-item"
v-for="item in list.items" :class="{ 'completed': item.is_complete, 'is-swiped': item.swiped }" @touchstart="handleTouchStart"
:key="item.id" @touchmove="handleTouchMove" @touchend="handleTouchEnd">
class="list-item"
:class="{ 'completed': item.is_complete, 'is-swiped': item.swiped }"
@touchstart="handleTouchStart($event, item)"
@touchmove="handleTouchMove($event, item)"
@touchend="handleTouchEnd(item)"
>
<div class="list-item-content"> <div class="list-item-content">
<div class="list-item-main"> <div class="list-item-main">
<label class="checkbox-label mb-0 flex-shrink-0"> <label class="checkbox-label mb-0 flex-shrink-0">
<input <input type="checkbox" :checked="item.is_complete"
type="checkbox"
:checked="item.is_complete"
@change="confirmUpdateItem(item, ($event.target as HTMLInputElement).checked)" @change="confirmUpdateItem(item, ($event.target as HTMLInputElement).checked)"
:disabled="item.updating" :disabled="item.updating" :aria-label="item.name" />
:aria-label="item.name"
/>
<span class="checkmark"></span> <span class="checkmark"></span>
</label> </label>
<div class="item-text flex-grow"> <div class="item-text flex-grow">
<span :class="{ 'text-decoration-line-through': item.is_complete }">{{ item.name }}</span> <span :class="{ 'text-decoration-line-through': item.is_complete }">{{ item.name }}</span>
<small v-if="item.quantity" class="item-caption">Quantity: {{ item.quantity }}</small> <small v-if="item.quantity" class="item-caption">Quantity: {{ item.quantity }}</small>
<div v-if="item.is_complete" class="form-group mt-1" style="max-width: 150px; margin-bottom: 0;"> <div v-if="item.is_complete" class="form-group mt-1" style="max-width: 150px; margin-bottom: 0;">
<label :for="`price-${item.id}`" class="sr-only">Price for {{ item.name }}</label> <label :for="`price-${item.id}`" class="sr-only">Price for {{ item.name }}</label>
<input <input :id="`price-${item.id}`" type="number" v-model.number="item.priceInput"
:id="`price-${item.id}`" class="form-input form-input-sm" placeholder="Price" step="0.01" @blur="updateItemPrice(item)"
type="number" @keydown.enter.prevent="($event.target as HTMLInputElement).blur()" />
v-model.number="item.priceInput"
class="form-input form-input-sm"
placeholder="Price"
step="0.01"
@blur="updateItemPrice(item)"
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()"
/>
</div> </div>
</div> </div>
</div> </div>
<!-- Non-swipe actions can be added here or handled by swipe --> <!-- Non-swipe actions can be added here or handled by swipe -->
<div class="list-item-actions"> <div class="list-item-actions">
<button <button class="btn btn-danger btn-sm btn-icon-only" @click.stop="confirmDeleteItem(item)"
class="btn btn-danger btn-sm btn-icon-only" :disabled="item.deleting" aria-label="Delete item">
@click.stop="confirmDeleteItem(item)" <svg class="icon icon-sm">
:disabled="item.deleting" <use xlink:href="#icon-trash"></use>
aria-label="Delete item" </svg>
> </button>
<svg class="icon icon-sm"><use xlink:href="#icon-trash"></use></svg>
</button>
</div> </div>
</div> </div>
<!-- Swipe actions could be added here if fully implementing swipe from Valerie UI example --> <!-- Swipe actions could be added here if fully implementing swipe from Valerie UI example -->
@ -133,11 +110,13 @@
<div class="modal-container" ref="ocrModalRef" style="min-width: 400px;"> <div class="modal-container" ref="ocrModalRef" style="min-width: 400px;">
<div class="modal-header"> <div class="modal-header">
<h3>Add Items via OCR</h3> <h3>Add Items via OCR</h3>
<button class="close-button" @click="closeOcrDialog" aria-label="Close"><svg class="icon"><use xlink:href="#icon-close"/></svg></button> <button class="close-button" @click="closeOcrDialog" aria-label="Close"><svg class="icon">
<use xlink:href="#icon-close" />
</svg></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div v-if="ocrLoading" class="text-center"> <div v-if="ocrLoading" class="text-center">
<div class="spinner-dots"><span/><span/><span/></div> <div class="spinner-dots"><span /><span /><span /></div>
<p>Processing image...</p> <p>Processing image...</p>
</div> </div>
<div v-else-if="ocrItems.length > 0"> <div v-else-if="ocrItems.length > 0">
@ -147,7 +126,9 @@
<div class="list-item-content flex items-center" style="gap: 0.5rem;"> <div class="list-item-content flex items-center" style="gap: 0.5rem;">
<input type="text" v-model="ocrItem.name" class="form-input flex-grow" required /> <input type="text" v-model="ocrItem.name" class="form-input flex-grow" required />
<button class="btn btn-danger btn-sm btn-icon-only" @click="ocrItems.splice(index, 1)"> <button class="btn btn-danger btn-sm btn-icon-only" @click="ocrItems.splice(index, 1)">
<svg class="icon icon-sm"><use xlink:href="#icon-trash"/></svg> <svg class="icon icon-sm">
<use xlink:href="#icon-trash" />
</svg>
</button> </button>
</div> </div>
</li> </li>
@ -155,36 +136,35 @@
</div> </div>
<div v-else class="form-group"> <div v-else class="form-group">
<label for="ocrFile" class="form-label">Upload Image</label> <label for="ocrFile" class="form-label">Upload Image</label>
<input type="file" id="ocrFile" class="form-input" accept="image/*" @change="handleOcrFileUpload" ref="ocrFileInputRef"/> <input type="file" id="ocrFile" class="form-input" accept="image/*" @change="handleOcrFileUpload"
ref="ocrFileInputRef" />
<p v-if="ocrError" class="form-error-text mt-1">{{ ocrError }}</p> <p v-if="ocrError" class="form-error-text mt-1">{{ ocrError }}</p>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="closeOcrDialog">Cancel</button> <button type="button" class="btn btn-neutral" @click="closeOcrDialog">Cancel</button>
<button <button v-if="ocrItems.length > 0" type="button" class="btn btn-primary ml-2" @click="addOcrItems"
v-if="ocrItems.length > 0" :disabled="addingOcrItems">
type="button" <span v-if="addingOcrItems" class="spinner-dots-sm"><span /><span /><span /></span>
class="btn btn-primary ml-2"
@click="addOcrItems"
:disabled="addingOcrItems"
>
<span v-if="addingOcrItems" class="spinner-dots-sm"><span/><span/><span/></span>
Add Items Add Items
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<!-- Cost Summary Dialog --> <!-- Cost Summary Dialog -->
<div v-if="showCostSummaryDialog" class="modal-backdrop open" @click.self="showCostSummaryDialog = false"> <div v-if="showCostSummaryDialog" class="modal-backdrop open" @click.self="showCostSummaryDialog = false">
<div class="modal-container" ref="costSummaryModalRef" style="min-width: 550px;"> <div class="modal-container" ref="costSummaryModalRef" style="min-width: 550px;">
<div class="modal-header"> <div class="modal-header">
<h3>List Cost Summary</h3> <h3>List Cost Summary</h3>
<button class="close-button" @click="showCostSummaryDialog = false" aria-label="Close"><svg class="icon"><use xlink:href="#icon-close"/></svg></button> <button class="close-button" @click="showCostSummaryDialog = false" aria-label="Close"><svg class="icon">
<use xlink:href="#icon-close" />
</svg></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div v-if="costSummaryLoading" class="text-center"> <div v-if="costSummaryLoading" class="text-center">
<div class="spinner-dots"><span/><span/><span/></div><p>Loading summary...</p> <div class="spinner-dots"><span /><span /><span /></div>
<p>Loading summary...</p>
</div> </div>
<div v-else-if="costSummaryError" class="alert alert-error">{{ costSummaryError }}</div> <div v-else-if="costSummaryError" class="alert alert-error">{{ costSummaryError }}</div>
<div v-else-if="listCostSummary"> <div v-else-if="listCostSummary">
@ -210,7 +190,8 @@
<td class="text-right">{{ formatCurrency(userShare.items_added_value) }}</td> <td class="text-right">{{ formatCurrency(userShare.items_added_value) }}</td>
<td class="text-right">{{ formatCurrency(userShare.amount_due) }}</td> <td class="text-right">{{ formatCurrency(userShare.amount_due) }}</td>
<td class="text-right"> <td class="text-right">
<span class="item-badge" :class="parseFloat(String(userShare.balance)) >= 0 ? 'badge-settled' : 'badge-pending'"> <span class="item-badge"
:class="parseFloat(String(userShare.balance)) >= 0 ? 'badge-settled' : 'badge-pending'">
{{ formatCurrency(userShare.balance) }} {{ formatCurrency(userShare.balance) }}
</span> </span>
</td> </td>
@ -229,20 +210,24 @@
<!-- Confirmation Dialog --> <!-- Confirmation Dialog -->
<div v-if="showConfirmDialogState" class="modal-backdrop open" @click.self="cancelConfirmation"> <div v-if="showConfirmDialogState" class="modal-backdrop open" @click.self="cancelConfirmation">
<div class="modal-container confirm-modal" ref="confirmModalRef"> <div class="modal-container confirm-modal" ref="confirmModalRef">
<div class="modal-header"> <div class="modal-header">
<h3>Confirmation</h3> <h3>Confirmation</h3>
<button class="close-button" @click="cancelConfirmation" aria-label="Close"><svg class="icon"><use xlink:href="#icon-close"/></svg></button> <button class="close-button" @click="cancelConfirmation" aria-label="Close"><svg class="icon">
</div> <use xlink:href="#icon-close" />
<div class="modal-body"> </svg></button>
<svg class="icon icon-lg mb-2" style="color: var(--warning);"><use xlink:href="#icon-alert-triangle"/></svg>
<p>{{ confirmDialogMessage }}</p>
</div>
<div class="modal-footer">
<button class="btn btn-neutral" @click="cancelConfirmation">Cancel</button>
<button class="btn btn-primary ml-2" @click="handleConfirmedAction">Confirm</button>
</div>
</div> </div>
<div class="modal-body">
<svg class="icon icon-lg mb-2" style="color: var(--warning);">
<use xlink:href="#icon-alert-triangle" />
</svg>
<p>{{ confirmDialogMessage }}</p>
</div>
<div class="modal-footer">
<button class="btn btn-neutral" @click="cancelConfirmation">Cancel</button>
<button class="btn btn-primary ml-2" @click="handleConfirmedAction">Confirm</button>
</div>
</div>
</div> </div>
</main> </main>
@ -304,7 +289,7 @@ const list = ref<List | null>(null);
const loading = ref(true); const loading = ref(true);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const addingItem = ref(false); const addingItem = ref(false);
const pollingInterval = ref<NodeJS.Timeout | null>(null); const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null);
const lastListUpdate = ref<string | null>(null); const lastListUpdate = ref<string | null>(null);
const lastItemUpdate = ref<string | null>(null); const lastItemUpdate = ref<string | null>(null);
@ -318,7 +303,7 @@ const ocrLoading = ref(false);
const ocrItems = ref<{ name: string }[]>([]); // Items extracted from OCR const ocrItems = ref<{ name: string }[]>([]); // Items extracted from OCR
const addingOcrItems = ref(false); const addingOcrItems = ref(false);
const ocrError = ref<string | null>(null); const ocrError = ref<string | null>(null);
const ocrFileInputRef = ref<HTMLInputElement|null>(null); const ocrFileInputRef = ref<HTMLInputElement | null>(null);
const { files: ocrFiles, reset: resetOcrFileDialog } = useFileDialog({ const { files: ocrFiles, reset: resetOcrFileDialog } = useFileDialog({
accept: 'image/*', accept: 'image/*',
multiple: false, multiple: false,
@ -365,14 +350,14 @@ const fetchListDetails = async () => {
const rawList = response.data as List; const rawList = response.data as List;
rawList.items = processListItems(rawList.items); rawList.items = processListItems(rawList.items);
list.value = rawList; list.value = rawList;
lastListUpdate.value = rawList.updated_at; lastListUpdate.value = rawList.updated_at;
lastItemUpdate.value = rawList.items.reduce((latest: string, item: Item) => { lastItemUpdate.value = rawList.items.reduce((latest: string, item: Item) => {
return item.updated_at > latest ? item.updated_at : latest; return item.updated_at > latest ? item.updated_at : latest;
}, ''); }, '');
if (showCostSummaryDialog.value) { // If dialog is open, refresh its data if (showCostSummaryDialog.value) { // If dialog is open, refresh its data
await fetchListCostSummary(); await fetchListCostSummary();
} }
} catch (err: unknown) { } catch (err: unknown) {
@ -390,7 +375,7 @@ const checkForUpdates = async () => {
const newLastItemUpdate = newItems.reduce((latest: string, item: Item) => item.updated_at > latest ? item.updated_at : latest, ''); const newLastItemUpdate = newItems.reduce((latest: string, item: Item) => item.updated_at > latest ? item.updated_at : latest, '');
if ((lastListUpdate.value && newListUpdatedAt > lastListUpdate.value) || if ((lastListUpdate.value && newListUpdatedAt > lastListUpdate.value) ||
(lastItemUpdate.value && newLastItemUpdate > lastItemUpdate.value)) { (lastItemUpdate.value && newLastItemUpdate > lastItemUpdate.value)) {
await fetchListDetails(); await fetchListDetails();
} }
} catch (err) { } catch (err) {
@ -441,21 +426,21 @@ const updateItem = async (item: Item, newCompleteStatus: boolean) => {
version: item.version, version: item.version,
}; };
if (item.is_complete && item.priceInput !== undefined && item.priceInput !== null && String(item.priceInput).trim() !== '') { if (item.is_complete && item.priceInput !== undefined && item.priceInput !== null && String(item.priceInput).trim() !== '') {
payload.price = parseFloat(String(item.priceInput)); payload.price = parseFloat(String(item.priceInput));
} else if (item.is_complete && (item.priceInput === undefined || String(item.priceInput).trim() === '')) { } else if (item.is_complete && (item.priceInput === undefined || String(item.priceInput).trim() === '')) {
// If complete and price is empty, don't send price, or send null if API expects it // If complete and price is empty, don't send price, or send null if API expects it
payload.price = null; // Or omit, depending on API payload.price = null; // Or omit, depending on API
} }
const response = await apiClient.put(API_ENDPOINTS.ITEMS.BY_ID(String(item.id)), payload); const response = await apiClient.put(API_ENDPOINTS.ITEMS.BY_ID(String(item.id)), payload);
const updatedItemFromServer = processListItems([response.data as Item])[0]; const updatedItemFromServer = processListItems([response.data as Item])[0];
const index = list.value.items.findIndex(i => i.id === item.id); const index = list.value.items.findIndex(i => i.id === item.id);
if (index !== -1) { if (index !== -1) {
list.value.items[index] = { ...list.value.items[index], ...updatedItemFromServer, updating: false }; list.value.items[index] = { ...list.value.items[index], ...updatedItemFromServer, updating: false };
} }
// If cost summary was open, refresh it // If cost summary was open, refresh it
if (showCostSummaryDialog.value) await fetchListCostSummary(); if (showCostSummaryDialog.value) await fetchListCostSummary();
@ -469,33 +454,33 @@ const updateItem = async (item: Item, newCompleteStatus: boolean) => {
const updateItemPrice = async (item: Item) => { const updateItemPrice = async (item: Item) => {
if (!list.value || !item.is_complete) return; // Only update price if item is complete if (!list.value || !item.is_complete) return; // Only update price if item is complete
const newPrice = item.priceInput !== undefined && String(item.priceInput).trim() !== '' ? parseFloat(String(item.priceInput)) : null; const newPrice = item.priceInput !== undefined && String(item.priceInput).trim() !== '' ? parseFloat(String(item.priceInput)) : null;
if (item.price === newPrice) return; // No change if (item.price === newPrice) return; // No change
item.updating = true; item.updating = true;
const originalPrice = item.price; const originalPrice = item.price;
item.price = newPrice; // Optimistic item.price = newPrice; // Optimistic
try { try {
const response = await apiClient.put( const response = await apiClient.put(
API_ENDPOINTS.ITEMS.BY_ID(String(item.id)), API_ENDPOINTS.ITEMS.BY_ID(String(item.id)),
{ price: item.price, is_complete: item.is_complete, version: item.version } { price: item.price, is_complete: item.is_complete, version: item.version }
); );
const updatedItemFromServer = processListItems([response.data as Item])[0]; const updatedItemFromServer = processListItems([response.data as Item])[0];
const index = list.value.items.findIndex(i => i.id === item.id); const index = list.value.items.findIndex(i => i.id === item.id);
if (index !== -1) { if (index !== -1) {
list.value.items[index] = { ...list.value.items[index], ...updatedItemFromServer, updating: false }; list.value.items[index] = { ...list.value.items[index], ...updatedItemFromServer, updating: false };
}
if (showCostSummaryDialog.value) await fetchListCostSummary();
} catch (err) {
item.price = originalPrice; // Revert
item.priceInput = originalPrice !== null && originalPrice !== undefined ? originalPrice : '';
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item price.', type: 'error' });
} finally {
item.updating = false;
} }
if (showCostSummaryDialog.value) await fetchListCostSummary();
} catch (err) {
item.price = originalPrice; // Revert
item.priceInput = originalPrice !== null && originalPrice !== undefined ? originalPrice : '';
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item price.', type: 'error' });
} finally {
item.updating = false;
}
}; };
@ -540,20 +525,20 @@ const cancelConfirmation = () => {
// OCR Functionality // OCR Functionality
const openOcrDialog = () => { const openOcrDialog = () => {
ocrItems.value = []; ocrItems.value = [];
ocrError.value = null; ocrError.value = null;
resetOcrFileDialog(); // Clear previous file selection from useFileDialog resetOcrFileDialog(); // Clear previous file selection from useFileDialog
showOcrDialogState.value = true; showOcrDialogState.value = true;
nextTick(() => { nextTick(() => {
if (ocrFileInputRef.value) { if (ocrFileInputRef.value) {
ocrFileInputRef.value.value = ''; // Manually clear input type=file ocrFileInputRef.value.value = ''; // Manually clear input type=file
} }
}); });
}; };
const closeOcrDialog = () => { const closeOcrDialog = () => {
showOcrDialogState.value = false; showOcrDialogState.value = false;
ocrItems.value = []; ocrItems.value = [];
ocrError.value = null; ocrError.value = null;
}; };
watch(ocrFiles, async (newFiles) => { watch(ocrFiles, async (newFiles) => {
@ -564,10 +549,10 @@ watch(ocrFiles, async (newFiles) => {
}); });
const handleOcrFileUpload = (event: Event) => { const handleOcrFileUpload = (event: Event) => {
const target = event.target as HTMLInputElement; const target = event.target as HTMLInputElement;
if (target.files && target.files.length > 0) { if (target.files && target.files.length > 0) {
handleOcrUpload(target.files[0]); handleOcrUpload(target.files[0]);
} }
}; };
const handleOcrUpload = async (file: File) => { const handleOcrUpload = async (file: File) => {
@ -581,15 +566,15 @@ const handleOcrUpload = async (file: File) => {
const response = await apiClient.post(API_ENDPOINTS.OCR.PROCESS, formData, { const response = await apiClient.post(API_ENDPOINTS.OCR.PROCESS, formData, {
headers: { 'Content-Type': 'multipart/form-data' } headers: { 'Content-Type': 'multipart/form-data' }
}); });
ocrItems.value = response.data.extracted_items.map((nameStr: string) => ({ name: nameStr.trim() })).filter(item => item.name); ocrItems.value = response.data.extracted_items.map((nameStr: string) => ({ name: nameStr.trim() })).filter((item: { name: string }) => item.name);
if(ocrItems.value.length === 0) { if (ocrItems.value.length === 0) {
ocrError.value = "No items extracted from the image."; ocrError.value = "No items extracted from the image.";
} }
} catch (err) { } catch (err) {
ocrError.value = (err instanceof Error ? err.message : String(err)) || 'Failed to process image.'; ocrError.value = (err instanceof Error ? err.message : String(err)) || 'Failed to process image.';
} finally { } finally {
ocrLoading.value = false; ocrLoading.value = false;
if (ocrFileInputRef.value) ocrFileInputRef.value.value = ''; // Reset file input if (ocrFileInputRef.value) ocrFileInputRef.value.value = ''; // Reset file input
} }
}; };
@ -645,10 +630,10 @@ useEventListener(window, 'keydown', (event: KeyboardEvent) => {
// Check if a modal is open or if focus is already in an input/textarea // Check if a modal is open or if focus is already in an input/textarea
const activeElement = document.activeElement; const activeElement = document.activeElement;
if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) { if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) {
return; return;
} }
if (showOcrDialogState.value || showCostSummaryDialog.value || showConfirmDialogState.value) { if (showOcrDialogState.value || showCostSummaryDialog.value || showConfirmDialogState.value) {
return; return;
} }
event.preventDefault(); event.preventDefault();
itemNameInputRef.value?.focus(); itemNameInputRef.value?.focus();
@ -656,24 +641,23 @@ useEventListener(window, 'keydown', (event: KeyboardEvent) => {
}); });
// Swipe detection (basic) // Swipe detection (basic)
// let touchStartX = 0; // Commented out as unused let touchStartX = 0;
// const SWIPE_THRESHOLD = 50; // pixels // Commented out as unused const SWIPE_THRESHOLD = 50; // pixels
const handleTouchStart = (event: TouchEvent, /* item: Item */) => { // Commented out unused item const handleTouchStart = (event: TouchEvent) => {
touchStartX = event.changedTouches[0].clientX; touchStartX = event.changedTouches[0].clientX;
// Add class for visual feedback during swipe if desired // Add class for visual feedback during swipe if desired
}; };
const handleTouchMove = (/* event: TouchEvent, item: Item */) => { // Commented out unused event and item const handleTouchMove = () => {
// Can be used for interactive swipe effect // Can be used for interactive swipe effect
}; };
const handleTouchEnd = (/* item: Item */) => { // Commented out unused item const handleTouchEnd = () => {
// Not implemented: Valerie UI swipe example implies JS adds/removes 'is-swiped' // Not implemented: Valerie UI swipe example implies JS adds/removes 'is-swiped'
// For a simple demo, one might toggle it here based on a more complex gesture // For a simple demo, one might toggle it here based on a more complex gesture
// This would require more state per item and logic // This would require more state per item and logic
// For now, swipe actions are not visually implemented // For now, swipe actions are not visually implemented
// item.swiped = !item.swiped; // Example of toggling. A real implementation would be more complex.
}; };
@ -695,16 +679,45 @@ onUnmounted(() => {
</script> </script>
<style scoped> <style scoped>
.page-padding { padding: 1rem; } .page-padding {
.mb-1 { margin-bottom: 0.5rem; } padding: 1rem;
.mb-2 { margin-bottom: 1rem; } }
.mb-3 { margin-bottom: 1.5rem; }
.mt-1 { margin-top: 0.5rem; } .mb-1 {
.mt-2 { margin-top: 1rem; } margin-bottom: 0.5rem;
.ml-1 { margin-left: 0.25rem; } }
.ml-2 { margin-left: 0.5rem; }
.text-right { text-align: right; } .mb-2 {
.flex-grow { flex-grow: 1; } margin-bottom: 1rem;
}
.mb-3 {
margin-bottom: 1.5rem;
}
.mt-1 {
margin-top: 0.5rem;
}
.mt-2 {
margin-top: 1rem;
}
.ml-1 {
margin-left: 0.25rem;
}
.ml-2 {
margin-left: 0.5rem;
}
.text-right {
text-align: right;
}
.flex-grow {
flex-grow: 1;
}
.item-caption { .item-caption {
display: block; display: block;
@ -712,27 +725,36 @@ onUnmounted(() => {
opacity: 0.6; opacity: 0.6;
margin-top: 0.25rem; margin-top: 0.25rem;
} }
.text-decoration-line-through { .text-decoration-line-through {
text-decoration: line-through; text-decoration: line-through;
} }
.form-input-sm { /* For price input */
padding: 0.4rem 0.6rem; .form-input-sm {
font-size: 0.9rem; /* For price input */
padding: 0.4rem 0.6rem;
font-size: 0.9rem;
} }
.cost-overview p { .cost-overview p {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
font-size: 1.05rem; font-size: 1.05rem;
} }
.form-error-text { .form-error-text {
color: var(--danger); color: var(--danger);
font-size: 0.85rem; font-size: 0.85rem;
} }
.list-item.completed .item-text { .list-item.completed .item-text {
/* text-decoration: line-through; is handled by span class */ /* text-decoration: line-through; is handled by span class */
opacity: 0.7; opacity: 0.7;
} }
.list-item-actions { .list-item-actions {
margin-left: auto; /* Pushes actions to the right */ margin-left: auto;
padding-left: 1rem; /* Space before actions */ /* Pushes actions to the right */
padding-left: 1rem;
/* Space before actions */
} }
</style> </style>

View File

@ -8,51 +8,42 @@
<form @submit.prevent="onSubmit" class="form-layout"> <form @submit.prevent="onSubmit" class="form-layout">
<div class="form-group mb-2"> <div class="form-group mb-2">
<label for="name" class="form-label">Full Name</label> <label for="name" class="form-label">Full Name</label>
<input type="text" id="name" v-model="name" class="form-input" required autocomplete="name"/> <input type="text" id="name" v-model="name" class="form-input" required autocomplete="name" />
<p v-if="formErrors.name" class="form-error-text">{{ formErrors.name }}</p> <p v-if="formErrors.name" class="form-error-text">{{ formErrors.name }}</p>
</div> </div>
<div class="form-group mb-2"> <div class="form-group mb-2">
<label for="email" class="form-label">Email</label> <label for="email" class="form-label">Email</label>
<input type="email" id="email" v-model="email" class="form-input" required autocomplete="email"/> <input type="email" id="email" v-model="email" class="form-input" required autocomplete="email" />
<p v-if="formErrors.email" class="form-error-text">{{ formErrors.email }}</p> <p v-if="formErrors.email" class="form-error-text">{{ formErrors.email }}</p>
</div> </div>
<div class="form-group mb-2"> <div class="form-group mb-2">
<label for="password" class="form-label">Password</label> <label for="password" class="form-label">Password</label>
<div class="input-with-icon-append"> <div class="input-with-icon-append">
<input <input :type="isPwdVisible ? 'text' : 'password'" id="password" v-model="password" class="form-input"
:type="isPwdVisible ? 'text' : 'password'" required autocomplete="new-password" />
id="password" <button type="button" @click="isPwdVisible = !isPwdVisible" class="icon-append-btn"
v-model="password" aria-label="Toggle password visibility">
class="form-input" <svg class="icon icon-sm">
required <use :xlink:href="isPwdVisible ? '#icon-settings' : '#icon-settings'"></use>
autocomplete="new-password" </svg> <!-- Placeholder for visibility icons -->
/> </button>
<button type="button" @click="isPwdVisible = !isPwdVisible" class="icon-append-btn" aria-label="Toggle password visibility">
<svg class="icon icon-sm"><use :xlink:href="isPwdVisible ? '#icon-settings' : '#icon-settings'"></use></svg> <!-- Placeholder for visibility icons -->
</button>
</div> </div>
<p v-if="formErrors.password" class="form-error-text">{{ formErrors.password }}</p> <p v-if="formErrors.password" class="form-error-text">{{ formErrors.password }}</p>
</div> </div>
<div class="form-group mb-3"> <div class="form-group mb-3">
<label for="confirmPassword" class="form-label">Confirm Password</label> <label for="confirmPassword" class="form-label">Confirm Password</label>
<input <input :type="isPwdVisible ? 'text' : 'password'" id="confirmPassword" v-model="confirmPassword"
:type="isPwdVisible ? 'text' : 'password'" class="form-input" required autocomplete="new-password" />
id="confirmPassword"
v-model="confirmPassword"
class="form-input"
required
autocomplete="new-password"
/>
<p v-if="formErrors.confirmPassword" class="form-error-text">{{ formErrors.confirmPassword }}</p> <p v-if="formErrors.confirmPassword" class="form-error-text">{{ formErrors.confirmPassword }}</p>
</div> </div>
<p v-if="formErrors.general" class="alert alert-error form-error-text">{{ formErrors.general }}</p> <p v-if="formErrors.general" class="alert alert-error form-error-text">{{ formErrors.general }}</p>
<button type="submit" class="btn btn-primary w-full mt-2" :disabled="loading"> <button type="submit" class="btn btn-primary w-full mt-2" :disabled="loading">
<span v-if="loading" class="spinner-dots-sm" role="status"><span/><span/><span/></span> <span v-if="loading" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
Sign Up Sign Up
</button> </button>
@ -68,7 +59,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useAuthStore } from 'stores/auth'; // Assuming path import { useAuthStore } from '@/stores/auth'; // Assuming path is correct
import { useNotificationStore } from '@/stores/notifications'; import { useNotificationStore } from '@/stores/notifications';
const router = useRouter(); const router = useRouter();
@ -143,10 +134,12 @@ const onSubmit = async () => {
min-height: 100dvh; min-height: 100dvh;
padding: 1rem; padding: 1rem;
} }
.signup-card { .signup-card {
width: 100%; width: 100%;
max-width: 400px; max-width: 400px;
} }
/* form-layout, w-full, mt-2, text-center styles were removed as utility classes from Valerie UI are used directly or are available. */ /* form-layout, w-full, mt-2, text-center styles were removed as utility classes from Valerie UI are used directly or are available. */
.link-styled { .link-styled {
@ -155,26 +148,33 @@ const onSubmit = async () => {
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
transition: border-color var(--transition-speed) var(--transition-ease-out); transition: border-color var(--transition-speed) var(--transition-ease-out);
} }
.link-styled:hover, .link-styled:focus {
.link-styled:hover,
.link-styled:focus {
border-bottom-color: var(--primary); border-bottom-color: var(--primary);
} }
.form-error-text { .form-error-text {
color: var(--danger); color: var(--danger);
font-size: 0.85rem; font-size: 0.85rem;
margin-top: 0.25rem; margin-top: 0.25rem;
} }
.alert.form-error-text { /* For general error message */
padding: 0.75rem 1rem; .alert.form-error-text {
margin-bottom: 1rem; /* For general error message */
padding: 0.75rem 1rem;
margin-bottom: 1rem;
} }
.input-with-icon-append { .input-with-icon-append {
position: relative; position: relative;
display: flex; display: flex;
} }
.input-with-icon-append .form-input { .input-with-icon-append .form-input {
padding-right: 3rem; padding-right: 3rem;
} }
.icon-append-btn { .icon-append-btn {
position: absolute; position: absolute;
right: 0; right: 0;
@ -191,9 +191,14 @@ const onSubmit = async () => {
color: var(--dark); color: var(--dark);
opacity: 0.7; opacity: 0.7;
} }
.icon-append-btn:hover, .icon-append-btn:focus {
.icon-append-btn:hover,
.icon-append-btn:focus {
opacity: 1; opacity: 1;
background-color: rgba(0,0,0,0.03); background-color: rgba(0, 0, 0, 0.03);
}
.icon-append-btn .icon {
margin: 0;
} }
.icon-append-btn .icon { margin: 0; }
</style> </style>