mitlist/fe/src/pages/ListDetailPage.vue

1252 lines
34 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<main class="neo-container page-padding">
<div v-if="loading" class="neo-loading-state">
<div class="spinner-dots" role="status"><span /><span /><span /></div>
<p>Loading list...</p>
</div>
<div v-else-if="error" class="neo-error-state">
<svg class="icon" aria-hidden="true">
<use xlink:href="#icon-alert-triangle" />
</svg>
{{ error }}
<button class="neo-button" @click="fetchListDetails">Retry</button>
</div>
<template v-else-if="list">
<!-- Header -->
<div class="neo-list-header">
<h1 class="neo-title mb-3">{{ list.name }}</h1>
<div class="neo-header-actions">
<button class="neo-action-button" @click="showCostSummaryDialog = true"
:class="{ 'neo-disabled': !isOnline }">
<svg class="icon">
<use xlink:href="#icon-clipboard" />
</svg> Cost Summary
</button>
<button class="neo-action-button" @click="openOcrDialog" :class="{ 'neo-disabled': !isOnline }">
<svg class="icon">
<use xlink:href="#icon-plus" />
</svg> Add via OCR
</button>
<div class="neo-status" :class="list.is_complete ? 'neo-status-complete' : 'neo-status-active'">
<span v-if="list.group_id">Group List</span>
<span v-else>Personal List</span>
</div>
</div>
</div>
<p v-if="list.description" class="neo-description">{{ list.description }}</p>
<!-- Items List -->
<div v-if="list.items.length === 0" class="neo-empty-state">
<svg class="icon icon-lg" aria-hidden="true">
<use xlink:href="#icon-clipboard" />
</svg>
<h3>No Items Yet!</h3>
<p>Add some items using the form below.</p>
</div>
<div v-else class="neo-list-card">
<ul class="neo-item-list">
<li v-for="item in list.items" :key="item.id" class="neo-item"
:class="{ 'neo-item-complete': item.is_complete }">
<div class="neo-item-content">
<label class="neo-checkbox-label">
<input type="checkbox" :checked="item.is_complete"
@change="confirmUpdateItem(item, ($event.target as HTMLInputElement).checked)"
:disabled="item.updating" :aria-label="item.name" />
<span class="neo-checkmark"></span>
</label>
<div class="neo-item-details">
<span class="neo-item-name">{{ item.name }}</span>
<span v-if="item.quantity" class="neo-item-quantity">× {{ item.quantity }}</span>
<div v-if="item.is_complete" class="neo-price-input">
<input type="number" v-model.number="item.priceInput" class="neo-number-input" placeholder="Price"
step="0.01" @blur="updateItemPrice(item)"
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()" />
</div>
</div>
<div class="neo-item-actions">
<button class="neo-icon-button neo-edit-button" @click.stop="editItem(item)" aria-label="Edit item">
<svg class="icon">
<use xlink:href="#icon-edit"></use>
</svg>
</button>
<button class="neo-icon-button neo-delete-button" @click.stop="confirmDeleteItem(item)"
:disabled="item.deleting" aria-label="Delete item">
<svg class="icon">
<use xlink:href="#icon-trash"></use>
</svg>
</button>
</div>
</div>
</li>
<li class="neo-item new-item-input">
<form @submit.prevent="onAddItem" class="neo-checkbox-label neo-new-item-form">
<input type="checkbox" disabled />
<input type="text" v-model="newItem.name" class="neo-new-item-input" placeholder="Add a new item" required
ref="itemNameInputRef" />
<input type="number" v-model="newItem.quantity" class="neo-quantity-input" placeholder="Qty" min="1" />
<button type="submit" class="neo-add-button" :disabled="addingItem">
<span v-if="addingItem" class="spinner-dots-sm"><span /><span /><span /></span>
<span v-else>Add</span>
</button>
</form>
</li>
</ul>
</div>
</template>
<!-- OCR Dialog -->
<div v-if="showOcrDialogState" class="modal-backdrop open" @click.self="closeOcrDialog">
<div class="modal-container" ref="ocrModalRef" style="min-width: 400px;">
<div class="modal-header">
<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>
</div>
<div class="modal-body">
<div v-if="ocrLoading" class="text-center">
<div class="spinner-dots"><span /><span /><span /></div>
<p>Processing image...</p>
</div>
<div v-else-if="ocrItems.length > 0">
<p class="mb-2">Review Extracted Items:</p>
<ul class="item-list">
<li v-for="(ocrItem, index) in ocrItems" :key="index" class="list-item">
<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 />
<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>
</button>
</div>
</li>
</ul>
</div>
<div v-else class="form-group">
<label for="ocrFile" class="form-label">Upload Image</label>
<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>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="closeOcrDialog">Cancel</button>
<button v-if="ocrItems.length > 0" type="button" class="btn btn-primary ml-2" @click="addOcrItems"
:disabled="addingOcrItems">
<span v-if="addingOcrItems" class="spinner-dots-sm"><span /><span /><span /></span>
Add Items
</button>
</div>
</div>
</div>
<!-- Confirmation Dialog -->
<div v-if="showConfirmDialogState" class="modal-backdrop open" @click.self="cancelConfirmation">
<div class="modal-container confirm-modal" ref="confirmModalRef">
<div class="modal-header">
<h3>Confirmation</h3>
<button class="close-button" @click="cancelConfirmation" aria-label="Close"><svg class="icon">
<use xlink:href="#icon-close" />
</svg></button>
</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>
<!-- Cost Summary Dialog -->
<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-header">
<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>
</div>
<div class="modal-body">
<div v-if="costSummaryLoading" class="text-center">
<div class="spinner-dots"><span /><span /><span /></div>
<p>Loading summary...</p>
</div>
<div v-else-if="costSummaryError" class="alert alert-error">{{ costSummaryError }}</div>
<div v-else-if="listCostSummary">
<div class="mb-3 cost-overview">
<p><strong>Total List Cost:</strong> {{ formatCurrency(listCostSummary.total_list_cost) }}</p>
<p><strong>Equal Share Per User:</strong> {{ formatCurrency(listCostSummary.equal_share_per_user) }}</p>
<p><strong>Participating Users:</strong> {{ listCostSummary.num_participating_users }}</p>
</div>
<h4>User Balances</h4>
<div class="table-container mt-2">
<table class="table">
<thead>
<tr>
<th>User</th>
<th class="text-right">Items Added Value</th>
<th class="text-right">Amount Due</th>
<th class="text-right">Balance</th>
</tr>
</thead>
<tbody>
<tr v-for="userShare in listCostSummary.user_balances" :key="userShare.user_id">
<td>{{ userShare.user_identifier }}</td>
<td class="text-right">{{ formatCurrency(userShare.items_added_value) }}</td>
<td class="text-right">{{ formatCurrency(userShare.amount_due) }}</td>
<td class="text-right">
<span class="item-badge"
:class="parseFloat(String(userShare.balance)) >= 0 ? 'badge-settled' : 'badge-pending'">
{{ formatCurrency(userShare.balance) }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<p v-else>No cost summary available.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" @click="showCostSummaryDialog = false">Close</button>
</div>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue';
import { useRoute } from 'vue-router';
import { apiClient, API_ENDPOINTS } from '@/config/api';
import { onClickOutside, useEventListener, useFileDialog, useNetwork } from '@vueuse/core';
import { useNotificationStore } from '@/stores/notifications';
import { useOfflineStore, type CreateListItemPayload } from '@/stores/offline';
interface Item {
id: number;
name: string;
quantity?: string | undefined | number; // Allow number for input binding
is_complete: boolean;
price?: number | null;
version: number;
updating?: boolean;
updated_at: string;
deleting?: boolean;
// For UI state
priceInput?: string | number | null; // Separate for input binding due to potential nulls/strings
swiped?: boolean; // For swipe UI
}
interface List { id: number; name: string; description?: string; is_complete: boolean; items: Item[]; version: number; updated_at: string; group_id?: number; }
interface UserCostShare {
user_id: number;
user_identifier: string;
items_added_value: string | number;
amount_due: string | number;
balance: string | number;
}
interface ListCostSummaryData {
list_id: number;
list_name: string;
total_list_cost: string | number;
num_participating_users: number;
equal_share_per_user: string | number;
user_balances: UserCostShare[];
}
const route = useRoute();
const { isOnline } = useNetwork();
const notificationStore = useNotificationStore();
const offlineStore = useOfflineStore();
const list = ref<List | null>(null);
const loading = ref(true);
const error = ref<string | null>(null);
const addingItem = ref(false);
const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null);
const lastListUpdate = ref<string | null>(null);
const lastItemUpdate = ref<string | null>(null);
const newItem = ref<{ name: string; quantity?: string | number }>({ name: '' });
const itemNameInputRef = ref<HTMLInputElement | null>(null);
// OCR
const showOcrDialogState = ref(false);
const ocrModalRef = ref<HTMLElement | null>(null);
const ocrLoading = ref(false);
const ocrItems = ref<{ name: string }[]>([]); // Items extracted from OCR
const addingOcrItems = ref(false);
const ocrError = ref<string | null>(null);
const ocrFileInputRef = ref<HTMLInputElement | null>(null);
const { files: ocrFiles, reset: resetOcrFileDialog } = useFileDialog({
accept: 'image/*',
multiple: false,
});
// Confirmation Dialog
const showConfirmDialogState = ref(false);
const confirmModalRef = ref<HTMLElement | null>(null);
const confirmDialogMessage = ref('');
const pendingAction = ref<(() => Promise<void>) | null>(null);
// Cost Summary
const showCostSummaryDialog = ref(false);
const costSummaryModalRef = ref<HTMLElement | null>(null);
const listCostSummary = ref<ListCostSummaryData | null>(null);
const costSummaryLoading = ref(false);
const costSummaryError = ref<string | null>(null);
onClickOutside(ocrModalRef, () => { showOcrDialogState.value = false; });
onClickOutside(costSummaryModalRef, () => { showCostSummaryDialog.value = false; });
onClickOutside(confirmModalRef, () => { showConfirmDialogState.value = false; pendingAction.value = null; });
const formatCurrency = (value: string | number | undefined | null): string => {
if (value === undefined || value === null) return '$0.00';
const numValue = typeof value === 'string' ? parseFloat(value) : value;
return isNaN(numValue) ? '$0.00' : `$${numValue.toFixed(2)}`;
};
const processListItems = (items: Item[]): Item[] => {
return items.map(item => ({
...item,
priceInput: item.price !== null && item.price !== undefined ? item.price : ''
}));
};
const fetchListDetails = async () => {
loading.value = true;
error.value = null;
try {
const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(String(route.params.id)));
const rawList = response.data as List;
rawList.items = processListItems(rawList.items);
list.value = rawList;
lastListUpdate.value = rawList.updated_at;
lastItemUpdate.value = rawList.items.reduce((latest: string, item: Item) => {
return item.updated_at > latest ? item.updated_at : latest;
}, '');
if (showCostSummaryDialog.value) { // If dialog is open, refresh its data
await fetchListCostSummary();
}
} catch (err: unknown) {
error.value = (err instanceof Error ? err.message : String(err)) || 'Failed to load list details.';
} finally {
loading.value = false;
}
};
const checkForUpdates = async () => {
if (!list.value) return;
try {
const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(String(list.value.id)));
const { updated_at: newListUpdatedAt, items: newItems } = response.data as List;
const newLastItemUpdate = newItems.reduce((latest: string, item: Item) => item.updated_at > latest ? item.updated_at : latest, '');
if ((lastListUpdate.value && newListUpdatedAt > lastListUpdate.value) ||
(lastItemUpdate.value && newLastItemUpdate > lastItemUpdate.value)) {
await fetchListDetails();
}
} catch (err) {
console.warn('Polling for updates failed:', err);
}
};
const startPolling = () => {
stopPolling();
pollingInterval.value = setInterval(() => checkForUpdates(), 15000);
};
const stopPolling = () => {
if (pollingInterval.value) clearInterval(pollingInterval.value);
};
const isItemPendingSync = (item: Item) => {
return offlineStore.pendingActions.some(action => {
if (action.type === 'update_list_item' || action.type === 'delete_list_item') {
const payload = action.payload as { listId: string; itemId: string };
return payload.itemId === String(item.id);
}
return false;
});
};
const onAddItem = async () => {
if (!list.value || !newItem.value.name.trim()) {
notificationStore.addNotification({ message: 'Please enter an item name.', type: 'warning' });
itemNameInputRef.value?.focus();
return;
}
addingItem.value = true;
if (!isOnline.value) {
// Add to offline queue
offlineStore.addAction({
type: 'create_list_item',
payload: {
listId: String(list.value.id),
itemData: {
name: newItem.value.name,
quantity: newItem.value.quantity?.toString()
}
}
});
// Optimistically add to UI
const optimisticItem: Item = {
id: Date.now(), // Temporary ID
name: newItem.value.name,
quantity: newItem.value.quantity,
is_complete: false,
version: 1,
updated_at: new Date().toISOString()
};
list.value.items.push(processListItems([optimisticItem])[0]);
newItem.value = { name: '' };
itemNameInputRef.value?.focus();
addingItem.value = false;
return;
}
try {
const response = await apiClient.post(
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
{ name: newItem.value.name, quantity: newItem.value.quantity?.toString() }
);
const addedItem = response.data as Item;
list.value.items.push(processListItems([addedItem])[0]);
newItem.value = { name: '' };
itemNameInputRef.value?.focus();
} catch (err) {
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to add item.', type: 'error' });
} finally {
addingItem.value = false;
}
};
const updateItem = async (item: Item, newCompleteStatus: boolean) => {
if (!list.value) return;
item.updating = true;
const originalCompleteStatus = item.is_complete;
item.is_complete = newCompleteStatus; // Optimistic update
if (!isOnline.value) {
// Add to offline queue
offlineStore.addAction({
type: 'update_list_item',
payload: {
listId: String(list.value.id),
itemId: String(item.id),
data: {
completed: newCompleteStatus
},
version: item.version
}
});
item.updating = false;
return;
}
try {
await apiClient.put(
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
{ completed: newCompleteStatus, version: item.version }
);
item.version++;
} catch (err) {
item.is_complete = originalCompleteStatus; // Revert on error
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item.', type: 'error' });
} finally {
item.updating = false;
}
};
const updateItemPrice = async (item: Item) => {
if (!list.value || !item.is_complete) return;
const newPrice = item.priceInput !== undefined && String(item.priceInput).trim() !== '' ? parseFloat(String(item.priceInput)) : null;
if (item.price === newPrice) return;
item.updating = true;
const originalPrice = item.price;
const originalPriceInput = item.priceInput;
item.price = newPrice;
if (!isOnline.value) {
// Add to offline queue
offlineStore.addAction({
type: 'update_list_item',
payload: {
listId: String(list.value.id),
itemId: String(item.id),
data: {
price: newPrice,
completed: item.is_complete
},
version: item.version
}
});
item.updating = false;
return;
}
try {
await apiClient.put(
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
{ price: newPrice, completed: item.is_complete, version: item.version }
);
item.version++;
} catch (err) {
item.price = originalPrice;
item.priceInput = originalPriceInput;
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item price.', type: 'error' });
} finally {
item.updating = false;
}
};
const deleteItem = async (item: Item) => {
if (!list.value) return;
item.deleting = true;
if (!isOnline.value) {
// Add to offline queue
offlineStore.addAction({
type: 'delete_list_item',
payload: {
listId: String(list.value.id),
itemId: String(item.id)
}
});
// Optimistically remove from UI
list.value.items = list.value.items.filter(i => i.id !== item.id);
item.deleting = false;
return;
}
try {
await apiClient.delete(API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)));
list.value.items = list.value.items.filter(i => i.id !== item.id);
} catch (err) {
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to delete item.', type: 'error' });
} finally {
item.deleting = false;
}
};
// Confirmation dialog logic
const confirmUpdateItem = (item: Item, newCompleteStatus: boolean) => {
confirmDialogMessage.value = `Mark "${item.name}" as ${newCompleteStatus ? 'complete' : 'incomplete'}?`;
pendingAction.value = () => updateItem(item, newCompleteStatus);
showConfirmDialogState.value = true;
};
const confirmDeleteItem = (item: Item) => {
confirmDialogMessage.value = `Delete "${item.name}"? This cannot be undone.`;
pendingAction.value = () => deleteItem(item);
showConfirmDialogState.value = true;
};
const handleConfirmedAction = async () => {
if (pendingAction.value) {
await pendingAction.value();
}
cancelConfirmation();
};
const cancelConfirmation = () => {
showConfirmDialogState.value = false;
pendingAction.value = null;
};
// OCR Functionality
const openOcrDialog = () => {
ocrItems.value = [];
ocrError.value = null;
resetOcrFileDialog(); // Clear previous file selection from useFileDialog
showOcrDialogState.value = true;
nextTick(() => {
if (ocrFileInputRef.value) {
ocrFileInputRef.value.value = ''; // Manually clear input type=file
}
});
};
const closeOcrDialog = () => {
showOcrDialogState.value = false;
ocrItems.value = [];
ocrError.value = null;
};
watch(ocrFiles, async (newFiles) => {
if (newFiles && newFiles.length > 0) {
const file = newFiles[0];
await handleOcrUpload(file);
}
});
const handleOcrFileUpload = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files && target.files.length > 0) {
handleOcrUpload(target.files[0]);
}
};
const handleOcrUpload = async (file: File) => {
if (!file) return;
ocrLoading.value = true;
ocrError.value = null;
ocrItems.value = [];
try {
const formData = new FormData();
formData.append('image_file', file);
const response = await apiClient.post(API_ENDPOINTS.OCR.PROCESS, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
ocrItems.value = response.data.extracted_items.map((nameStr: string) => ({ name: nameStr.trim() })).filter((item: { name: string }) => item.name);
if (ocrItems.value.length === 0) {
ocrError.value = "No items extracted from the image.";
}
} catch (err) {
ocrError.value = (err instanceof Error ? err.message : String(err)) || 'Failed to process image.';
} finally {
ocrLoading.value = false;
if (ocrFileInputRef.value) ocrFileInputRef.value.value = ''; // Reset file input
}
};
const addOcrItems = async () => {
if (!list.value || !ocrItems.value.length) return;
addingOcrItems.value = true;
let successCount = 0;
try {
for (const item of ocrItems.value) {
if (!item.name.trim()) continue;
const response = await apiClient.post(
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
{ name: item.name, quantity: "1" } // Default quantity
);
const addedItem = response.data as Item;
list.value.items.push(processListItems([addedItem])[0]);
successCount++;
}
if (successCount > 0) notificationStore.addNotification({ message: `${successCount} item(s) added successfully from OCR.`, type: 'success' });
closeOcrDialog();
} catch (err) {
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to add OCR items.', type: 'error' });
} finally {
addingOcrItems.value = false;
}
};
// Cost Summary
const fetchListCostSummary = async () => {
if (!list.value || list.value.id === 0) return;
costSummaryLoading.value = true;
costSummaryError.value = null;
try {
const response = await apiClient.get(API_ENDPOINTS.COSTS.LIST_SUMMARY(list.value.id));
listCostSummary.value = response.data;
} catch (err) {
costSummaryError.value = (err instanceof Error ? err.message : String(err)) || 'Failed to load cost summary.';
listCostSummary.value = null;
} finally {
costSummaryLoading.value = false;
}
};
watch(showCostSummaryDialog, (newVal) => {
if (newVal && (!listCostSummary.value || listCostSummary.value.list_id !== list.value?.id)) {
fetchListCostSummary();
}
});
// Keyboard shortcut
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
if (event.key === 'n' && !event.ctrlKey && !event.metaKey && !event.altKey) {
// Check if a modal is open or if focus is already in an input/textarea
const activeElement = document.activeElement;
if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) {
return;
}
if (showOcrDialogState.value || showCostSummaryDialog.value || showConfirmDialogState.value) {
return;
}
event.preventDefault();
itemNameInputRef.value?.focus();
}
});
// Swipe detection (basic)
let touchStartX = 0;
const SWIPE_THRESHOLD = 50; // pixels
const handleTouchStart = (event: TouchEvent) => {
touchStartX = event.changedTouches[0].clientX;
// Add class for visual feedback during swipe if desired
};
const handleTouchMove = () => {
// Can be used for interactive swipe effect
};
const handleTouchEnd = () => {
// 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
// This would require more state per item and logic
// For now, swipe actions are not visually implemented
};
onMounted(() => {
if (!route.params.id) {
error.value = 'No list ID provided';
loading.value = false;
return;
}
fetchListDetails().then(() => {
startPolling();
});
});
onUnmounted(() => {
stopPolling();
});
// Add after deleteItem function
const editItem = (item: Item) => {
// For now, just simulate editing by toggling name and adding "(Edited)" when clicked
// In a real implementation, you would show a modal or inline form
if (!item.name.includes('(Edited)')) {
item.name += ' (Edited)';
}
// Placeholder for future edit functionality
notificationStore.addNotification({
message: 'Edit functionality would show here (modal or inline form)',
type: 'info'
});
};
</script>
<style scoped>
.neo-container {
padding: 1rem;
max-width: 1200px;
margin: 0 auto;
}
.page-padding {
padding: 1rem;
max-width: 1200px;
margin: 0 auto;
}
.mb-3 {
margin-bottom: 1.5rem;
}
.neo-loading-state,
.neo-error-state,
.neo-empty-state {
text-align: center;
padding: 3rem 1rem;
margin: 2rem 0;
border: 3px solid #111;
border-radius: 18px;
background: #fff;
box-shadow: 6px 6px 0 #111;
}
.neo-error-state {
border-color: #e74c3c;
}
.neo-list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.neo-title {
font-size: 2.5rem;
font-weight: 900;
margin: 0;
line-height: 1.2;
}
.neo-header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.neo-description {
font-size: 1.2rem;
margin-bottom: 2rem;
color: #555;
}
.neo-status {
font-weight: 900;
font-size: 1rem;
padding: 0.4rem 1rem;
border: 3px solid #111;
border-radius: 50px;
background: var(--light);
box-shadow: 3px 3px 0 #111;
}
.neo-status-active {
background: #f7f7d4;
}
.neo-status-complete {
background: #d4f7dd;
}
.neo-list-card {
break-inside: avoid;
border-radius: 18px;
box-shadow: 6px 6px 0 #111;
width: 100%;
margin: 0 0 2rem 0;
background: var(--light);
display: flex;
flex-direction: column;
cursor: pointer;
border: 3px solid #111;
overflow: hidden;
}
.neo-item-list {
list-style: none;
padding: 0;
margin: 0 0 2rem 0;
break-inside: avoid;
width: 100%;
background: var(--light);
display: flex;
flex-direction: column;
}
.neo-item {
padding: 1.2rem;
margin-bottom: 0;
border-bottom: 1px solid #eee;
background: var(--light);
transition: background-color 0.1s ease-in-out;
}
.neo-item:last-child {
border-bottom: none;
}
.neo-item:hover {
background-color: #f9f9f9;
}
.neo-item-complete {
background: #f9f9f9;
}
.neo-item-content {
display: flex;
align-items: center;
}
.neo-checkbox-label {
display: flex;
align-items: center;
gap: 0.7em;
cursor: pointer;
}
.neo-checkbox-label input[type="checkbox"] {
width: 1.2em;
height: 1.2em;
accent-color: #111;
border: 2px solid #111;
border-radius: 4px;
}
.neo-item-details {
flex-grow: 1;
display: flex;
flex-direction: column;
}
.neo-item-name {
font-size: 1.1rem;
font-weight: 700;
}
.neo-item-complete .neo-item-name {
text-decoration: line-through;
opacity: 0.6;
}
.neo-item-quantity {
font-size: 0.9rem;
color: #555;
margin-top: 0.2rem;
}
.neo-price-input {
margin-top: 0.5rem;
}
.neo-item-actions {
display: flex;
gap: 0.5rem;
}
.neo-icon-button {
background: none;
border: none;
cursor: pointer;
padding: 0.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.neo-edit-button {
color: #3498db;
}
.neo-edit-button:hover {
background: #eef7fd;
}
.neo-delete-button {
background: none;
border: none;
cursor: pointer;
color: #e74c3c;
padding: 0.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-left: 0;
}
.neo-delete-button:hover {
background: #fee;
}
.neo-actions {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
}
.neo-action-button {
background: #fff;
border: 3px solid #111;
border-radius: 8px;
padding: 0.6rem 1rem;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
box-shadow: 3px 3px 0 #111;
transition: transform 0.1s ease-in-out, box-shadow 0.1s ease-in-out;
}
.neo-action-button:hover {
transform: translateY(-2px);
box-shadow: 3px 5px 0 #111;
}
.neo-action-button .icon {
width: 1.2rem;
height: 1.2rem;
}
.neo-disabled {
opacity: 0.5;
cursor: not-allowed;
}
.neo-add-item-form {
display: flex;
gap: 0.5rem;
margin-top: 2rem;
border: 3px solid #111;
border-radius: 12px;
padding: 1rem;
background: #f9f9f9;
box-shadow: 4px 4px 0 #111;
}
.neo-new-item-form {
width: 100%;
gap: 10px;
}
.neo-text-input {
flex-grow: 1;
border: 2px solid #111;
border-radius: 8px;
padding: 0.8rem;
font-size: 1.1rem;
font-weight: 500;
}
.neo-new-item-input {
background: transparent;
border: none;
outline: none;
all: unset;
width: 100%;
font-size: 1.1rem;
font-weight: 500;
color: #444;
flex-grow: 1;
}
.neo-new-item-input::placeholder {
color: #999;
font-weight: 500;
}
.neo-quantity-input {
width: 80px;
border: 2px solid #111;
border-radius: 8px;
padding: 0.4rem;
font-size: 1rem;
font-weight: 500;
}
.neo-number-input {
border: 2px solid #111;
border-radius: 6px;
padding: 0.5rem;
font-size: 1rem;
width: 100px;
}
.neo-add-button {
background: #111;
color: white;
border: none;
border-radius: 8px;
padding: 0 1rem;
font-weight: 700;
cursor: pointer;
min-width: 60px;
height: 2rem;
}
.neo-button {
background: #111;
color: white;
border: none;
border-radius: 8px;
padding: 0.8rem 1.5rem;
font-weight: 700;
margin-top: 1rem;
cursor: pointer;
}
.new-item-input {
margin-top: 0.5rem;
padding: 0.5rem;
}
/* Responsive adjustments */
@media (max-width: 900px) {
.neo-container {
padding: 0.8rem;
}
.neo-title {
font-size: 1.8rem;
}
.neo-item {
padding: 1rem;
}
}
@media (max-width: 600px) {
.neo-container {
padding: 0.5rem;
}
.neo-header-actions {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.neo-title {
font-size: 1.5rem;
}
.neo-item-name {
font-size: 1rem;
}
.neo-add-item-form {
flex-direction: column;
padding: 0.8rem;
}
.neo-quantity-input {
width: 100%;
}
}
.modal-backdrop {
background-color: rgba(0, 0, 0, 0.5);
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-container {
background: white;
border-radius: 18px;
border: 3px solid #111;
box-shadow: 6px 6px 0 #111;
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
padding: 0;
}
.modal-header {
padding: 1.5rem;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-body {
padding: 1.5rem;
}
.modal-footer {
padding: 1.5rem;
border-top: 1px solid #eee;
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.close-button {
background: none;
border: none;
cursor: pointer;
color: #666;
}
.item-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 16px;
font-weight: 700;
font-size: 0.9rem;
}
.badge-settled {
background-color: #d4f7dd;
color: #2c784c;
}
.badge-pending {
background-color: #ffe1d6;
color: #c64600;
}
.text-right {
text-align: right;
}
.text-center {
text-align: center;
}
.spinner-dots {
display: flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
margin: 0 auto;
}
.spinner-dots span {
width: 8px;
height: 8px;
background-color: #555;
border-radius: 50%;
animation: dot-pulse 1.4s infinite ease-in-out both;
}
.spinner-dots-sm {
display: inline-flex;
align-items: center;
gap: 0.2rem;
}
.spinner-dots-sm span {
width: 4px;
height: 4px;
background-color: white;
border-radius: 50%;
animation: dot-pulse 1.4s infinite ease-in-out both;
}
.spinner-dots span:nth-child(1),
.spinner-dots-sm span:nth-child(1) {
animation-delay: -0.32s;
}
.spinner-dots span:nth-child(2),
.spinner-dots-sm span:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes dot-pulse {
0%,
80%,
100% {
transform: scale(0);
}
40% {
transform: scale(1);
}
}
</style>