Update package dependencies and refactor ListDetailPage and ListsPage components

- Added `motion` and `framer-motion` packages to `package.json` and `package-lock.json`.
- Updated API base URL in `api-config.ts` to point to the local development environment.
- Refactored `ListDetailPage.vue` to enhance item rendering and interaction, replacing `VListItem` with a custom list structure.
- Improved `ListsPage.vue` to handle loading states and item addition more effectively, including better handling of temporary item IDs.

These changes aim to improve the user experience and maintainability of the application.
This commit is contained in:
mohamad 2025-06-01 21:57:03 +02:00
parent 7da93d1fe9
commit 843b3411e4
5 changed files with 577 additions and 518 deletions

91
fe/package-lock.json generated
View File

@ -15,6 +15,7 @@
"@vueuse/core": "^13.1.0", "@vueuse/core": "^13.1.0",
"axios": "^1.9.0", "axios": "^1.9.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"motion": "^12.15.0",
"pinia": "^3.0.2", "pinia": "^3.0.2",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-i18n": "^12.0.0-alpha.2", "vue-i18n": "^12.0.0-alpha.2",
@ -4420,21 +4421,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@types/date-fns": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/@types/date-fns/-/date-fns-2.5.3.tgz",
"integrity": "sha512-4KVPD3g5RjSgZtdOjvI/TDFkLNUHhdoWxmierdQbDeEg17Rov0hbBYtIzNaQA67ORpteOhvR9YEMTb6xeDCang==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
@ -7685,6 +7671,33 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/framer-motion": {
"version": "12.15.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.15.0.tgz",
"integrity": "sha512-XKg/LnKExdLGugZrDILV7jZjI599785lDIJZLxMiiIFidCsy0a4R2ZEf+Izm67zyOuJgQYTHOmodi7igQsw3vg==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.15.0",
"motion-utils": "^12.12.1",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fresh": { "node_modules/fresh": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
@ -9439,6 +9452,47 @@
"ufo": "^1.5.4" "ufo": "^1.5.4"
} }
}, },
"node_modules/motion": {
"version": "12.15.0",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.15.0.tgz",
"integrity": "sha512-HLouXyIb1uQFiZgJTYGrtEzbatPc6vK+HP+Qt6afLQjaudiGiLLVsoy71CwzD/Stlh06FUd5OpyiXqn6XvqjqQ==",
"license": "MIT",
"dependencies": {
"framer-motion": "^12.15.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "12.15.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.15.0.tgz",
"integrity": "sha512-D2ldJgor+2vdcrDtKJw48k3OddXiZN1dDLLWrS8kiHzQdYVruh0IoTwbJBslrnTXIPgFED7PBN2Zbwl7rNqnhA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.12.1"
}
},
"node_modules/motion-utils": {
"version": "12.12.1",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.12.1.tgz",
"integrity": "sha512-f9qiqUHm7hWSLlNW8gS9pisnsN7CRFRD58vNjptKdsqFLpkVnX00TNeD6Q0d27V9KzT7ySFyK1TZ/DShfVOv6w==",
"license": "MIT"
},
"node_modules/mrmime": { "node_modules/mrmime": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
@ -10524,7 +10578,7 @@
"version": "19.1.0", "version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@ -10534,7 +10588,7 @@
"version": "19.1.0", "version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"scheduler": "^0.26.0" "scheduler": "^0.26.0"
@ -11005,7 +11059,7 @@
"version": "0.26.0", "version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/semver": { "node_modules/semver": {
@ -12057,7 +12111,6 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/type-check": { "node_modules/type-check": {

View File

@ -26,6 +26,7 @@
"@vueuse/core": "^13.1.0", "@vueuse/core": "^13.1.0",
"axios": "^1.9.0", "axios": "^1.9.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"motion": "^12.15.0",
"pinia": "^3.0.2", "pinia": "^3.0.2",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-i18n": "^12.0.0-alpha.2", "vue-i18n": "^12.0.0-alpha.2",

View File

@ -2,7 +2,7 @@
export const API_VERSION = 'v1' export const API_VERSION = 'v1'
// API Base URL // API Base URL
export const API_BASE_URL = (window as any).ENV?.VITE_API_URL || 'https://mitlistbe.mohamad.dev' export const API_BASE_URL = (window as any).ENV?.VITE_API_URL || 'http://localhost:8000'
// API Endpoints // API Endpoints
export const API_ENDPOINTS = { export const API_ENDPOINTS = {

View File

@ -30,39 +30,35 @@
</VCard> </VCard>
<VCard v-else-if="!itemsAreLoading && list.items.length === 0" variant="empty-state" empty-icon="clipboard" <VCard v-else-if="!itemsAreLoading && list.items.length === 0" variant="empty-state" empty-icon="clipboard"
empty-title="No Items Yet!" empty-message="Add some items using the form below." class="mt-4" /> empty-title="No Items Yet!" empty-message="Add some items using the form below." class="mt-4" />
<VCard v-else class="mt-4"> <div v-else class="neo-item-list-container mt-4">
<VList class="item-list-tight"> <ul class="neo-item-list">
<VListItem v-for="item in list.items" :key="item.id" class="item-with-actions" <li v-for="item in list.items" :key="item.id" class="neo-list-item"
:class="{ 'bg-gray-100 opacity-70': item.is_complete }"> :class="{ 'bg-gray-100 opacity-70': item.is_complete }">
<template #default> <div class="neo-item-content">
<div class="flex items-center flex-grow gap-2"> <label class="neo-checkbox-label" @click.stop>
<VCheckbox :model-value="item.is_complete" @update:modelValue="confirmUpdateItem(item, $event)" <input type="checkbox" :checked="item.is_complete"
:disabled="item.updating" :aria-label="item.name" /> @change="(e) => confirmUpdateItem(item, (e.target as HTMLInputElement)?.checked ?? false)" />
<div class="flex-grow"> <span class="item-name" :class="{ 'line-through': item.is_complete }">{{ item.name }}</span>
<span class="item-name" :class="{ 'line-through': item.is_complete }">{{ item.name }}</span> <span v-if="item.quantity" class="text-sm text-gray-500 ml-1">× {{ item.quantity }}</span>
<span v-if="item.quantity" class="text-sm text-gray-500 ml-1">× {{ item.quantity }}</span> </label>
<div v-if="item.is_complete" class="mt-1"> <div class="neo-item-actions">
<VInput type="number" :model-value="item.priceInput || ''" <button class="neo-icon-button neo-edit-button" @click.stop="editItem(item)" aria-label="Edit item">
@update:modelValue="item.priceInput = $event" placeholder="Price" size="sm" class="w-24"
step="0.01" @blur="updateItemPrice(item)"
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()" />
</div>
</div>
</div>
<div class="flex items-center gap-1 ml-2">
<VButton :icon-only="true" size="sm" variant="neutral" @click.stop="editItem(item)"
aria-label="Edit item">
<VIcon name="edit" /> <VIcon name="edit" />
</VButton> </button>
<VButton :icon-only="true" size="sm" variant="neutral" color="danger" <button class="neo-icon-button neo-delete-button" @click.stop="confirmDeleteItem(item)"
@click.stop="confirmDeleteItem(item)" :disabled="item.deleting" aria-label="Delete item"> :disabled="item.deleting" aria-label="Delete item">
<VIcon name="trash" /> <VIcon name="trash" />
</VButton> </button>
</div> </div>
</template> </div>
</VListItem> <div v-if="item.is_complete" class="neo-price-input">
</VList> <VInput type="number" :model-value="item.priceInput || ''" @update:modelValue="item.priceInput = $event"
</VCard> placeholder="Price" size="sm" class="w-24" step="0.01" @blur="updateItemPrice(item)"
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()" />
</div>
</li>
</ul>
</div>
<!-- Add New Item Form --> <!-- Add New Item Form -->
<form @submit.prevent="onAddItem" class="add-item-form mt-4 p-4 border rounded-lg shadow flex items-center gap-2"> <form @submit.prevent="onAddItem" class="add-item-form mt-4 p-4 border rounded-lg shadow flex items-center gap-2">
@ -1295,18 +1291,21 @@ const handleExpenseCreated = (expense: any) => {
overflow: hidden; overflow: hidden;
} }
.neo-item-list-container {
border: 3px solid #111;
border-radius: 18px;
background: var(--light);
box-shadow: 6px 6px 0 #111;
overflow: hidden;
}
.neo-item-list { .neo-item-list {
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 0 0 2rem 0; margin: 0;
break-inside: avoid;
width: 100%;
background: var(--light);
display: flex;
flex-direction: column;
} }
.neo-item { .neo-list-item {
padding: 1.2rem; padding: 1.2rem;
margin-bottom: 0; margin-bottom: 0;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
@ -1314,21 +1313,19 @@ const handleExpenseCreated = (expense: any) => {
transition: background-color 0.1s ease-in-out; transition: background-color 0.1s ease-in-out;
} }
.neo-item:last-child { .neo-list-item:last-child {
border-bottom: none; border-bottom: none;
} }
.neo-item:hover { .neo-list-item:hover {
background-color: #f9f9f9; background-color: #f9f9f9;
} }
.neo-item-complete {
background: #f9f9f9;
}
.neo-item-content { .neo-item-content {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
gap: 1rem;
} }
.neo-checkbox-label { .neo-checkbox-label {
@ -1336,6 +1333,7 @@ const handleExpenseCreated = (expense: any) => {
align-items: center; align-items: center;
gap: 0.7em; gap: 0.7em;
cursor: pointer; cursor: pointer;
flex-grow: 1;
} }
.neo-checkbox-label input[type="checkbox"] { .neo-checkbox-label input[type="checkbox"] {
@ -1346,35 +1344,11 @@ const handleExpenseCreated = (expense: any) => {
border-radius: 4px; border-radius: 4px;
} }
.neo-item-details {
flex-grow: 1;
display: flex;
flex-direction: column;
}
.item-name { .item-name {
/* Added for VListItem content */
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 700; font-weight: 700;
} }
.neo-item-complete .item-name {
/* Adjusted for VListItem */
text-decoration: line-through;
/* opacity: 0.6; Combined with bg-gray-100 opacity-70 on VListItem */
}
.neo-item-quantity {
font-size: 0.9rem;
color: #555;
margin-top: 0.2rem;
}
.neo-price-input {
margin-top: 0.5rem;
}
.neo-item-actions { .neo-item-actions {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@ -1389,6 +1363,13 @@ const handleExpenseCreated = (expense: any) => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: transform 0.1s ease-out, opacity 0.1s ease-out;
-webkit-tap-highlight-color: transparent;
}
.neo-icon-button:active {
transform: scale(0.98);
opacity: 0.9;
} }
.neo-edit-button { .neo-edit-button {
@ -1400,308 +1381,35 @@ const handleExpenseCreated = (expense: any) => {
} }
.neo-delete-button { .neo-delete-button {
background: none;
border: none;
cursor: pointer;
color: #e74c3c; color: #e74c3c;
padding: 0.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-left: 0;
} }
.neo-delete-button:hover { .neo-delete-button:hover {
background: #fee; background: #fee;
} }
.neo-actions { .neo-price-input {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
}
.neo-action-button {
/* General button, mostly replaced by VButton */
background: #111;
color: white;
border: none;
border-radius: 8px;
padding: 0.6rem 1rem;
font-weight: 700;
cursor: pointer;
}
.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;
}
.add-item-form {
/* Added for new form styling */
/* display: flex; (already on class) */
/* gap: 0.5rem; (already on class) */
/* margin-top: 1rem; (original was 2rem, now mt-4) */
/* padding: 1rem; (original was 1rem) */
/* border: 3px solid #111; (original was 3px) */
/* border-radius: 12px; (original was 12px) */
/* background: #f9f9f9; (original was #f9f9f9) */
/* box-shadow: 4px 4px 0 #111; (original was 4px) */
}
.neo-new-item-form {
/* Kept for reference, but form tag itself is now styled */
width: 100%;
gap: 10px;
}
.neo-text-input {
/* Not directly used by VInput, but kept for reference */
flex-grow: 1;
border: 2px solid #111;
border-radius: 8px;
padding: 0.8rem;
font-size: 1.1rem;
font-weight: 500;
}
.neo-new-item-input {
/* Not directly used by VInput, but kept for reference */
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 {
/* VInput handles its own placeholder styling */
color: #999;
font-weight: 500;
}
.neo-quantity-input {
/* Not directly used by VInput, but kept for reference */
width: 80px;
/* This specific width is now on VFormField for quantity */
border: 2px solid #111;
border-radius: 8px;
padding: 0.4rem;
font-size: 1rem;
font-weight: 500;
}
.neo-number-input {
/* For price input, now VInput with class="w-24" */
border: 2px solid #111;
border-radius: 6px;
padding: 0.5rem;
font-size: 1rem;
width: 100px;
}
.neo-add-button {
/* Replaced by VButton */
background: #111;
color: white;
border: none;
border-radius: 8px;
padding: 0 1rem;
font-weight: 700;
cursor: pointer;
min-width: 60px;
height: 2rem;
}
.neo-button {
/* General button, mostly replaced by VButton */
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 {
/* Styling for the old li wrapper of add item form, can be removed */
margin-top: 0.5rem; margin-top: 0.5rem;
padding: 0.5rem; padding-left: 2.2em;
}
/* Responsive adjustments */
@media (max-width: 900px) {
.neo-container {
padding: 0.8rem;
}
.neo-title {
font-size: 1.8rem;
}
/* .neo-item { // VListItem might have its own padding
padding: 1rem;
} */
} }
@media (max-width: 600px) { @media (max-width: 600px) {
.neo-container { .neo-list-item {
padding: 0.5rem;
}
.neo-list-header {
flex-direction: column;
align-items: flex-start;
gap: 0.8rem;
}
.neo-title {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.neo-header-actions {
flex-wrap: wrap;
gap: 0.5rem;
}
.neo-action-button {
/* VButton has its own sizing */
padding: 0.8rem;
font-size: 0.9rem;
}
.neo-description {
font-size: 1rem;
margin-bottom: 1.5rem;
}
/* .neo-item { // VListItem
padding: 1rem; padding: 1rem;
} */
.item-name {
/* Adjusted for VListItem */
font-size: 1rem;
} }
.neo-item-quantity { .neo-checkbox-label input[type="checkbox"] {
font-size: 0.85rem;
}
/* .neo-checkbox-label input[type="checkbox"] { // VCheckbox has its own styling
width: 1.4em; width: 1.4em;
height: 1.4em; height: 1.4em;
} */ }
.item-name {
font-size: 1rem;
}
.neo-icon-button { .neo-icon-button {
/* VButton icon-only replaces this */
padding: 0.6rem; padding: 0.6rem;
} }
.add-item-form {
/* Adjusted form class */
flex-wrap: wrap;
gap: 0.5rem;
}
/* VInput placeholder styling is internal to VInput */
/* .neo-new-item-input {
width: 100%;
font-size: 1rem;
} */
/* VInput type number styling is internal or via props */
/* .neo-quantity-input {
width: 80px;
font-size: 0.9rem;
} */
/* VButton styling replaces this */
/* .neo-add-button {
width: 100%;
margin-top: 0.5rem;
padding: 0.8rem;
} */
/* Optimize modals for mobile */
.modal-container {
/* VModal has its own responsive sizing via props/CSS */
width: 95%;
max-height: 85vh;
margin: 1rem;
}
.modal-header {
/* VModal slot */
padding: 1rem;
}
.modal-body {
/* VModal slot */
padding: 1rem;
}
.modal-footer {
/* VModal slot */
padding: 1rem;
}
/* Improve touch targets - general principle, components should handle this */
/* button,
input[type="checkbox"],
.neo-checkbox-label {
min-height: 44px;
} */
/* Optimize loading states for mobile */
.neo-loading-state {
/* VSpinner used instead */
padding: 2rem 1rem;
}
.spinner-dots span {
/* VSpinner has its own dot styling */
width: 10px;
height: 10px;
}
/* Improve scrolling performance */
.item-list-tight {
/* Assuming VList with this class */
-webkit-overflow-scrolling: touch;
}
/* Optimize expense cards for mobile */
.neo-expense-card {
padding: 0.8rem;
}
.neo-expense-header {
font-size: 1.1rem;
}
.neo-split-item {
padding: 0.8rem 0;
}
} }
/* Add smooth transitions for all interactive elements - VComponents have their own */ /* Add smooth transitions for all interactive elements - VComponents have their own */

View File

@ -8,11 +8,8 @@
</template> </template>
</VAlert> </VAlert>
<VCard v-else-if="lists.length === 0" <VCard v-else-if="lists.length === 0 && !loading" variant="empty-state" empty-icon="clipboard"
variant="empty-state" :empty-title="noListsMessage">
empty-icon="clipboard"
:empty-title="noListsMessage"
>
<template #default> <template #default>
<p v-if="!currentGroupId">Create a personal list or join a group to see shared lists.</p> <p v-if="!currentGroupId">Create a personal list or join a group to see shared lists.</p>
<p v-else>This group doesn't have any lists yet.</p> <p v-else>This group doesn't have any lists yet.</p>
@ -24,6 +21,10 @@
</template> </template>
</VCard> </VCard>
<div v-else-if="loading && lists.length === 0" class="loading-placeholder">
Loading lists...
</div>
<div v-else> <div v-else>
<div class="neo-lists-grid"> <div class="neo-lists-grid">
<div v-for="list in lists" :key="list.id" class="neo-list-card" <div v-for="list in lists" :key="list.id" class="neo-list-card"
@ -33,22 +34,27 @@
<div class="neo-list-header">{{ list.name }}</div> <div class="neo-list-header">{{ list.name }}</div>
<div class="neo-list-desc">{{ list.description || 'No description' }}</div> <div class="neo-list-desc">{{ list.description || 'No description' }}</div>
<ul class="neo-item-list"> <ul class="neo-item-list">
<li v-for="item in list.items" :key="item.id" class="neo-list-item"> <li v-for="item in list.items" :key="item.id || item.tempId" class="neo-list-item" :data-item-id="item.id"
:data-item-temp-id="item.tempId" :class="{ 'is-updating': item.updating }">
<label class="neo-checkbox-label" @click.stop> <label class="neo-checkbox-label" @click.stop>
<input type="checkbox" :checked="item.is_complete" @change="toggleItem(list, item)" /> <input type="checkbox" :checked="item.is_complete" @change="toggleItem(list, item)"
<span :class="{ 'neo-completed': item.is_complete }">{{ item.name }}</span> :disabled="item.id === undefined && item.tempId !== undefined" />
<span class="checkbox-text-span"
:class="{ 'neo-completed-static': item.is_complete && !item.updating }">{{
item.name }}</span>
</label> </label>
</li> </li>
<li class="neo-list-item new-item-input"> <li class="neo-list-item new-item-input-container">
<label class="neo-checkbox-label"> <label class="neo-checkbox-label">
<input type="checkbox" disabled /> <input type="checkbox" disabled />
<input type="text" class="neo-new-item-input" placeholder="Add new item..." <input type="text" class="neo-new-item-input" placeholder="Add new item..." ref="newItemInputRefs"
@keyup.enter="addNewItem(list, $event)" @blur="addNewItem(list, $event)" @click.stop /> :data-list-id="list.id" @keyup.enter="addNewItem(list, $event)"
@blur="handleNewItemBlur(list, $event)" @click.stop />
</label> </label>
</li> </li>
</ul> </ul>
</div> </div>
<div class="neo-create-list-card" @click="showCreateModal = true"> <div class="neo-create-list-card" @click="showCreateModal = true" ref="createListCardRef">
+ Create a new list + Create a new list
</div> </div>
</div> </div>
@ -59,15 +65,15 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed, watch, onUnmounted } from 'vue'; import { ref, onMounted, computed, watch, onUnmounted, nextTick } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { apiClient, API_ENDPOINTS } from '@/config/api'; import { apiClient, API_ENDPOINTS } from '@/config/api'; // Adjust path as needed
import CreateListModal from '@/components/CreateListModal.vue'; import CreateListModal from '@/components/CreateListModal.vue'; // Adjust path as needed
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import VAlert from '@/components/valerie/VAlert.vue'; import VAlert from '@/components/valerie/VAlert.vue'; // Adjust path as needed
import VCard from '@/components/valerie/VCard.vue'; import VCard from '@/components/valerie/VCard.vue'; // Adjust path as needed
import VButton from '@/components/valerie/VButton.vue'; import VButton from '@/components/valerie/VButton.vue'; // Adjust path as needed
// VSpinner might not be needed here unless other parts use it directly import { animate } from 'motion';
interface List { interface List {
id: number; id: number;
@ -88,29 +94,32 @@ interface Group {
} }
interface Item { interface Item {
id: number; id: number | string;
tempId?: string;
name: string; name: string;
quantity?: string | number; quantity?: string | number;
is_complete: boolean; is_complete: boolean;
price?: number | null; price?: number | null;
version: number; version: number;
updating?: boolean; updating?: boolean;
created_at?: string;
updated_at: string; updated_at: string;
} }
const props = defineProps<{ const props = defineProps<{
groupId?: number | string; // Prop for when ListsPage is embedded (e.g. in GroupDetailPage) groupId?: number | string;
}>(); }>();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const loading = ref(false); const loading = ref(true);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const lists = ref<(List & { items: Item[] })[]>([]); const lists = ref<(List & { items: Item[] })[]>([]);
const allFetchedGroups = ref<Group[]>([]); const allFetchedGroups = ref<Group[]>([]);
const currentViewedGroup = ref<Group | null>(null); const currentViewedGroup = ref<Group | null>(null);
const showCreateModal = ref(false); const showCreateModal = ref(false);
const newItemInputRefs = ref<HTMLInputElement[]>([]);
const currentGroupId = computed<number | null>(() => { const currentGroupId = computed<number | null>(() => {
const idFromProp = props.groupId; const idFromProp = props.groupId;
@ -129,19 +138,17 @@ const fetchCurrentViewGroupName = async () => {
currentViewedGroup.value = null; currentViewedGroup.value = null;
return; return;
} }
// Try to find in already fetched groups first
const found = allFetchedGroups.value.find(g => g.id === currentGroupId.value); const found = allFetchedGroups.value.find(g => g.id === currentGroupId.value);
if (found) { if (found) {
currentViewedGroup.value = found; currentViewedGroup.value = found;
return; return;
} }
// If not found, fetch it specifically (might happen if navigating directly)
try { try {
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BY_ID(String(currentGroupId.value))); const response = await apiClient.get(API_ENDPOINTS.GROUPS.BY_ID(String(currentGroupId.value)));
currentViewedGroup.value = response.data as Group; currentViewedGroup.value = response.data as Group;
} catch (err) { } catch (err) {
console.error(`Failed to fetch group name for ID ${currentGroupId.value}:`, err); console.error(`Failed to fetch group name for ID ${currentGroupId.value}:`, err);
currentViewedGroup.value = null; // Set to null if fetch fails currentViewedGroup.value = null;
} }
}; };
@ -171,45 +178,46 @@ const fetchAllAccessibleGroups = async () => {
} }
}; };
// Cache lists in localStorage
const cachedLists = useStorage<(List & { items: Item[] })[]>('cached-lists', []); const cachedLists = useStorage<(List & { items: Item[] })[]>('cached-lists', []);
const cachedTimestamp = useStorage<number>('cached-lists-timestamp', 0); const cachedTimestamp = useStorage<number>('cached-lists-timestamp', 0);
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
const loadCachedData = () => { const loadCachedData = () => {
const now = Date.now(); const now = Date.now();
if (cachedLists.value.length > 0 && (now - cachedTimestamp.value) < CACHE_DURATION) { if (cachedLists.value.length > 0 && (now - cachedTimestamp.value) < CACHE_DURATION) {
lists.value = cachedLists.value; lists.value = JSON.parse(JSON.stringify(cachedLists.value));
loading.value = false;
} }
}; };
const fetchLists = async () => { const fetchLists = async () => {
loading.value = true;
error.value = null;
try { try {
const endpoint = currentGroupId.value const endpoint = currentGroupId.value
? API_ENDPOINTS.GROUPS.LISTS(String(currentGroupId.value)) ? API_ENDPOINTS.GROUPS.LISTS(String(currentGroupId.value))
: API_ENDPOINTS.LISTS.BASE; : API_ENDPOINTS.LISTS.BASE;
const response = await apiClient.get(endpoint); const response = await apiClient.get(endpoint);
lists.value = response.data as (List & { items: Item[] })[]; lists.value = response.data as (List & { items: Item[] })[];
cachedLists.value = JSON.parse(JSON.stringify(response.data));
// Update cache
cachedLists.value = response.data;
cachedTimestamp.value = Date.now(); cachedTimestamp.value = Date.now();
} catch (err: unknown) { } catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to fetch lists.'; error.value = err instanceof Error ? err.message : 'Failed to fetch lists.';
console.error(error.value, err); console.error(error.value, err);
// If we have cached data, keep showing it even if refresh failed if (cachedLists.value.length === 0) lists.value = [];
if (cachedLists.value.length === 0) { } finally {
lists.value = []; loading.value = false;
}
} }
}; };
const fetchListsAndGroups = async () => { const fetchListsAndGroups = async () => {
loading.value = true;
await Promise.all([ await Promise.all([
fetchLists(), fetchLists(),
fetchAllAccessibleGroups() fetchAllAccessibleGroups()
]); ]);
await fetchCurrentViewGroupName(); await fetchCurrentViewGroupName();
loading.value = false;
}; };
const availableGroupsForModal = computed(() => { const availableGroupsForModal = computed(() => {
@ -225,16 +233,20 @@ const getGroupName = (groupId?: number | null): string | undefined => {
} }
const onListCreated = (newList: List & { items: Item[] }) => { const onListCreated = (newList: List & { items: Item[] }) => {
lists.value = [...lists.value, newList]; lists.value.push(newList);
// Update cache cachedLists.value = JSON.parse(JSON.stringify(lists.value));
cachedLists.value = lists.value;
cachedTimestamp.value = Date.now(); cachedTimestamp.value = Date.now();
// Consider animating new list card in if desired
}; };
const toggleItem = async (list: (List & { items: Item[] }), item: Item) => { const toggleItem = async (list: List, item: Item) => {
const original = item.is_complete; if (typeof item.id === 'string' && item.id.startsWith('temp-')) {
return;
}
const originalIsComplete = item.is_complete;
item.is_complete = !item.is_complete; item.is_complete = !item.is_complete;
item.updating = true; item.updating = true;
try { try {
await apiClient.put( await apiClient.put(
API_ENDPOINTS.LISTS.ITEM(String(list.id), String(item.id)), API_ENDPOINTS.LISTS.ITEM(String(list.id), String(item.id)),
@ -248,34 +260,86 @@ const toggleItem = async (list: (List & { items: Item[] }), item: Item) => {
); );
item.version++; item.version++;
} catch (err) { } catch (err) {
item.is_complete = original; item.is_complete = originalIsComplete;
console.error('Failed to update item:', err); console.error('Failed to update item:', err);
const itemElement = document.querySelector(`.neo-list-item[data-item-id="${item.id}"]`);
if (itemElement) {
itemElement.classList.add('error-flash');
setTimeout(() => itemElement.classList.remove('error-flash'), 800);
}
} finally { } finally {
item.updating = false; item.updating = false;
} }
}; };
const addNewItem = async (list: (List & { items: Item[] }), event: Event) => { const addNewItem = async (list: List, event: Event) => {
const input = event.target as HTMLInputElement; const inputElement = event.target as HTMLInputElement;
const itemName = input.value.trim(); const itemName = inputElement.value.trim();
if (!itemName) { if (!itemName) {
input.value = ''; if (event.type === 'blur') inputElement.value = '';
return; return;
} }
const localTempId = `temp-${Date.now()}`;
const newItem: Item = {
id: localTempId,
tempId: localTempId,
name: itemName,
is_complete: false,
version: 0,
updating: true,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
list.items.push(newItem);
const originalInputValue = inputElement.value;
inputElement.value = '';
inputElement.disabled = true;
await nextTick();
const newItemLiElement = document.querySelector(`.neo-list-item[data-item-temp-id="${localTempId}"]`);
if (newItemLiElement) {
newItemLiElement.classList.add('item-appear');
}
try { try {
const response = await apiClient.post(API_ENDPOINTS.LISTS.ITEMS(String(list.id)), { const response = await apiClient.post(API_ENDPOINTS.LISTS.ITEMS(String(list.id)), {
name: itemName, name: itemName,
is_complete: false, is_complete: false,
quantity: null,
price: null
}); });
const addedItemFromServer = response.data as Item;
list.items.push(response.data as Item); const itemIndex = list.items.findIndex(i => i.tempId === localTempId);
input.value = ''; if (itemIndex !== -1) {
list.items.splice(itemIndex, 1, {
...newItem,
...addedItemFromServer,
updating: false,
tempId: undefined
});
}
if (event.type === 'keyup' && (event as KeyboardEvent).key === 'Enter') {
inputElement.disabled = false;
inputElement.focus();
} else {
inputElement.disabled = false;
}
} catch (err) { } catch (err) {
console.error('Failed to add new item:', err); console.error('Failed to add new item:', err);
list.items = list.items.filter(i => i.tempId !== localTempId);
inputElement.value = originalInputValue;
inputElement.disabled = false;
animate(inputElement, { borderColor: ['red', '#ccc'] }, { duration: 0.5 });
}
};
const handleNewItemBlur = (list: List, event: Event) => {
const inputElement = event.target as HTMLInputElement;
if (inputElement.value.trim()) {
addNewItem(list, event);
} }
}; };
@ -290,10 +354,9 @@ const navigateToList = (listId: number) => {
}; };
sessionStorage.setItem('listDetailShell', JSON.stringify(listShell)); sessionStorage.setItem('listDetailShell', JSON.stringify(listShell));
} }
router.push({ name: 'ListDetail', params: { id: listId } }); router.push({ name: 'ListDetail', params: { id: listId } }); // Ensure 'ListDetail' route exists
}; };
// Add pre-fetching functionality using Intersection Observer
const prefetchListDetails = async (listId: number) => { const prefetchListDetails = async (listId: number) => {
try { try {
const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(String(listId))); const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(String(listId)));
@ -304,9 +367,11 @@ const prefetchListDetails = async (listId: number) => {
} }
}; };
// Setup Intersection Observer for pre-fetching let intersectionObserver: IntersectionObserver | null = null;
const setupIntersectionObserver = () => { const setupIntersectionObserver = () => {
const observer = new IntersectionObserver((entries) => { if (intersectionObserver) intersectionObserver.disconnect();
intersectionObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => { entries.forEach(entry => {
if (entry.isIntersecting) { if (entry.isIntersecting) {
const listId = entry.target.getAttribute('data-list-id'); const listId = entry.target.getAttribute('data-list-id');
@ -319,54 +384,71 @@ const setupIntersectionObserver = () => {
} }
}); });
}, { }, {
rootMargin: '50px 0px', // Start loading when card is 50px from viewport rootMargin: '100px 0px',
threshold: 0.1 // Trigger when at least 10% of the card is visible threshold: 0.01
}); });
// Observe all list cards nextTick(() => {
document.querySelectorAll('.neo-list-card').forEach(card => { document.querySelectorAll('.neo-list-card[data-list-id]').forEach(card => {
observer.observe(card); intersectionObserver!.observe(card);
});
}); });
return observer;
}; };
// Touch feedback state
const touchActiveListId = ref<number | null>(null); const touchActiveListId = ref<number | null>(null);
const handleTouchStart = (listId: number) => { touchActiveListId.value = listId; };
const handleTouchStart = (listId: number) => { const handleTouchEnd = () => { touchActiveListId.value = null; };
touchActiveListId.value = listId;
};
const handleTouchEnd = () => {
touchActiveListId.value = null;
};
onMounted(() => { onMounted(() => {
// Load cached data immediately
loadCachedData(); loadCachedData();
// Then fetch fresh data in background
fetchListsAndGroups().then(() => { fetchListsAndGroups().then(() => {
// Setup intersection observer after lists are loaded if (lists.value.length > 0) {
const observer = setupIntersectionObserver(); setupIntersectionObserver();
}
// Cleanup observer on component unmount
onUnmounted(() => {
observer.disconnect();
});
}); });
}); });
// Watch for changes in groupId
watch(currentGroupId, () => { watch(currentGroupId, () => {
loadCachedData(); loadCachedData();
fetchListsAndGroups(); fetchListsAndGroups().then(() => {
if (lists.value.length > 0) {
setupIntersectionObserver();
}
});
});
watch(() => lists.value.length, (newLength, oldLength) => {
if (newLength > 0 && oldLength === 0 && !loading.value) {
setupIntersectionObserver();
}
if (newLength > 0) {
nextTick(() => {
document.querySelectorAll('.neo-list-card[data-list-id]').forEach(card => {
if (intersectionObserver) {
intersectionObserver.observe(card);
}
});
});
}
});
onUnmounted(() => {
if (intersectionObserver) {
intersectionObserver.disconnect();
}
}); });
</script> </script>
<style scoped> <style scoped>
/* Ensure --light is defined in your global styles or here, e.g., :root { --light: #fff; } */
.loading-placeholder {
text-align: center;
padding: 2rem;
font-size: 1.2rem;
color: #555;
}
.page-padding { .page-padding {
padding: 1rem; padding: 1rem;
max-width: 1200px; max-width: 1200px;
@ -377,54 +459,56 @@ watch(currentGroupId, () => {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
/* Masonry grid for cards */
.neo-lists-grid { .neo-lists-grid {
columns: 3 500px; columns: 3 500px;
column-gap: 2rem; column-gap: 2rem;
margin-bottom: 2rem; margin-bottom: 2rem;
} }
/* Card styles */
.neo-list-card, .neo-list-card,
.neo-create-list-card { .neo-create-list-card {
break-inside: avoid; break-inside: avoid;
border-radius: 18px; border-radius: 18px;
box-shadow: 6px 6px 0 #111; box-shadow: 6px 6px 0 var(--dark);
width: 100%; width: 100%;
margin: 0 0 2rem 0; margin: 0 0 2rem 0;
background: var(--light); background: var(--light);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
/* padding: 2rem 2rem 1.5rem 2rem; border: 3px solid var(--dark);
padding: 2rem 2rem 1.5rem 2rem; */ padding: 1.5rem;
/* padding-inline: ; */
cursor: pointer; cursor: pointer;
/* transition: transform 0.1s ease-in-out, box-shadow 0.1s ease-in-out; */ transition: transform 0.15s ease-out, box-shadow 0.15s ease-out;
border: 3px solid #111; -webkit-tap-highlight-color: transparent;
padding-inline: 1rem;
} }
.neo-list-card:hover { .neo-list-card:hover {
/* transform: translateY(-3px); */ transform: translateY(-4px);
box-shadow: 6px 9px 0 #111; box-shadow: 6px 10px 0 var(--dark);
/* padding: 2rem 2rem 1.5rem 2rem; */ /* border-color: var(--secondary); */
border: 3px solid #111; }
.neo-list-card.touch-active {
transform: scale(0.97);
box-shadow: 3px 3px 0 var(--dark);
transition: transform 0.1s ease-out, box-shadow 0.1s ease-out;
} }
.neo-list-header { .neo-list-header {
padding-block-start: 1rem;
font-weight: 900; font-weight: 900;
font-size: 1.25rem; font-size: 1.25rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
letter-spacing: 0.5px; letter-spacing: 0.5px;
text-transform: none; color: var(--dark);
} }
.neo-list-desc { .neo-list-desc {
font-size: 1rem; font-size: 1rem;
color: #444; color: var(--dark);
opacity: 0.7;
margin-bottom: 1.2rem; margin-bottom: 1.2rem;
font-weight: 500; font-weight: 500;
line-height: 1.4;
} }
.neo-item-list { .neo-item-list {
@ -434,57 +518,193 @@ watch(currentGroupId, () => {
} }
.neo-list-item { .neo-list-item {
margin-bottom: 1.1rem; margin-bottom: 0.8rem;
font-size: 1.1rem; font-size: 1.05rem;
font-weight: 700; font-weight: 600;
display: flex; display: flex;
align-items: center; align-items: center;
position: relative;
}
.neo-list-item.is-updating .checkbox-text-span {
opacity: 0.6;
} }
.neo-checkbox-label { .neo-checkbox-label {
display: flex; display: grid;
grid-template-columns: auto 1fr;
align-items: center; align-items: center;
gap: 0.7em; gap: 0.8em;
cursor: pointer; cursor: pointer;
position: relative;
width: fit-content;
font-weight: 500;
color: #414856;
transition: color 0.3s ease;
} }
.neo-checkbox-label input[type="checkbox"] { .neo-checkbox-label input[type="checkbox"] {
width: 1.2em; appearance: none;
height: 1.2em; -webkit-appearance: none;
accent-color: #111; -moz-appearance: none;
border: 2px solid #111; position: relative;
height: 18px;
width: 18px;
outline: none;
border: 2px solid var(--dark);
margin: 0;
cursor: pointer;
background: var(--light);
border-radius: 4px; border-radius: 4px;
margin-right: 0.5em; display: grid;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
} }
.neo-completed { .neo-checkbox-label input[type="checkbox"]:hover {
text-decoration: line-through; border-color: var(--secondary);
opacity: 0.5; background: var(--light);
}
.neo-checkbox-label input[type="checkbox"]::before,
.neo-checkbox-label input[type="checkbox"]::after {
content: "";
position: absolute;
height: 2px;
background: var(--primary);
border-radius: 2px;
opacity: 0;
}
.neo-checkbox-label input[type="checkbox"]::before {
width: 0px;
right: 55%;
transform-origin: right bottom;
}
.neo-checkbox-label input[type="checkbox"]::after {
width: 0px;
left: 45%;
transform-origin: left bottom;
}
.neo-checkbox-label input[type="checkbox"]:checked {
border-color: var(--primary);
background: var(--light);
}
.neo-checkbox-label input[type="checkbox"]:checked::before {
opacity: 1;
animation: check-01 0.4s ease forwards;
}
.neo-checkbox-label input[type="checkbox"]:checked::after {
opacity: 1;
animation: check-02 0.4s ease forwards;
}
.checkbox-text-span {
position: relative;
transition: color 0.3s ease;
}
.checkbox-text-span::before,
.checkbox-text-span::after {
content: "";
position: absolute;
left: 0;
}
.checkbox-text-span::before {
height: 2px;
width: 8px;
top: 50%;
transform: translateY(-50%);
background: var(--secondary);
border-radius: 2px;
transition: background 0.3s ease;
}
.checkbox-text-span::after {
content: '';
position: absolute;
width: 4px;
height: 4px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-radius: 50%;
background: var(--accent);
opacity: 0;
pointer-events: none;
}
.neo-checkbox-label input[type="checkbox"]:checked+.checkbox-text-span::after {
animation: firework 0.6s ease forwards 0.15s;
}
.neo-completed-static {
color: var(--dark);
opacity: 0.7;
text-decoration: line-through var(--dark);
}
.new-item-input-container .neo-checkbox-label {
width: 100%;
}
.neo-new-item-input {
all: unset;
width: 100%;
font-size: 1.05rem;
font-weight: 500;
color: #444;
padding: 0.2rem 0;
border-bottom: 1px dashed #ccc;
transition: border-color 0.2s ease;
}
.neo-new-item-input:focus {
border-bottom-color: var(--secondary);
}
.neo-new-item-input::placeholder {
color: #999;
font-weight: 400;
}
.neo-new-item-input:disabled {
opacity: 0.7;
cursor: not-allowed;
background-color: transparent;
} }
.neo-create-list-card { .neo-create-list-card {
border: 3px dashed #111; border: 3px dashed var(--dark);
background: var(--light); background: var(--light);
padding: 2.5rem 0; padding: 2.5rem 0;
text-align: center; text-align: center;
font-weight: 900; font-weight: 900;
font-size: 1.1rem; font-size: 1.1rem;
color: #222; color: var(--dark);
cursor: pointer; cursor: pointer;
margin-top: 0;
transition: background 0.1s;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 120px; min-height: 120px;
margin-bottom: 2.5rem; margin-bottom: 2.5rem;
transition: all 0.15s ease-out;
} }
.neo-create-list-card:hover { .neo-create-list-card:hover {
background: #f0f0f0; background: var(--light);
transform: translateY(-3px) scale(1.01);
box-shadow: 0px 6px 8px rgba(0, 0, 0, 0.05);
/* border-color: var(--secondary); */
color: var(--primary);
} }
/* Responsive adjustments */
@media (max-width: 900px) { @media (max-width: 900px) {
.neo-lists-grid { .neo-lists-grid {
columns: 2 260px; columns: 2 260px;
@ -513,7 +733,6 @@ watch(currentGroupId, () => {
margin-bottom: 1rem; margin-bottom: 1rem;
padding: 1rem; padding: 1rem;
font-size: 1rem; font-size: 1rem;
/* Optimize touch target size */
min-height: 80px; min-height: 80px;
} }
@ -527,52 +746,130 @@ watch(currentGroupId, () => {
margin-bottom: 0.8rem; margin-bottom: 0.8rem;
} }
/* Touch feedback */
.neo-list-card.touch-active {
transform: scale(0.98);
transition: transform 0.1s ease-out;
}
/* Optimize checkbox size for touch */
.neo-checkbox-label input[type="checkbox"] { .neo-checkbox-label input[type="checkbox"] {
width: 1.4em; width: 1.4em;
height: 1.4em; height: 1.4em;
} }
/* Optimize item spacing for touch */
.neo-list-item { .neo-list-item {
padding: 0.8rem 0; /* padding: 0.8rem 0; */
margin-bottom: 0.5rem; /* Removed as margin-bottom is used */
margin-bottom: 0.7rem;
/* Adjusted for mobile */
} }
} }
.neo-new-item-input { @keyframes check-01 {
outline: none; 0% {
border: none; width: 4px;
margin-top: 0.5rem; top: auto;
padding: 0.5rem; transform: rotate(0);
background-color: var(--light) !important; }
50% {
width: 0px;
top: auto;
transform: rotate(0);
}
51% {
width: 0px;
top: 8px;
transform: rotate(45deg);
}
100% {
width: 6px;
top: 8px;
transform: rotate(45deg);
}
} }
.neo-new-item-input input[type="text"] { @keyframes check-02 {
border: none; 0% {
outline: none; width: 4px;
all: unset; top: auto;
width: 100%; transform: rotate(0);
font-size: 1.1rem; }
font-weight: 700;
color: #444; 50% {
width: 0px;
top: auto;
transform: rotate(0);
}
51% {
width: 0px;
top: 8px;
transform: rotate(-45deg);
}
100% {
width: 11px;
top: 8px;
transform: rotate(-45deg);
}
} }
.neo-new-item-input input[type="text"]::placeholder { @keyframes firework {
color: #999; 0% {
font-weight: 500; opacity: 1;
transform: translate(-50%, -50%);
box-shadow:
0 0 0 0 var(--accent),
0 0 0 0 var(--accent),
0 0 0 0 var(--accent),
0 0 0 0 var(--accent),
0 0 0 0 var(--accent),
0 0 0 0 var(--accent);
}
50% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate(-50%, -50%);
box-shadow:
0 -15px 0 0 var(--accent),
14px -8px 0 0 var(--accent),
14px 8px 0 0 var(--accent),
0 15px 0 0 var(--accent),
-14px 8px 0 0 var(--accent),
-14px -8px 0 0 var(--accent);
}
} }
/* Add smooth transitions for all interactive elements */ @keyframes error-flash {
.neo-list-card { 0% {
transition: transform 0.1s ease-out, box-shadow 0.1s ease-out; background-color: var(--danger);
-webkit-tap-highlight-color: transparent; opacity: 0.2;
/* Remove tap highlight on iOS */ }
100% {
background-color: transparent;
opacity: 0;
}
}
@keyframes item-appear {
0% {
opacity: 0;
transform: translateY(-15px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.error-flash {
animation: error-flash 0.8s ease-out forwards;
}
.item-appear {
animation: item-appear 0.35s cubic-bezier(0.22, 1, 0.36, 1) forwards;
} }
</style> </style>