Update package dependencies and refactor ListDetailPage and ListsPage components

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

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

91
fe/package-lock.json generated
View File

@ -15,6 +15,7 @@
"@vueuse/core": "^13.1.0",
"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": {

View File

@ -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",

View File

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

View File

@ -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 */

View File

@ -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>