
Some checks failed
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Failing after 1m17s
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.
1764 lines
44 KiB
Vue
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>
|