ph4 #45

Merged
mo merged 3 commits from ph4 into prod 2025-06-01 22:03:25 +02:00
7 changed files with 1674 additions and 983 deletions

93
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": {
@ -13874,4 +13927,4 @@
} }
} }
} }
} }

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 = {

File diff suppressed because it is too large Load Diff

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,204 @@ 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.3s cubic-bezier(0.4, 0, 0.2, 1);
} }
.neo-completed { .neo-checkbox-label input[type="checkbox"]:hover {
text-decoration: line-through; border-color: var(--secondary);
opacity: 0.5; background: var(--light);
transform: scale(1.05);
}
.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;
transition: opacity 0.2s ease;
}
.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);
transform: scale(1.05);
}
.neo-checkbox-label input[type="checkbox"]:checked::before {
opacity: 1;
animation: check-01 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.neo-checkbox-label input[type="checkbox"]:checked::after {
opacity: 1;
animation: check-02 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.checkbox-text-span {
position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.checkbox-text-span::before,
.checkbox-text-span::after {
content: "";
position: absolute;
left: 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.checkbox-text-span::before {
height: 2px;
width: 8px;
top: 50%;
transform: translateY(-50%);
background: var(--secondary);
border-radius: 2px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.checkbox-text-span::after {
content: '';
position: absolute;
width: 4px;
height: 4px;
top: 50%;
left: 130%;
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 {
color: var(--dark);
opacity: 0.7;
text-decoration: line-through var(--dark);
transform: translateX(4px);
}
.neo-checkbox-label input[type="checkbox"]:checked+.checkbox-text-span::after {
animation: firework 0.8s cubic-bezier(0.4, 0, 0.2, 1) 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 +744,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 +757,131 @@ 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%) scale(0.5);
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;
transform: translate(-50%, -50%) scale(1);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(1.2);
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>

View File

@ -5,12 +5,12 @@ export type ChoreType = 'personal' | 'group'
export interface Chore { export interface Chore {
id: number id: number
group_id?: number group_id?: number | null
name: string name: string
description?: string description?: string | null
created_by_id: number created_by_id: number
frequency: ChoreFrequency frequency: ChoreFrequency
custom_interval_days?: number custom_interval_days?: number | null
next_due_date: string next_due_date: string
last_completed_at?: string last_completed_at?: string
created_at: string created_at: string
@ -22,36 +22,23 @@ export interface Chore {
email: string email: string
} }
assignments?: ChoreAssignment[] assignments?: ChoreAssignment[]
is_completed: boolean
completed_at: string | null
} }
export interface ChoreCreate { export interface ChoreCreate extends Omit<Chore, 'id'> { }
name: string
description?: string
frequency: ChoreFrequency
custom_interval_days?: number
next_due_date: string
type: ChoreType
group_id?: number
}
export interface ChoreUpdate { export interface ChoreUpdate extends Partial<ChoreCreate> { }
name?: string
description?: string
frequency?: ChoreFrequency
custom_interval_days?: number
next_due_date?: string
type?: ChoreType
group_id?: number
}
// Chore Assignment Types // Chore Assignment Types
export interface ChoreAssignment { export interface ChoreAssignment {
id: number id: number
chore_id: number chore_id: number
assigned_to_user_id: number assigned_to_id: number
assigned_by_id: number
due_date: string due_date: string
is_complete: boolean is_complete: boolean
completed_at?: string completed_at: string | null
created_at: string created_at: string
updated_at: string updated_at: string
chore?: Chore chore?: Chore
@ -60,11 +47,11 @@ export interface ChoreAssignment {
export interface ChoreAssignmentCreate { export interface ChoreAssignmentCreate {
chore_id: number chore_id: number
assigned_to_user_id: number assigned_to_id: number
due_date: string due_date: string
} }
export interface ChoreAssignmentUpdate { export interface ChoreAssignmentUpdate {
is_complete?: boolean
due_date?: string due_date?: string
is_complete?: boolean
} }