Merge pull request 'ph4' (#45) from ph4 into prod

Reviewed-on: #45
This commit is contained in:
mo 2025-06-01 22:03:25 +02:00
commit 20e1c2ac69
7 changed files with 1674 additions and 983 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 = {

File diff suppressed because it is too large Load Diff

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">
<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>
<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)"
</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" />
</button>
<button class="neo-icon-button neo-delete-button" @click.stop="confirmDeleteItem(item)"
:disabled="item.deleting" aria-label="Delete item">
<VIcon name="trash" />
</button>
</div>
</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>
</div>
<div class="flex items-center gap-1 ml-2">
<VButton :icon-only="true" size="sm" variant="neutral" @click.stop="editItem(item)"
aria-label="Edit item">
<VIcon name="edit" />
</VButton>
<VButton :icon-only="true" size="sm" variant="neutral" color="danger"
@click.stop="confirmDeleteItem(item)" :disabled="item.deleting" aria-label="Delete item">
<VIcon name="trash" />
</VButton>
</div>
</template>
</VListItem>
</VList>
</VCard>
<!-- 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,204 @@ 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.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.neo-completed {
text-decoration: line-through;
opacity: 0.5;
.neo-checkbox-label input[type="checkbox"]:hover {
border-color: var(--secondary);
background: var(--light);
transform: scale(1.05);
}
.neo-checkbox-label input[type="checkbox"]::before,
.neo-checkbox-label input[type="checkbox"]::after {
content: "";
position: absolute;
height: 2px;
background: var(--primary);
border-radius: 2px;
opacity: 0;
transition: opacity 0.2s ease;
}
.neo-checkbox-label input[type="checkbox"]::before {
width: 0px;
right: 55%;
transform-origin: right bottom;
}
.neo-checkbox-label input[type="checkbox"]::after {
width: 0px;
left: 45%;
transform-origin: left bottom;
}
.neo-checkbox-label input[type="checkbox"]:checked {
border-color: var(--primary);
background: var(--light);
transform: scale(1.05);
}
.neo-checkbox-label input[type="checkbox"]:checked::before {
opacity: 1;
animation: check-01 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.neo-checkbox-label input[type="checkbox"]:checked::after {
opacity: 1;
animation: check-02 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.checkbox-text-span {
position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.checkbox-text-span::before,
.checkbox-text-span::after {
content: "";
position: absolute;
left: 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.checkbox-text-span::before {
height: 2px;
width: 8px;
top: 50%;
transform: translateY(-50%);
background: var(--secondary);
border-radius: 2px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.checkbox-text-span::after {
content: '';
position: absolute;
width: 4px;
height: 4px;
top: 50%;
left: 130%;
transform: translate(-50%, -50%);
border-radius: 50%;
background: var(--accent);
opacity: 0;
pointer-events: none;
}
.neo-checkbox-label input[type="checkbox"]:checked+.checkbox-text-span {
color: var(--dark);
opacity: 0.7;
text-decoration: line-through var(--dark);
transform: translateX(4px);
}
.neo-checkbox-label input[type="checkbox"]:checked+.checkbox-text-span::after {
animation: firework 0.8s cubic-bezier(0.4, 0, 0.2, 1) forwards 0.15s;
}
.neo-completed-static {
color: var(--dark);
opacity: 0.7;
text-decoration: line-through var(--dark);
}
.new-item-input-container .neo-checkbox-label {
width: 100%;
}
.neo-new-item-input {
all: unset;
width: 100%;
font-size: 1.05rem;
font-weight: 500;
color: #444;
padding: 0.2rem 0;
border-bottom: 1px dashed #ccc;
transition: border-color 0.2s ease;
}
.neo-new-item-input:focus {
border-bottom-color: var(--secondary);
}
.neo-new-item-input::placeholder {
color: #999;
font-weight: 400;
}
.neo-new-item-input:disabled {
opacity: 0.7;
cursor: not-allowed;
background-color: transparent;
}
.neo-create-list-card {
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 +744,6 @@ watch(currentGroupId, () => {
margin-bottom: 1rem;
padding: 1rem;
font-size: 1rem;
/* Optimize touch target size */
min-height: 80px;
}
@ -527,52 +757,131 @@ 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%) scale(0.5);
box-shadow:
0 0 0 0 var(--accent),
0 0 0 0 var(--accent),
0 0 0 0 var(--accent),
0 0 0 0 var(--accent),
0 0 0 0 var(--accent),
0 0 0 0 var(--accent);
}
50% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(1.2);
box-shadow:
0 -15px 0 0 var(--accent),
14px -8px 0 0 var(--accent),
14px 8px 0 0 var(--accent),
0 15px 0 0 var(--accent),
-14px 8px 0 0 var(--accent),
-14px -8px 0 0 var(--accent);
}
}
/* Add smooth transitions for all interactive elements */
.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>

View File

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