ph4 #45
93
fe/package-lock.json
generated
93
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": {
|
||||
@ -13874,4 +13927,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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