mitlist/fe/src/pages/ListsPage.vue
mohamad 66daa19cd5
Some checks failed
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Failing after 1m17s
feat: Implement WebSocket enhancements and introduce new components for settlements
This commit includes several key updates and new features:

- Enhanced WebSocket functionality across various components, improving real-time communication and user experience.
- Introduced new components for managing settlements, including `SettlementCard.vue`, `SettlementForm.vue`, and `SuggestedSettlementsCard.vue`, to streamline financial interactions.
- Updated existing components and services to support the new settlement features, ensuring consistency and improved performance.
- Added advanced performance optimizations to enhance loading times and responsiveness throughout the application.

These changes aim to provide a more robust and user-friendly experience in managing financial settlements and real-time interactions.
2025-06-30 01:07:10 +02:00

1764 lines
44 KiB
Vue

<template>
<!-- Smart Page Header -->
<header class="page-header mb-8">
<div class="header-content">
<div class="header-main">
<h1 class="page-title">{{ pageTitle }}</h1>
<p v-if="currentGroupId" class="page-subtitle">
{{ t('listsPage.groupContext', 'Shared with your group') }}
</p>
<p v-else class="page-subtitle">
{{ t('listsPage.personalContext', 'Your personal lists') }}
</p>
</div>
<!-- Quick Stats -->
<div class="quick-stats">
<div class="stat-item">
<span class="stat-number">{{ filteredLists.length }}</span>
<span class="stat-label">{{ t('listsPage.stats.lists', 'Lists') }}</span>
</div>
<div v-if="totalItems > 0" class="stat-item">
<span class="stat-number">{{ totalItems }}</span>
<span class="stat-label">{{ t('listsPage.stats.items', 'Items') }}</span>
</div>
</div>
</div>
</header>
<!-- Error State with Retry -->
<Alert v-if="error" type="error" class="error-alert">
<div class="error-content">
<h3>{{ t('listsPage.error.title', 'Failed to load lists') }}</h3>
<p>{{ error }}</p>
<Button color="error" size="sm" @click="fetchListsAndGroups" class="retry-button">
<span class="material-icons">refresh</span>
{{ t('listsPage.retryButton', 'Retry') }}
</Button>
</div>
</Alert>
<!-- Enhanced Empty State -->
<div v-else-if="filteredLists.length === 0 && !loading" class="empty-state">
<div class="empty-illustration">
<div class="empty-icon-container">
<span class="material-icons">playlist_add</span>
</div>
</div>
<div class="empty-content">
<h3 class="empty-title">{{ t(noListsMessageKey, 'No lists yet') }}</h3>
<p class="empty-description">
<span v-if="!currentGroupId">{{ t('listsPage.emptyState.personalGlobalInfo', 'Create your first list!')
}}</span>
<span v-else>{{ t('listsPage.emptyState.groupSpecificInfo', 'Start collaborating with your group!') }}</span>
</p>
<!-- Quick Start Actions -->
<div class="empty-actions">
<Button @click="showCreateModal = true" class="primary-action">
<span class="material-icons">add</span>
{{ t('listsPage.createNewListButton', 'Create Your First List') }}
</Button>
<div class="suggested-templates">
<p class="templates-label">{{ t('listsPage.quickStart', 'Quick start:') }}</p>
<div class="template-buttons">
<button v-for="template in quickTemplates" :key="template.id" class="template-button"
@click="createFromTemplate(template)">
<span class="material-icons">{{ template.icon }}</span>
{{ template.name }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Enhanced Loading State -->
<div v-else-if="loading && filteredLists.length === 0" class="loading-container">
<div class="loading-grid">
<div v-for="i in 6" :key="i" class="loading-list-card">
<div class="loading-header">
<div class="skeleton-line title"></div>
<div class="skeleton-line subtitle"></div>
</div>
<div class="loading-items">
<div v-for="j in 3" :key="j" class="skeleton-line item"></div>
</div>
</div>
</div>
</div>
<!-- Enhanced Lists Grid -->
<div v-else class="lists-container">
<!-- Smart Filters -->
<div v-if="filteredLists.length > 3" class="filters-section">
<div class="filter-pills">
<button v-for="filter in availableFilters" :key="filter.id" class="filter-pill"
:class="{ active: activeFilter === filter.id }" @click="setActiveFilter(filter.id)">
<span class="material-icons">{{ filter.icon }}</span>
{{ filter.label }}
<span v-if="filter.count > 0" class="filter-count">{{ filter.count }}</span>
</button>
</div>
</div>
<!-- Lists Grid with Improved UX -->
<div class="lists-grid">
<!-- Active Lists -->
<div v-for="list in visibleLists" :key="list.id" class="list-card" :class="{
'touch-active': touchActiveListId === list.id,
'archived': list.archived_at,
'has-activity': hasRecentActivity(list),
'collaborative': list.group_id
}" @click="navigateToList(list.id)" @touchstart.passive="handleTouchStart(list.id)"
@touchend.passive="handleTouchEnd" @touchcancel.passive="handleTouchEnd" :data-list-id="list.id"
:ref="el => setListCardRef(el, list.id)">
<!-- List Header with Enhanced Visual Hierarchy -->
<header class="list-header">
<div class="list-title-section">
<h3 class="list-title">{{ list.name }}</h3>
<div class="list-metadata">
<span v-if="list.group_id" class="metadata-badge group">
<span class="material-icons">group</span>
{{ t('listsPage.shared', 'Shared') }}
</span>
<span v-if="hasRecentActivity(list)" class="metadata-badge recent">
<span class="material-icons">schedule</span>
{{ t('listsPage.recentActivity', 'Active') }}
</span>
</div>
</div>
<!-- Enhanced Actions Menu -->
<div class="list-actions">
<button class="action-trigger" @click.stop="toggleActionsMenu(list.id)"
:aria-expanded="actionsMenuVisibleFor === list.id"
:aria-label="t('listsPage.moreActions', 'More actions')">
<span class="material-icons">more_vert</span>
</button>
<Transition name="dropdown-scale">
<div v-if="actionsMenuVisibleFor === list.id" class="actions-dropdown">
<button v-if="!list.archived_at" class="dropdown-action" @click.stop="archiveOrUnarchive(list)">
<span class="material-icons">archive</span>
{{ t('listsPage.archive', 'Archive') }}
</button>
<button v-if="list.archived_at" class="dropdown-action" @click.stop="archiveOrUnarchive(list)">
<span class="material-icons">unarchive</span>
{{ t('listsPage.unarchive', 'Unarchive') }}
</button>
<button class="dropdown-action" @click.stop="editList(list)">
<span class="material-icons">edit</span>
{{ t('listsPage.edit', 'Edit') }}
</button>
<button class="dropdown-action" @click.stop="duplicateList(list)">
<span class="material-icons">content_copy</span>
{{ t('listsPage.duplicate', 'Duplicate') }}
</button>
</div>
</Transition>
</div>
</header>
<!-- List Description -->
<p v-if="list.description" class="list-description">
{{ list.description }}
</p>
<p v-else class="list-description placeholder">
{{ t('listsPage.noDescription', 'No description') }}
</p>
<!-- Enhanced Items Preview -->
<div class="list-items-preview">
<!-- Completed Items Summary -->
<div v-if="getCompletedCount(list.items) > 0" class="completion-summary">
<div class="completion-bar">
<div class="completion-fill" :style="{ width: `${getCompletionPercentage(list.items)}%` }"></div>
</div>
<span class="completion-text">
{{ getCompletedCount(list.items) }}/{{ list.items.length }}
{{ t('listsPage.completed', 'completed') }}
</span>
</div>
<!-- Items List -->
<ul class="items-preview-list">
<li v-for="item in getPreviewItems(list.items)" :key="item.id || item.tempId" class="preview-item" :class="{
'completed': item.is_complete,
'updating': item.updating
}">
<label class="item-checkbox-label" @click.stop>
<input type="checkbox" :checked="item.is_complete" @change="toggleItem(list, item)"
:disabled="item.id === undefined && item.tempId !== undefined" class="item-checkbox" />
<span class="item-text" :class="{ 'completed': item.is_complete }">
{{ item.name }}
</span>
</label>
</li>
<!-- Show more indicator -->
<li v-if="list.items.length > 3" class="more-items-indicator">
<span class="more-text">
+{{ list.items.length - 3 }} {{ t('listsPage.moreItems', 'more items') }}
</span>
</li>
</ul>
<!-- Quick Add (only for non-archived lists) -->
<div v-if="!list.archived_at" class="quick-add-container">
<div class="quick-add-input">
<input type="text" :placeholder="t('listsPage.addItemPlaceholder', 'Add item...')"
:ref="el => setNewItemInputRef(el as HTMLInputElement | null, list.id)" :data-list-id="list.id"
@keyup.enter="addNewItem(list, $event)" @blur="handleNewItemBlur(list, $event)" @click.stop
class="add-input" />
<span class="material-icons add-icon">add</span>
</div>
</div>
</div>
</div>
<!-- Create New List Card -->
<div class="create-list-card" @click="showCreateModal = true" ref="createListCardRef">
<div class="create-content">
<div class="create-icon">
<span class="material-icons">add</span>
</div>
<h3 class="create-title">{{ t('listsPage.createCard.title', 'Create New List') }}</h3>
<p class="create-subtitle">{{ t('listsPage.createCard.subtitle', 'Start organizing your tasks') }}</p>
</div>
</div>
</div>
</div>
<!-- Enhanced Create List Modal -->
<CreateListModal v-model="showCreateModal" :groups="availableGroupsForModal" @created="onListCreated" />
</template>
<script setup lang="ts">
import { ref, onMounted, computed, watch, onUnmounted, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { apiClient, API_ENDPOINTS } from '@/services/api';
import CreateListModal from '@/components/CreateListModal.vue';
import { useStorage, onClickOutside } from '@vueuse/core';
import { Alert, Button } from '@/components/ui'
import { useSocket } from '@/composables/useSocket'
import { useItemHelpers } from '@/composables/useItemHelpers'
const { t } = useI18n();
const { getCompletedCount, getCompletionPercentage, getPreviewItems } = useItemHelpers();
interface ListStatus {
id: number;
updated_at: string;
item_count: number;
latest_item_updated_at: string | null;
}
interface List {
id: number;
name: string;
description?: string;
is_complete: boolean;
updated_at: string;
created_by_id: number;
group_id?: number | null;
created_at: string;
version: number;
items: Item[];
archived_at?: string | null;
}
interface Group {
id: number;
name: string;
}
interface Item {
id: number | string;
tempId?: string;
name: string;
quantity?: string | null;
is_complete: boolean;
price?: number | null;
version: number;
updating?: boolean;
created_at?: string;
updated_at: string;
}
const props = defineProps<{
groupId?: number | string;
}>();
const route = useRoute();
const router = useRouter();
// Enhanced reactive state
const loading = ref(true);
const error = ref<string | null>(null);
const lists = ref<(List & { items: Item[] })[]>([]);
const archivedLists = ref<List[]>([]);
const haveFetchedArchived = ref(false);
const allFetchedGroups = ref<Group[]>([]);
const currentViewedGroup = ref<Group | null>(null);
const showCreateModal = ref(false);
const newItemInputRefs = ref<Map<number, HTMLInputElement>>(new Map());
const actionsMenuVisibleFor = ref<number | null>(null);
const listCardRefs = ref<Map<number, HTMLElement>>(new Map());
const touchActiveListId = ref<number | null>(null);
const activeFilter = ref('all');
let pollingInterval: ReturnType<typeof setInterval> | null = null;
// Quick templates for empty state
const quickTemplates = computed(() => [
{
id: 'grocery',
name: t('listsPage.templates.grocery', 'Grocery List'),
icon: 'shopping_cart',
items: ['Milk', 'Bread', 'Eggs']
},
{
id: 'todo',
name: t('listsPage.templates.todo', 'To-Do'),
icon: 'task_alt',
items: ['Important task', 'Call someone', 'Schedule appointment']
},
{
id: 'packing',
name: t('listsPage.templates.packing', 'Packing'),
icon: 'luggage',
items: ['Clothes', 'Toiletries', 'Documents']
}
]);
// Filtered lists (move this up for dependency order)
const filteredLists = computed<(List & { items: Item[] })[]>(() => {
if (showArchived.value) {
const combined = [...lists.value, ...archivedLists.value];
const uniqueLists = Array.from(new Map(combined.map(l => [l.id, l])).values());
return uniqueLists.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
}
return lists.value.filter((list) => !list.archived_at);
});
// Enhanced computed properties
const totalItems = computed(() =>
filteredLists.value.reduce((sum: number, list: List & { items: Item[] }) => sum + list.items.length, 0)
);
const visibleLists = computed(() => {
let filtered = filteredLists.value;
switch (activeFilter.value) {
case 'active':
filtered = filtered.filter((l: List) => !l.archived_at && hasIncompleteItems(l));
break;
case 'completed':
filtered = filtered.filter((l: List) => !l.archived_at && isListCompleted(l));
break;
case 'shared':
filtered = filtered.filter((l: List) => l.group_id);
break;
default:
break;
}
return filtered;
});
const availableFilters = computed(() => [
{
id: 'all',
label: t('listsPage.filters.all', 'All'),
icon: 'list',
count: filteredLists.value.length
},
{
id: 'active',
label: t('listsPage.filters.active', 'Active'),
icon: 'radio_button_checked',
count: filteredLists.value.filter((l: List) => !l.archived_at && hasIncompleteItems(l)).length
},
{
id: 'completed',
label: t('listsPage.filters.completed', 'Completed'),
icon: 'check_circle',
count: filteredLists.value.filter((l: List) => !l.archived_at && isListCompleted(l)).length
},
{
id: 'shared',
label: t('listsPage.filters.shared', 'Shared'),
icon: 'group',
count: filteredLists.value.filter((l: List) => l.group_id).length
}
]);
// Enhanced utility functions
const hasRecentActivity = (list: List) => {
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
return new Date(list.updated_at) > oneDayAgo;
};
const hasIncompleteItems = (list: List) => {
return list.items.some(item => !item.is_complete);
};
const isListCompleted = (list: List) => {
return list.items.length > 0 && list.items.every(item => item.is_complete);
};
const setActiveFilter = (filterId: string) => {
activeFilter.value = filterId;
};
const createFromTemplate = async (template: any) => {
// Implementation for creating list from template
showCreateModal.value = true;
// Pass template data to modal
};
// Enhanced ref setters
const setNewItemInputRef = (el: HTMLInputElement | null, listId: number) => {
if (el) {
newItemInputRefs.value.set(listId, el);
} else {
newItemInputRefs.value.delete(listId);
}
};
const currentGroupId = computed<number | null>(() => {
const idFromProp = props.groupId;
const idFromRoute = route.params.groupId;
if (idFromProp) {
return typeof idFromProp === 'string' ? parseInt(idFromProp, 10) : idFromProp;
}
if (idFromRoute) {
return parseInt(idFromRoute as string, 10);
}
return null;
});
const pageTitle = computed(() => {
if (currentGroupId.value) {
return currentViewedGroup.value
? t('listsPage.pageTitle.forGroup', { groupName: currentViewedGroup.value.name })
: t('listsPage.pageTitle.forGroupId', { groupId: currentGroupId.value });
}
return t('listsPage.pageTitle.myLists');
});
const noListsMessageKey = computed(() => {
if (currentGroupId.value) {
return 'listsPage.emptyState.noListsForGroup';
}
return 'listsPage.emptyState.noListsYet';
});
const fetchAllAccessibleGroups = async () => {
try {
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
allFetchedGroups.value = (response.data as Group[]);
} catch (err) {
console.error('Failed to fetch groups for modal:', err);
}
};
const cachedLists = useStorage<(List & { items: Item[] })[]>('cached-lists', []);
const cachedTimestamp = useStorage<number>('cached-lists-timestamp', 0);
const CACHE_DURATION = 5 * 60 * 1000;
const loadCachedData = () => {
const now = Date.now();
if (cachedLists.value.length > 0 && (now - cachedTimestamp.value) < CACHE_DURATION) {
lists.value = JSON.parse(JSON.stringify(cachedLists.value));
loading.value = false;
}
};
const fetchLists = async () => {
error.value = null;
// Only show loading if we don't have any cached data to display
if (lists.value.length === 0) {
loading.value = true;
}
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[] })[]).map(list => ({
...list,
items: list.items || []
}));
cachedLists.value = JSON.parse(JSON.stringify(lists.value));
cachedTimestamp.value = Date.now();
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : t('listsPage.errors.fetchFailed');
console.error(error.value, err);
// If we have cached data and there's an error, don't clear the lists
if (cachedLists.value.length === 0) lists.value = [];
} finally {
loading.value = false;
}
};
const fetchArchivedLists = async () => {
if (haveFetchedArchived.value) return;
try {
const endpoint = API_ENDPOINTS.LISTS.ARCHIVED;
const response = await apiClient.get(endpoint);
archivedLists.value = response.data as List[];
haveFetchedArchived.value = true;
} catch (err) {
console.error('Failed to fetch archived lists:', err);
}
};
const fetchListsAndGroups = async () => {
// Only show loading if we don't have cached data
if (lists.value.length === 0) {
loading.value = true;
}
try {
await Promise.all([
fetchLists(),
fetchAllAccessibleGroups()
]);
} catch (err) {
console.error('Error in fetchListsAndGroups:', err);
} finally {
loading.value = false;
}
};
const availableGroupsForModal = computed(() => {
return allFetchedGroups.value.map(group => ({
label: group.name,
value: group.id,
}));
});
const getGroupName = (groupId?: number | null): string | undefined => {
if (!groupId) return undefined;
return allFetchedGroups.value.find(g => g.id === groupId)?.name;
}
const onListCreated = (newList: List & { items: Item[] }) => {
lists.value.push({
...newList,
items: newList.items || []
});
cachedLists.value = JSON.parse(JSON.stringify(lists.value));
cachedTimestamp.value = Date.now();
};
const toggleItem = async (list: List, item: Item | any) => {
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)),
{
is_complete: item.is_complete,
version: item.version,
name: item.name,
quantity: item.quantity,
price: item.price
}
);
item.version++;
} catch (err) {
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, event: Event) => {
const inputElement = event.target as HTMLInputElement;
const itemName = inputElement.value.trim();
if (!itemName) {
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,
});
const addedItemFromServer = response.data as Item;
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;
inputElement.style.transition = 'border-color 0.5s ease';
inputElement.style.borderColor = 'red';
setTimeout(() => {
inputElement.style.borderColor = '#ccc';
}, 500);
}
};
const handleNewItemBlur = (list: List, event: Event) => {
const inputElement = event.target as HTMLInputElement;
if (inputElement.value.trim()) {
addNewItem(list, event);
}
};
const navigateToList = (listId: number) => {
const selectedList = lists.value.find(l => l.id === listId);
if (selectedList) {
const listShell = {
id: selectedList.id,
name: selectedList.name,
description: selectedList.description,
group_id: selectedList.group_id,
};
sessionStorage.setItem('listDetailShell', JSON.stringify(listShell));
}
router.push({ name: 'ListDetail', params: { id: listId } });
};
const prefetchListDetails = async (listId: number) => {
try {
const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(String(listId)));
const fullListData = response.data;
sessionStorage.setItem(`listDetailFull_${listId}`, JSON.stringify(fullListData));
} catch (err) {
console.warn('Pre-fetch failed:', err);
}
};
let intersectionObserver: IntersectionObserver | null = null;
const setupIntersectionObserver = () => {
if (intersectionObserver) intersectionObserver.disconnect();
intersectionObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const listId = entry.target.getAttribute('data-list-id');
if (listId) {
const cachedFullData = sessionStorage.getItem(`listDetailFull_${listId}`);
if (!cachedFullData) {
prefetchListDetails(Number(listId));
}
}
}
});
}, {
rootMargin: '100px 0px',
threshold: 0.01
});
nextTick(() => {
document.querySelectorAll('.neo-list-card[data-list-id]').forEach(card => {
intersectionObserver!.observe(card);
});
});
};
const handleTouchStart = (listId: number) => { touchActiveListId.value = listId; };
const handleTouchEnd = () => { touchActiveListId.value = null; };
const refetchList = async (listId: number) => {
try {
const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(String(listId)));
const updatedList = response.data as List;
const listIndex = lists.value.findIndex(l => l.id === listId);
if (listIndex !== -1) {
lists.value[listIndex] = { ...updatedList, items: updatedList.items || [] };
cachedLists.value = JSON.parse(JSON.stringify(lists.value));
cachedTimestamp.value = Date.now();
} else {
}
} catch (err) {
console.error(`Failed to refetch list ${listId}:`, err);
}
};
const checkForUpdates = async () => {
if (lists.value.length === 0) {
return;
}
const listIds = lists.value.map(l => l.id);
if (listIds.length === 0) return;
try {
const response = await apiClient.get(API_ENDPOINTS.LISTS.STATUSES, {
params: { ids: listIds }
});
const statuses = response.data as ListStatus[];
for (const status of statuses) {
const localList = lists.value.find(l => l.id === status.id);
if (localList) {
const localUpdatedAt = new Date(localList.updated_at).getTime();
const remoteUpdatedAt = new Date(status.updated_at).getTime();
const localItemCount = localList.items.length;
const remoteItemCount = status.item_count;
const localLatestItemUpdate = localList.items.reduce((latest, item) => {
const itemDate = new Date(item.updated_at).getTime();
return itemDate > latest ? itemDate : latest;
}, 0);
const remoteLatestItemUpdate = status.latest_item_updated_at
? new Date(status.latest_item_updated_at).getTime()
: 0;
if (
remoteUpdatedAt > localUpdatedAt ||
localItemCount !== remoteItemCount ||
(remoteLatestItemUpdate > localLatestItemUpdate)
) {
await refetchList(status.id);
}
}
}
} catch (err) {
console.warn('Polling for list updates failed:', err);
}
};
const startPolling = () => {
stopPolling();
pollingInterval = setInterval(checkForUpdates, 15000);
};
const stopPolling = () => {
if (pollingInterval) {
clearInterval(pollingInterval);
pollingInterval = null;
}
};
const showArchived = ref(false);
watch(showArchived, (isShowing) => {
if (isShowing) {
fetchArchivedLists();
}
});
const setListCardRef = (el: any, listId: number) => {
if (el) {
listCardRefs.value.set(listId, el);
} else if (listCardRefs.value.has(listId)) {
listCardRefs.value.delete(listId);
}
};
const toggleActionsMenu = (listId: number) => {
actionsMenuVisibleFor.value = actionsMenuVisibleFor.value === listId ? null : listId;
};
const closeActionsMenu = () => {
actionsMenuVisibleFor.value = null;
};
const currentCardRef = computed(() => {
if (actionsMenuVisibleFor.value) {
return listCardRefs.value.get(actionsMenuVisibleFor.value)
}
return undefined
});
onClickOutside(currentCardRef, () => {
closeActionsMenu();
});
const archiveOrUnarchive = (list: List) => {
if (list.archived_at) {
unarchiveList(list);
} else {
archiveList(list);
}
closeActionsMenu();
};
const archiveList = async (list: List) => {
const originalArchiveState = list.archived_at;
// Optimistic update
list.archived_at = new Date().toISOString();
try {
await apiClient.delete(API_ENDPOINTS.LISTS.BY_ID(String(list.id)), {
params: { expected_version: list.version }
});
// Move from active to archived lists
const index = lists.value.findIndex(l => l.id === list.id);
if (index > -1) {
const [archivedItem] = lists.value.splice(index, 1);
archivedLists.value.push(archivedItem);
}
// Update cache
cachedLists.value = JSON.parse(JSON.stringify(lists.value));
cachedTimestamp.value = Date.now();
console.log(`Successfully archived list ${list.id}`);
} catch (err: any) {
// Rollback optimistic update
list.archived_at = originalArchiveState;
if (err.response?.status === 409) {
error.value = `List "${list.name}" was modified by someone else. Please refresh and try again.`;
} else {
error.value = err.response?.data?.detail || `Failed to archive list "${list.name}"`;
}
console.error('Failed to archive list:', err);
}
};
const unarchiveList = async (list: List) => {
const originalArchiveState = list.archived_at;
// Optimistic update
list.archived_at = undefined;
try {
const response = await apiClient.post(API_ENDPOINTS.LISTS.UNARCHIVE(String(list.id)));
// Move from archived to active lists
const index = archivedLists.value.findIndex(l => l.id === list.id);
if (index > -1) {
const [unarchivedItem] = archivedLists.value.splice(index, 1);
unarchivedItem.archived_at = undefined;
unarchivedItem.version = response.data.version; // Update version from server
lists.value.push(unarchivedItem);
}
// Update cache
cachedLists.value = JSON.parse(JSON.stringify(lists.value));
cachedTimestamp.value = Date.now();
console.log(`Successfully unarchived list ${list.id}`);
} catch (err: any) {
// Rollback optimistic update
list.archived_at = originalArchiveState;
if (err.response?.status === 409) {
error.value = `List "${list.name}" was modified by someone else. Please refresh and try again.`;
} else {
error.value = err.response?.data?.detail || `Failed to unarchive list "${list.name}"`;
}
console.error('Failed to unarchive list:', err);
}
};
const deleteList = async (list: List) => {
// Note: Backend only supports archiving, not permanent deletion
// This function archives the list if not already archived
if (list.archived_at) {
// Already archived - show info message
error.value = `List "${list.name}" is already archived. Use unarchive if you want to restore it.`;
closeActionsMenu();
return;
}
if (confirm(`Are you sure you want to archive the list "${list.name}"? You can unarchive it later if needed.`)) {
await archiveList(list);
}
closeActionsMenu();
};
const editList = async (list: List) => {
// TODO: Implement list editing functionality
console.log(`TODO: Implement edit functionality for list ${list.id}`);
closeActionsMenu();
};
const duplicateList = async (list: List) => {
// TODO: Implement list duplication functionality
console.log(`TODO: Implement duplicate functionality for list ${list.id}`);
closeActionsMenu();
};
// --- WebSocket Integration ---
const { connect: connectSocket, on: onSocket, off: offSocket, isConnected } = useSocket();
// Real-time event handlers
const handleListCreated = (payload: any) => {
const listId = payload?.list_id ?? payload?.list?.id ?? payload?.id;
if (!listId) return;
refetchList(Number(listId));
};
const handleListUpdated = (payload: any) => {
const listId = payload?.list_id ?? payload?.list?.id ?? payload?.id;
if (!listId) return;
refetchList(Number(listId));
};
const handleListDeleted = (payload: any) => {
const listId = payload?.list_id ?? payload?.listId ?? payload?.id;
if (!listId) return;
const index = lists.value.findIndex(l => l.id === listId);
if (index !== -1) {
lists.value.splice(index, 1);
cachedLists.value = JSON.parse(JSON.stringify(lists.value));
cachedTimestamp.value = Date.now();
}
};
const handleItemEvent = (payload: any) => {
const listId = payload?.item?.list_id ?? payload?.listId;
if (!listId) return;
refetchList(Number(listId));
};
onMounted(() => {
loadCachedData();
fetchListsAndGroups().then(() => {
if (lists.value.length > 0) {
setupIntersectionObserver();
startPolling();
}
});
// Establish WebSocket connection for the current household/group (if any)
if (currentGroupId.value) {
connectSocket(currentGroupId.value).catch(err => console.error('WebSocket connection failed:', err));
}
// Register WebSocket listeners
onSocket('list:created', handleListCreated);
onSocket('list:updated', handleListUpdated);
onSocket('list:deleted', handleListDeleted);
onSocket('item:created', handleItemEvent);
onSocket('item:updated', handleItemEvent);
onSocket('item:deleted', handleItemEvent);
});
watch(currentGroupId, () => {
loadCachedData();
haveFetchedArchived.value = false;
archivedLists.value = [];
fetchListsAndGroups().then(() => {
if (lists.value.length > 0) {
setupIntersectionObserver();
startPolling();
} else {
stopPolling();
}
});
if (currentGroupId.value) {
connectSocket(currentGroupId.value).catch(err => console.warn('WebSocket reconnection failed:', err));
}
});
// Pause polling while WebSocket is connected, resume if disconnected
watch(isConnected, (connected) => {
if (connected) {
stopPolling();
} else {
startPolling();
}
});
watch(() => lists.value.length, (newLength, oldLength) => {
if (newLength > 0 && oldLength === 0 && !loading.value) {
setupIntersectionObserver();
}
if (newLength > 0) {
startPolling();
nextTick(() => {
document.querySelectorAll('.neo-list-card[data-list-id]').forEach(card => {
if (intersectionObserver) {
intersectionObserver.observe(card);
}
});
});
}
});
onUnmounted(() => {
if (intersectionObserver) {
intersectionObserver.disconnect();
}
stopPolling();
// Cleanup WebSocket listeners
offSocket('list:created', handleListCreated);
offSocket('list:updated', handleListUpdated);
offSocket('list:deleted', handleListDeleted);
offSocket('item:created', handleItemEvent);
offSocket('item:updated', handleItemEvent);
offSocket('item:deleted', handleItemEvent);
});
</script>
<style scoped lang="scss">
@import url('https://fonts.googleapis.com/icon?family=Material+Icons');
/* Enhanced Page Header */
.page-header {
/* MainLayout handles spacing */
}
.header-content {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 2rem;
flex-wrap: wrap;
@media (max-width: 768px) {
flex-direction: column;
gap: 1rem;
}
}
.header-main {
flex-grow: 1;
}
.page-title {
font-size: 2rem;
font-weight: 700;
color: #1f2937;
margin: 0 0 0.5rem 0;
@media (min-width: 768px) {
font-size: 2.5rem;
}
}
.page-subtitle {
font-size: 1rem;
color: #6b7280;
margin: 0;
font-weight: 400;
}
.quick-stats {
display: flex;
gap: 2rem;
@media (max-width: 768px) {
gap: 1rem;
}
}
.stat-item {
text-align: center;
.stat-number {
display: block;
font-size: 2rem;
font-weight: 700;
color: #3b82f6;
line-height: 1;
}
.stat-label {
display: block;
font-size: 0.875rem;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 0.25rem;
}
}
/* Enhanced Error State */
.error-alert {
margin-bottom: 2rem;
}
.error-content {
text-align: center;
h3 {
margin: 0 0 0.5rem 0;
font-weight: 600;
}
p {
margin: 0 0 1rem 0;
color: #6b7280;
}
}
.retry-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
/* Enhanced Empty State */
.empty-state {
text-align: center;
padding: 4rem 2rem;
max-width: 600px;
margin: 0 auto;
}
.empty-illustration {
margin-bottom: 2rem;
}
.empty-icon-container {
display: inline-flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
background: linear-gradient(135deg, #e0e7ff, #c7d2fe);
border-radius: 50%;
margin-bottom: 1rem;
.material-icons {
font-size: 2.5rem;
color: #4f46e5;
}
}
.empty-content {
.empty-title {
font-size: 1.5rem;
font-weight: 600;
color: #111827;
margin: 0 0 0.5rem 0;
}
.empty-description {
font-size: 1rem;
color: #6b7280;
margin: 0 0 2rem 0;
line-height: 1.5;
}
}
.empty-actions {
.primary-action {
margin-bottom: 2rem;
padding: 0.75rem 2rem;
font-size: 1rem;
}
}
.suggested-templates {
.templates-label {
font-size: 0.875rem;
color: #6b7280;
margin: 0 0 1rem 0;
}
.template-buttons {
display: flex;
justify-content: center;
gap: 1rem;
flex-wrap: wrap;
}
}
.template-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.875rem;
&:hover {
border-color: #3b82f6;
background: #f8fafc;
}
.material-icons {
font-size: 1.125rem;
color: #6b7280;
}
}
/* Enhanced Loading State */
.loading-container {
padding: 2rem 0;
}
.loading-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
.loading-list-card {
background: white;
border-radius: 12px;
padding: 1.5rem;
border: 1px solid #e5e7eb;
}
.loading-header {
margin-bottom: 1rem;
.skeleton-line.title {
height: 1.25rem;
width: 70%;
margin-bottom: 0.5rem;
}
.skeleton-line.subtitle {
height: 1rem;
width: 50%;
}
}
.loading-items {
display: flex;
flex-direction: column;
gap: 0.5rem;
.skeleton-line.item {
height: 1rem;
width: 90%;
}
}
.skeleton-line {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s infinite;
border-radius: 4px;
}
@keyframes skeleton-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* Filters Section */
.filters-section {
margin-bottom: 2rem;
}
.filter-pills {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.filter-pill {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: white;
border: 1px solid #e5e7eb;
border-radius: 20px;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.875rem;
&:hover {
border-color: #3b82f6;
background: #f8fafc;
}
&.active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.material-icons {
font-size: 1rem;
}
.filter-count {
background: rgba(0, 0, 0, 0.1);
padding: 0.125rem 0.375rem;
border-radius: 8px;
font-size: 0.75rem;
font-weight: 600;
}
}
/* Enhanced Lists Grid */
.lists-container {
max-width: 1400px;
margin: 0 auto;
}
.lists-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
@media (min-width: 1200px) {
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
}
}
/* Enhanced List Cards */
.list-card {
background: white;
border-radius: 16px;
padding: 1.5rem;
border: 1px solid #e5e7eb;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
border-color: #3b82f6;
}
&.touch-active {
transform: scale(0.98);
}
&.archived {
opacity: 0.6;
background: #f9fafb;
}
&.has-activity {
border-left: 4px solid #10b981;
}
&.collaborative {
border-left: 4px solid #8b5cf6;
}
}
.list-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 1rem;
}
.list-title-section {
flex-grow: 1;
min-width: 0;
}
.list-title {
font-size: 1.125rem;
font-weight: 600;
color: #111827;
margin: 0 0 0.5rem 0;
line-height: 1.3;
}
.list-metadata {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.metadata-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 6px;
&.group {
background: #f3f4f6;
color: #6b7280;
}
&.recent {
background: #dcfce7;
color: #166534;
}
.material-icons {
font-size: 0.875rem;
}
}
.list-actions {
position: relative;
}
.action-trigger {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: none;
background: none;
color: #6b7280;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #f3f4f6;
color: #374151;
}
}
.actions-dropdown {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10;
min-width: 150px;
}
.dropdown-action {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.75rem 1rem;
background: none;
border: none;
cursor: pointer;
transition: background-color 0.2s ease;
font-size: 0.875rem;
text-align: left;
&:hover {
background: #f8f9fa;
}
&.danger {
color: #ef4444;
&:hover {
background: #fef2f2;
}
}
.material-icons {
font-size: 1rem;
}
}
.list-description {
color: #6b7280;
font-size: 0.875rem;
line-height: 1.5;
margin: 0 0 1rem 0;
&.placeholder {
font-style: italic;
opacity: 0.7;
}
}
/* Enhanced Items Preview */
.list-items-preview {
margin-top: 1rem;
}
.completion-summary {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.completion-bar {
flex-grow: 1;
height: 6px;
background: #e5e7eb;
border-radius: 3px;
overflow: hidden;
}
.completion-fill {
height: 100%;
background: linear-gradient(90deg, #10b981, #059669);
border-radius: 3px;
transition: width 0.3s ease;
}
.completion-text {
font-size: 0.75rem;
color: #6b7280;
font-weight: 500;
white-space: nowrap;
}
.items-preview-list {
list-style: none;
padding: 0;
margin: 0 0 1rem 0;
}
.preview-item {
margin-bottom: 0.5rem;
&:last-child {
margin-bottom: 0;
}
}
.item-checkbox-label {
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
padding: 0.25rem 0;
}
.item-checkbox {
width: 1rem;
height: 1rem;
accent-color: #3b82f6;
flex-shrink: 0;
}
.item-text {
font-size: 0.875rem;
color: #374151;
line-height: 1.4;
&.completed {
text-decoration: line-through;
color: #9ca3af;
}
}
.more-items-indicator {
padding: 0.5rem 0;
border-top: 1px solid #f3f4f6;
margin-top: 0.5rem;
}
.more-text {
font-size: 0.75rem;
color: #6b7280;
font-style: italic;
}
/* Quick Add Input */
.quick-add-container {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #f3f4f6;
}
.quick-add-input {
position: relative;
display: flex;
align-items: center;
}
.add-input {
width: 100%;
padding: 0.5rem 2rem 0.5rem 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
font-size: 0.875rem;
transition: all 0.2s ease;
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
&::placeholder {
color: #9ca3af;
}
}
.add-icon {
position: absolute;
right: 0.5rem;
color: #9ca3af;
font-size: 1.125rem;
pointer-events: none;
}
/* Create List Card */
.create-list-card {
background: linear-gradient(135deg, #f0f9ff, #e0f2fe);
border: 2px dashed #3b82f6;
border-radius: 16px;
padding: 2rem;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
&:hover {
background: linear-gradient(135deg, #e0f2fe, #bae6fd);
transform: translateY(-2px);
}
}
.create-content {
text-align: center;
}
.create-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
background: #3b82f6;
color: white;
border-radius: 50%;
margin-bottom: 1rem;
.material-icons {
font-size: 1.5rem;
}
}
.create-title {
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 0.5rem 0;
}
.create-subtitle {
font-size: 0.875rem;
color: #6b7280;
margin: 0;
}
/* Transitions */
.dropdown-scale-enter-active,
.dropdown-scale-leave-active {
transition: all 0.2s ease;
transform-origin: top right;
}
.dropdown-scale-enter-from,
.dropdown-scale-leave-to {
opacity: 0;
transform: scale(0.9) translateY(-4px);
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.page-title {
color: #f1f5f9;
}
.page-subtitle {
color: #cbd5e1;
}
.list-card,
.create-list-card,
.filter-pill,
.template-button,
.loading-list-card {
background: #334155;
border-color: #475569;
color: #f1f5f9;
}
.list-title {
color: #f1f5f9;
}
.list-description,
.item-text {
color: #cbd5e1;
}
}
/* Accessibility */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
</style>