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:
parent
7da93d1fe9
commit
843b3411e4
91
fe/package-lock.json
generated
91
fe/package-lock.json
generated
@ -15,6 +15,7 @@
|
||||
"@vueuse/core": "^13.1.0",
|
||||
"axios": "^1.9.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"motion": "^12.15.0",
|
||||
"pinia": "^3.0.2",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^12.0.0-alpha.2",
|
||||
@ -4420,21 +4421,6 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
|
||||
@ -7685,6 +7671,33 @@
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||
@ -9439,6 +9452,47 @@
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||
@ -10524,7 +10578,7 @@
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@ -10534,7 +10588,7 @@
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
@ -11005,7 +11059,7 @@
|
||||
"version": "0.26.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
|
||||
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
@ -12057,7 +12111,6 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
|
@ -26,6 +26,7 @@
|
||||
"@vueuse/core": "^13.1.0",
|
||||
"axios": "^1.9.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"motion": "^12.15.0",
|
||||
"pinia": "^3.0.2",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^12.0.0-alpha.2",
|
||||
|
@ -2,7 +2,7 @@
|
||||
export const API_VERSION = 'v1'
|
||||
|
||||
// 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
|
||||
export const API_ENDPOINTS = {
|
||||
|
@ -30,39 +30,35 @@
|
||||
</VCard>
|
||||
<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" />
|
||||
<VCard v-else class="mt-4">
|
||||
<VList class="item-list-tight">
|
||||
<VListItem v-for="item in list.items" :key="item.id" class="item-with-actions"
|
||||
<div v-else class="neo-item-list-container mt-4">
|
||||
<ul class="neo-item-list">
|
||||
<li v-for="item in list.items" :key="item.id" class="neo-list-item"
|
||||
:class="{ 'bg-gray-100 opacity-70': item.is_complete }">
|
||||
<template #default>
|
||||
<div class="flex items-center flex-grow gap-2">
|
||||
<VCheckbox :model-value="item.is_complete" @update:modelValue="confirmUpdateItem(item, $event)"
|
||||
:disabled="item.updating" :aria-label="item.name" />
|
||||
<div class="flex-grow">
|
||||
<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>
|
||||
<div v-if="item.is_complete" class="mt-1">
|
||||
<VInput type="number" :model-value="item.priceInput || ''"
|
||||
@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">
|
||||
<div class="neo-item-content">
|
||||
<label class="neo-checkbox-label" @click.stop>
|
||||
<input type="checkbox" :checked="item.is_complete"
|
||||
@change="(e) => confirmUpdateItem(item, (e.target as HTMLInputElement)?.checked ?? false)" />
|
||||
<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>
|
||||
</label>
|
||||
<div class="neo-item-actions">
|
||||
<button class="neo-icon-button neo-edit-button" @click.stop="editItem(item)" aria-label="Edit item">
|
||||
<VIcon name="edit" />
|
||||
</VButton>
|
||||
<VButton :icon-only="true" size="sm" variant="neutral" color="danger"
|
||||
@click.stop="confirmDeleteItem(item)" :disabled="item.deleting" aria-label="Delete item">
|
||||
</button>
|
||||
<button class="neo-icon-button neo-delete-button" @click.stop="confirmDeleteItem(item)"
|
||||
:disabled="item.deleting" aria-label="Delete item">
|
||||
<VIcon name="trash" />
|
||||
</VButton>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCard>
|
||||
</div>
|
||||
<div v-if="item.is_complete" class="neo-price-input">
|
||||
<VInput type="number" :model-value="item.priceInput || ''" @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>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
@ -1295,18 +1291,21 @@ const handleExpenseCreated = (expense: any) => {
|
||||
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 {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 2rem 0;
|
||||
break-inside: avoid;
|
||||
width: 100%;
|
||||
background: var(--light);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.neo-item {
|
||||
.neo-list-item {
|
||||
padding: 1.2rem;
|
||||
margin-bottom: 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
@ -1314,21 +1313,19 @@ const handleExpenseCreated = (expense: any) => {
|
||||
transition: background-color 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.neo-item:last-child {
|
||||
.neo-list-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.neo-item:hover {
|
||||
.neo-list-item:hover {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.neo-item-complete {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.neo-item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.neo-checkbox-label {
|
||||
@ -1336,6 +1333,7 @@ const handleExpenseCreated = (expense: any) => {
|
||||
align-items: center;
|
||||
gap: 0.7em;
|
||||
cursor: pointer;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.neo-checkbox-label input[type="checkbox"] {
|
||||
@ -1346,35 +1344,11 @@ const handleExpenseCreated = (expense: any) => {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.neo-item-details {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
/* Added for VListItem content */
|
||||
font-size: 1.1rem;
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@ -1389,6 +1363,13 @@ const handleExpenseCreated = (expense: any) => {
|
||||
display: flex;
|
||||
align-items: 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 {
|
||||
@ -1400,308 +1381,35 @@ const handleExpenseCreated = (expense: any) => {
|
||||
}
|
||||
|
||||
.neo-delete-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #e74c3c;
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.neo-delete-button:hover {
|
||||
background: #fee;
|
||||
}
|
||||
|
||||
.neo-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.neo-action-button {
|
||||
/* 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 */
|
||||
.neo-price-input {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 900px) {
|
||||
.neo-container {
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.neo-title {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
/* .neo-item { // VListItem might have its own padding
|
||||
padding: 1rem;
|
||||
} */
|
||||
padding-left: 2.2em;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.neo-container {
|
||||
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
|
||||
.neo-list-item {
|
||||
padding: 1rem;
|
||||
} */
|
||||
|
||||
.item-name {
|
||||
/* Adjusted for VListItem */
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.neo-item-quantity {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* .neo-checkbox-label input[type="checkbox"] { // VCheckbox has its own styling
|
||||
.neo-checkbox-label input[type="checkbox"] {
|
||||
width: 1.4em;
|
||||
height: 1.4em;
|
||||
} */
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.neo-icon-button {
|
||||
/* VButton icon-only replaces this */
|
||||
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 */
|
||||
|
@ -8,11 +8,8 @@
|
||||
</template>
|
||||
</VAlert>
|
||||
|
||||
<VCard v-else-if="lists.length === 0"
|
||||
variant="empty-state"
|
||||
empty-icon="clipboard"
|
||||
:empty-title="noListsMessage"
|
||||
>
|
||||
<VCard v-else-if="lists.length === 0 && !loading" variant="empty-state" empty-icon="clipboard"
|
||||
:empty-title="noListsMessage">
|
||||
<template #default>
|
||||
<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>
|
||||
@ -24,6 +21,10 @@
|
||||
</template>
|
||||
</VCard>
|
||||
|
||||
<div v-else-if="loading && lists.length === 0" class="loading-placeholder">
|
||||
Loading lists...
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="neo-lists-grid">
|
||||
<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-desc">{{ list.description || 'No description' }}</div>
|
||||
<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>
|
||||
<input type="checkbox" :checked="item.is_complete" @change="toggleItem(list, item)" />
|
||||
<span :class="{ 'neo-completed': item.is_complete }">{{ item.name }}</span>
|
||||
<input type="checkbox" :checked="item.is_complete" @change="toggleItem(list, item)"
|
||||
: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>
|
||||
</li>
|
||||
<li class="neo-list-item new-item-input">
|
||||
<li class="neo-list-item new-item-input-container">
|
||||
<label class="neo-checkbox-label">
|
||||
<input type="checkbox" disabled />
|
||||
<input type="text" class="neo-new-item-input" placeholder="Add new item..."
|
||||
@keyup.enter="addNewItem(list, $event)" @blur="addNewItem(list, $event)" @click.stop />
|
||||
<input type="text" class="neo-new-item-input" placeholder="Add new item..." ref="newItemInputRefs"
|
||||
:data-list-id="list.id" @keyup.enter="addNewItem(list, $event)"
|
||||
@blur="handleNewItemBlur(list, $event)" @click.stop />
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</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
|
||||
</div>
|
||||
</div>
|
||||
@ -59,15 +65,15 @@
|
||||
</template>
|
||||
|
||||
<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 { apiClient, API_ENDPOINTS } from '@/config/api';
|
||||
import CreateListModal from '@/components/CreateListModal.vue';
|
||||
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Adjust path as needed
|
||||
import CreateListModal from '@/components/CreateListModal.vue'; // Adjust path as needed
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import VAlert from '@/components/valerie/VAlert.vue';
|
||||
import VCard from '@/components/valerie/VCard.vue';
|
||||
import VButton from '@/components/valerie/VButton.vue';
|
||||
// VSpinner might not be needed here unless other parts use it directly
|
||||
import VAlert from '@/components/valerie/VAlert.vue'; // Adjust path as needed
|
||||
import VCard from '@/components/valerie/VCard.vue'; // Adjust path as needed
|
||||
import VButton from '@/components/valerie/VButton.vue'; // Adjust path as needed
|
||||
import { animate } from 'motion';
|
||||
|
||||
interface List {
|
||||
id: number;
|
||||
@ -88,29 +94,32 @@ interface Group {
|
||||
}
|
||||
|
||||
interface Item {
|
||||
id: number;
|
||||
id: number | string;
|
||||
tempId?: string;
|
||||
name: string;
|
||||
quantity?: string | number;
|
||||
is_complete: boolean;
|
||||
price?: number | null;
|
||||
version: number;
|
||||
updating?: boolean;
|
||||
created_at?: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
groupId?: number | string; // Prop for when ListsPage is embedded (e.g. in GroupDetailPage)
|
||||
groupId?: number | string;
|
||||
}>();
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const loading = ref(false);
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
const lists = ref<(List & { items: Item[] })[]>([]);
|
||||
const allFetchedGroups = ref<Group[]>([]);
|
||||
const currentViewedGroup = ref<Group | null>(null);
|
||||
const showCreateModal = ref(false);
|
||||
const newItemInputRefs = ref<HTMLInputElement[]>([]);
|
||||
|
||||
const currentGroupId = computed<number | null>(() => {
|
||||
const idFromProp = props.groupId;
|
||||
@ -129,19 +138,17 @@ const fetchCurrentViewGroupName = async () => {
|
||||
currentViewedGroup.value = null;
|
||||
return;
|
||||
}
|
||||
// Try to find in already fetched groups first
|
||||
const found = allFetchedGroups.value.find(g => g.id === currentGroupId.value);
|
||||
if (found) {
|
||||
currentViewedGroup.value = found;
|
||||
return;
|
||||
}
|
||||
// If not found, fetch it specifically (might happen if navigating directly)
|
||||
try {
|
||||
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BY_ID(String(currentGroupId.value)));
|
||||
currentViewedGroup.value = response.data as Group;
|
||||
} catch (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 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 now = Date.now();
|
||||
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 () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const endpoint = currentGroupId.value
|
||||
? API_ENDPOINTS.GROUPS.LISTS(String(currentGroupId.value))
|
||||
: API_ENDPOINTS.LISTS.BASE;
|
||||
const response = await apiClient.get(endpoint);
|
||||
lists.value = response.data as (List & { items: Item[] })[];
|
||||
|
||||
// Update cache
|
||||
cachedLists.value = response.data;
|
||||
cachedLists.value = JSON.parse(JSON.stringify(response.data));
|
||||
cachedTimestamp.value = Date.now();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch lists.';
|
||||
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) lists.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchListsAndGroups = async () => {
|
||||
loading.value = true;
|
||||
await Promise.all([
|
||||
fetchLists(),
|
||||
fetchAllAccessibleGroups()
|
||||
]);
|
||||
await fetchCurrentViewGroupName();
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const availableGroupsForModal = computed(() => {
|
||||
@ -225,16 +233,20 @@ const getGroupName = (groupId?: number | null): string | undefined => {
|
||||
}
|
||||
|
||||
const onListCreated = (newList: List & { items: Item[] }) => {
|
||||
lists.value = [...lists.value, newList];
|
||||
// Update cache
|
||||
cachedLists.value = lists.value;
|
||||
lists.value.push(newList);
|
||||
cachedLists.value = JSON.parse(JSON.stringify(lists.value));
|
||||
cachedTimestamp.value = Date.now();
|
||||
// Consider animating new list card in if desired
|
||||
};
|
||||
|
||||
const toggleItem = async (list: (List & { items: Item[] }), item: Item) => {
|
||||
const original = item.is_complete;
|
||||
const toggleItem = async (list: List, item: Item) => {
|
||||
if (typeof item.id === 'string' && item.id.startsWith('temp-')) {
|
||||
return;
|
||||
}
|
||||
const originalIsComplete = item.is_complete;
|
||||
item.is_complete = !item.is_complete;
|
||||
item.updating = true;
|
||||
|
||||
try {
|
||||
await apiClient.put(
|
||||
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++;
|
||||
} catch (err) {
|
||||
item.is_complete = original;
|
||||
item.is_complete = originalIsComplete;
|
||||
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 {
|
||||
item.updating = false;
|
||||
}
|
||||
};
|
||||
|
||||
const addNewItem = async (list: (List & { items: Item[] }), event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const itemName = input.value.trim();
|
||||
const addNewItem = async (list: List, event: Event) => {
|
||||
const inputElement = event.target as HTMLInputElement;
|
||||
const itemName = inputElement.value.trim();
|
||||
|
||||
if (!itemName) {
|
||||
input.value = '';
|
||||
if (event.type === 'blur') inputElement.value = '';
|
||||
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 {
|
||||
const response = await apiClient.post(API_ENDPOINTS.LISTS.ITEMS(String(list.id)), {
|
||||
name: itemName,
|
||||
is_complete: false,
|
||||
quantity: null,
|
||||
price: null
|
||||
});
|
||||
const addedItemFromServer = response.data as Item;
|
||||
|
||||
list.items.push(response.data as Item);
|
||||
input.value = '';
|
||||
const itemIndex = list.items.findIndex(i => i.tempId === localTempId);
|
||||
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) {
|
||||
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));
|
||||
}
|
||||
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) => {
|
||||
try {
|
||||
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 observer = new IntersectionObserver((entries) => {
|
||||
if (intersectionObserver) intersectionObserver.disconnect();
|
||||
|
||||
intersectionObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
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
|
||||
threshold: 0.1 // Trigger when at least 10% of the card is visible
|
||||
rootMargin: '100px 0px',
|
||||
threshold: 0.01
|
||||
});
|
||||
|
||||
// Observe all list cards
|
||||
document.querySelectorAll('.neo-list-card').forEach(card => {
|
||||
observer.observe(card);
|
||||
nextTick(() => {
|
||||
document.querySelectorAll('.neo-list-card[data-list-id]').forEach(card => {
|
||||
intersectionObserver!.observe(card);
|
||||
});
|
||||
});
|
||||
|
||||
return observer;
|
||||
};
|
||||
|
||||
// Touch feedback state
|
||||
const touchActiveListId = ref<number | null>(null);
|
||||
|
||||
const handleTouchStart = (listId: number) => {
|
||||
touchActiveListId.value = listId;
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
touchActiveListId.value = null;
|
||||
};
|
||||
const handleTouchStart = (listId: number) => { touchActiveListId.value = listId; };
|
||||
const handleTouchEnd = () => { touchActiveListId.value = null; };
|
||||
|
||||
onMounted(() => {
|
||||
// Load cached data immediately
|
||||
loadCachedData();
|
||||
|
||||
// Then fetch fresh data in background
|
||||
fetchListsAndGroups().then(() => {
|
||||
// Setup intersection observer after lists are loaded
|
||||
const observer = setupIntersectionObserver();
|
||||
|
||||
// Cleanup observer on component unmount
|
||||
onUnmounted(() => {
|
||||
observer.disconnect();
|
||||
});
|
||||
if (lists.value.length > 0) {
|
||||
setupIntersectionObserver();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Watch for changes in groupId
|
||||
watch(currentGroupId, () => {
|
||||
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>
|
||||
|
||||
<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 {
|
||||
padding: 1rem;
|
||||
max-width: 1200px;
|
||||
@ -377,54 +459,56 @@ watch(currentGroupId, () => {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Masonry grid for cards */
|
||||
.neo-lists-grid {
|
||||
columns: 3 500px;
|
||||
column-gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Card styles */
|
||||
.neo-list-card,
|
||||
.neo-create-list-card {
|
||||
break-inside: avoid;
|
||||
border-radius: 18px;
|
||||
box-shadow: 6px 6px 0 #111;
|
||||
box-shadow: 6px 6px 0 var(--dark);
|
||||
width: 100%;
|
||||
margin: 0 0 2rem 0;
|
||||
background: var(--light);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* padding: 2rem 2rem 1.5rem 2rem;
|
||||
padding: 2rem 2rem 1.5rem 2rem; */
|
||||
/* padding-inline: ; */
|
||||
border: 3px solid var(--dark);
|
||||
padding: 1.5rem;
|
||||
cursor: pointer;
|
||||
/* transition: transform 0.1s ease-in-out, box-shadow 0.1s ease-in-out; */
|
||||
border: 3px solid #111;
|
||||
padding-inline: 1rem;
|
||||
transition: transform 0.15s ease-out, box-shadow 0.15s ease-out;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.neo-list-card:hover {
|
||||
/* transform: translateY(-3px); */
|
||||
box-shadow: 6px 9px 0 #111;
|
||||
/* padding: 2rem 2rem 1.5rem 2rem; */
|
||||
border: 3px solid #111;
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 6px 10px 0 var(--dark);
|
||||
/* border-color: var(--secondary); */
|
||||
}
|
||||
|
||||
.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 {
|
||||
padding-block-start: 1rem;
|
||||
font-weight: 900;
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: none;
|
||||
color: var(--dark);
|
||||
}
|
||||
|
||||
.neo-list-desc {
|
||||
font-size: 1rem;
|
||||
color: #444;
|
||||
color: var(--dark);
|
||||
opacity: 0.7;
|
||||
margin-bottom: 1.2rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.neo-item-list {
|
||||
@ -434,57 +518,193 @@ watch(currentGroupId, () => {
|
||||
}
|
||||
|
||||
.neo-list-item {
|
||||
margin-bottom: 1.1rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.8rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.neo-list-item.is-updating .checkbox-text-span {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.neo-checkbox-label {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 0.7em;
|
||||
gap: 0.8em;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
width: fit-content;
|
||||
font-weight: 500;
|
||||
color: #414856;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.neo-checkbox-label input[type="checkbox"] {
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
accent-color: #111;
|
||||
border: 2px solid #111;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
position: relative;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
outline: none;
|
||||
border: 2px solid var(--dark);
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
background: var(--light);
|
||||
border-radius: 4px;
|
||||
margin-right: 0.5em;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.neo-completed {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.5;
|
||||
.neo-checkbox-label input[type="checkbox"]:hover {
|
||||
border-color: var(--secondary);
|
||||
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 {
|
||||
border: 3px dashed #111;
|
||||
border: 3px dashed var(--dark);
|
||||
background: var(--light);
|
||||
padding: 2.5rem 0;
|
||||
text-align: center;
|
||||
font-weight: 900;
|
||||
font-size: 1.1rem;
|
||||
color: #222;
|
||||
color: var(--dark);
|
||||
cursor: pointer;
|
||||
margin-top: 0;
|
||||
transition: background 0.1s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 120px;
|
||||
margin-bottom: 2.5rem;
|
||||
transition: all 0.15s ease-out;
|
||||
}
|
||||
|
||||
.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) {
|
||||
.neo-lists-grid {
|
||||
columns: 2 260px;
|
||||
@ -513,7 +733,6 @@ watch(currentGroupId, () => {
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
font-size: 1rem;
|
||||
/* Optimize touch target size */
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
@ -527,52 +746,130 @@ watch(currentGroupId, () => {
|
||||
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"] {
|
||||
width: 1.4em;
|
||||
height: 1.4em;
|
||||
}
|
||||
|
||||
/* Optimize item spacing for touch */
|
||||
.neo-list-item {
|
||||
padding: 0.8rem 0;
|
||||
margin-bottom: 0.5rem;
|
||||
/* padding: 0.8rem 0; */
|
||||
/* Removed as margin-bottom is used */
|
||||
margin-bottom: 0.7rem;
|
||||
/* Adjusted for mobile */
|
||||
}
|
||||
}
|
||||
|
||||
.neo-new-item-input {
|
||||
outline: none;
|
||||
border: none;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background-color: var(--light) !important;
|
||||
@keyframes check-01 {
|
||||
0% {
|
||||
width: 4px;
|
||||
top: auto;
|
||||
transform: rotate(0);
|
||||
}
|
||||
|
||||
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"] {
|
||||
border: none;
|
||||
outline: none;
|
||||
all: unset;
|
||||
width: 100%;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #444;
|
||||
@keyframes check-02 {
|
||||
0% {
|
||||
width: 4px;
|
||||
top: auto;
|
||||
transform: rotate(0);
|
||||
}
|
||||
|
||||
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 {
|
||||
color: #999;
|
||||
font-weight: 500;
|
||||
@keyframes firework {
|
||||
0% {
|
||||
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 */
|
||||
.neo-list-card {
|
||||
transition: transform 0.1s ease-out, box-shadow 0.1s ease-out;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
/* Remove tap highlight on iOS */
|
||||
@keyframes error-flash {
|
||||
0% {
|
||||
background-color: var(--danger);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
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>
|
Loading…
Reference in New Issue
Block a user